triauth 0.0.1 → 1.0.0.beta.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +56 -0
  3. data/LICENSE +202 -28
  4. data/README.md +2036 -7
  5. data/lib/triauth/api/attestation.rb +419 -0
  6. data/lib/triauth/api/authentication.rb +651 -0
  7. data/lib/triauth/api/identity.rb +245 -0
  8. data/lib/triauth/api/signing.rb +740 -0
  9. data/lib/triauth/api/validation.rb +162 -0
  10. data/lib/triauth/api.rb +33 -0
  11. data/lib/triauth/authentication_endpoint.rb +196 -0
  12. data/lib/triauth/challenge.rb +115 -0
  13. data/lib/triauth/challenge_response_flow.rb +344 -0
  14. data/lib/triauth/config.rb +93 -0
  15. data/lib/triauth/error.rb +95 -0
  16. data/lib/triauth/helpers.rb +1222 -0
  17. data/lib/triauth/identity.rb +301 -0
  18. data/lib/triauth/identity_domain.rb +212 -0
  19. data/lib/triauth/identity_includes.rb +249 -0
  20. data/lib/triauth/identity_keys.rb +309 -0
  21. data/lib/triauth/logger.rb +156 -0
  22. data/lib/triauth/multi_signature.rb +177 -0
  23. data/lib/triauth/protocol.rb +74 -0
  24. data/lib/triauth/request_cipher.rb +121 -0
  25. data/lib/triauth/resolvers/base.rb +147 -0
  26. data/lib/triauth/resolvers/caching_resolver.rb +202 -0
  27. data/lib/triauth/resolvers/cloudflare.rb +40 -0
  28. data/lib/triauth/resolvers/dns_json.rb +391 -0
  29. data/lib/triauth/resolvers/dns_sb.rb +40 -0
  30. data/lib/triauth/resolvers/google.rb +39 -0
  31. data/lib/triauth/resolvers/multi_resolver.rb +202 -0
  32. data/lib/triauth/resolvers/system_dns.rb +259 -0
  33. data/lib/triauth/resolvers.rb +33 -0
  34. data/lib/triauth/response.rb +142 -0
  35. data/lib/triauth/response_cipher.rb +341 -0
  36. data/lib/triauth/signature.rb +405 -0
  37. data/lib/triauth/validator.rb +549 -0
  38. data/lib/triauth/verifiers/base.rb +147 -0
  39. data/lib/triauth/verifiers/ecdsa.rb +87 -0
  40. data/lib/triauth/verifiers/ed25519.rb +89 -0
  41. data/lib/triauth/verifiers/registry.rb +52 -0
  42. data/lib/triauth/verifiers/web_authn.rb +253 -0
  43. data/lib/triauth/verifiers.rb +37 -0
  44. data/lib/triauth/version.rb +23 -0
  45. data/lib/triauth.rb +104 -7
  46. metadata +55 -13
@@ -0,0 +1,301 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright (c) 2026 The Triauth Authors (https://www.triauth.org/)
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+ #
17
+ # SPDX-License-Identifier: Apache-2.0
18
+
19
+
20
+ require_relative 'error'
21
+ require_relative 'validator'
22
+ require_relative 'authentication_endpoint'
23
+ require_relative 'identity_domain'
24
+ require_relative 'identity_keys'
25
+ require_relative 'protocol'
26
+ require_relative 'helpers'
27
+ require_relative 'identity_includes'
28
+
29
+ module Triauth
30
+ # Represents an entity that is identified by the provided `identifier`.
31
+ #
32
+ # Objects of this class start in unresolved state, and need to be resolved with a call to resolve() that queries the DNS.
33
+ class Identity
34
+ # A regexp for the reserved keywords that may appear in the identity records (DNS TXT records)
35
+ # and are recognized and returned as a part of publicProfile.
36
+ PUBLIC_PROFILE_KEYWORDS = /\A(name|initials)\z/
37
+ private_constant :PUBLIC_PROFILE_KEYWORDS
38
+
39
+ # @return [String] Identifier (e.g., `john@triauthdemo.org`) for which the Identity object is constructed.
40
+ attr_reader :identifier
41
+ # @return [Hash] Resolution options (`lookupCode`: the lookup code that addresses the identity records - required in private mode).
42
+ attr_reader :options
43
+ # @return [Hash{Symbol => Object}] The effective settings: the global `Triauth.config` with the constructor's overrides merged over it.
44
+ attr_reader :config
45
+ # The public profile entries published in the identity records (e.g., `name`, `initials`, and
46
+ # any `x-` prefixed key); a nil value marks a key that is published but carries no value.
47
+ # Populated by resolve().
48
+ # @return [Hash{Symbol => String, nil}]
49
+ attr_reader :public_profile
50
+ # Fully-qualified group names this identity claims membership in (e.g., ['admins@triauthdemo.org']);
51
+ # deduplicated and sorted, populated by resolve()
52
+ # @return [Array<String>]
53
+ attr_reader :groups
54
+ # @return [String] The domain name of the identifier.
55
+ attr_reader :domain_name
56
+ # @return [Triauth::AuthenticationEndpoint] The authentication endpoint of the identifier's domain. Resolved by resolve().
57
+ attr_reader :authentication_endpoint
58
+ # @return [Triauth::IdentityDomain] The identity domain of the identifier. Resolved by resolve().
59
+ attr_reader :identity_domain
60
+ # The public keys published for this identity, grouped by device. Populated by resolve().
61
+ # @return [Triauth::IdentityKeys]
62
+ attr_reader :keys
63
+ # The delegation grants published for this identity. Populated by resolve().
64
+ # @return [Triauth::IdentityIncludes]
65
+ attr_reader :includes
66
+ # Whether the identity resolved successfully; `nil` until resolve() has run.
67
+ # @return [Boolean, nil]
68
+ attr_reader :resolved
69
+ # Whether the whole identity resolution - the endpoint record and every identity record - was
70
+ # DNSSEC-validated; computed by resolve().
71
+ # @return [Boolean]
72
+ attr_reader :secure
73
+
74
+ # Constructs a new instance of Identity for the given identifier.
75
+ #
76
+ # Objects of this class start in an un-resolved state, and a call to the resolve() method that
77
+ # performs the DNS resolution needs to be made before most of the other methods can be used.
78
+ #
79
+ # @param identifier [String] Identifier (e.g., `john@triauthdemo.org`) for which the Identity object is constructed.
80
+ # @param options [Hash] Resolution options. Defaults to `{}`.
81
+ # @option options [String] :lookupCode The lookup code that addresses the identity records - required in private mode.
82
+ # @param config [Hash] Optional overrides for the global Triauth.config settings. Defaults to `{}`.
83
+ #
84
+ # @raise [Triauth::Error] carrying the first validation error when the identifier is malformed.
85
+ def initialize(identifier, options = {}, config = {})
86
+ # Memoizes the resolution, so that concurrent and repeated `resolve()` calls share a single
87
+ # DNS round trip. A rejection is memoized too, and is replayed to every later caller.
88
+ # The memoized outcome of the first resolution: `[true, result]` or `[false, error]`, guarded by the mutex
89
+ # (the port of the `#resolvePromise` promise).
90
+ @resolve_mutex = Mutex.new
91
+ @resolve_promise = nil
92
+
93
+ @identifier = identifier
94
+ @options = options
95
+ @config = Helpers.merge_config(config)
96
+
97
+ # Ensure received identifier is valid
98
+ validation_result = Validator.validate_identifier(@identifier)
99
+ if !validation_result[:valid]
100
+ raise Triauth::Error.new(validation_result[:errors][0])
101
+ end
102
+
103
+ @public_profile = {}
104
+
105
+ @groups = []
106
+
107
+ @domain_name = @identifier.split('@', -1)[1]
108
+
109
+ @authentication_endpoint = AuthenticationEndpoint.new(@domain_name, @config)
110
+ @identity_domain = IdentityDomain.new(@identifier, @config)
111
+
112
+ @keys = IdentityKeys.new(@config)
113
+
114
+ @includes = IdentityIncludes.new(@config)
115
+
116
+ @resolved = nil
117
+
118
+ @secure = false
119
+ end
120
+
121
+ # Resolves the identity by querying the DNS.
122
+ # Identities must have at least one identity record present to be successfully resolved.
123
+ #
124
+ # The result is memoized, and subsequent calls to the `resolve` function do not perform DNS lookups.
125
+ # Rejections are currently memoized too.
126
+ #
127
+ # @return [Boolean] true if the identity has been resolved, false otherwise.
128
+ def resolve
129
+ # Memoize the outcome so that multiple and/or concurrent calls return the same status.
130
+ @resolve_mutex.synchronize do
131
+ @resolve_promise ||= begin
132
+ # The body of the memoized resolution is a lambda, so that every JS `return` is a `return` of the lambda.
133
+ resolution = lambda do
134
+ Helpers.log(@config, :debug, "Resolving Triauth.Identity for identifier #{@identifier}")
135
+
136
+ # Resolve the authenticationEndpoint to read triauth configuration, and then the identityDomain
137
+ #
138
+ # This will query the DNS for TXT records stored under the domain name of the identifier, looking for a TXT record starting with 'triauth' keyword.
139
+ # We start with this, to make sure domain is configured for triauth, and to get the mode in which identity domains should be derived from identifiers (e.g., public or private).
140
+ #
141
+ # Next, resolve the identity domain, using the options as read from the DNS TXT record defining the authentication endpoint, to obtain its identityRecords.
142
+ # This will query the DNS for TXT records stored at the domain name that is derived from the user's identifier (e.g., 'john._at.triauthdemo.org' (public mode), or '_4GIBDU53B3._at.private.triauthdemo.org' (private mode)).
143
+ if !@authentication_endpoint.resolve ||
144
+ !@identity_domain.set_options({ mode: @authentication_endpoint.options[:mode], lookupCode: @options[:lookupCode] }).resolve
145
+ @resolved = false
146
+ return [true, @resolved]
147
+ end
148
+
149
+ identity_records = @identity_domain.identity_records
150
+
151
+ # The endpoint record is a dependency of key discovery (its options select the identity domain
152
+ # the keys are read from), so keys can only be as secure as the endpoint record itself.
153
+ endpoint_secure = @authentication_endpoint.secure == true
154
+ include_policy = IdentityIncludes.parse_policy(@authentication_endpoint.options[:include])
155
+
156
+ public_profile_extension_count = 0
157
+ public_profile = {} # local copy, until object is resolved
158
+ groups = Set.new # local set of bare group names; qualified and assigned once resolved
159
+ identity_secure = endpoint_secure # identity can be only as secure as the endpoint answer
160
+ commitment = nil # the `commit` record value, if present
161
+
162
+ identity_records.each do |entry|
163
+ identity_secure &&= entry[:dnssec] == true
164
+
165
+ # DNS-derived freshness of this record: fresh until its TTL runs out
166
+ expires = (Helpers.is_integer(entry[:ttl]) ? Helpers.date_now + (entry[:ttl] * 1000) : nil)
167
+
168
+ # Profile values are the only record values that are percent-decoded
169
+ profile_value = PUBLIC_PROFILE_KEYWORDS.match?(entry[:key]) || entry[:key].start_with?('x-') ? Helpers.percent_decode(entry[:value]) : nil
170
+
171
+ # `key` record - add to this.keys (IdentityKeys)
172
+ if entry[:key] == 'key'
173
+ @keys.add(entry[:value], entry[:options], { expires: expires })
174
+
175
+ # `include` record - add to this.includes (IdentityIncludes)
176
+ elsif entry[:key] == 'include'
177
+ @includes.add(entry[:value], entry[:options], { expires: expires, includePolicy: include_policy, localDomainName: @domain_name })
178
+
179
+ # `groups` record - a comma-separated list of group names this identity claims membership in, later scoped to the identifier's domain
180
+ elsif entry[:key] == 'groups' && groups.size < Protocol::LIMITS[:maxGroups]
181
+
182
+ # No critical options are defined, so any critical option means the record is ignored ('x-' options are inert)
183
+ if !Helpers.verify_options_schema(entry[:options], {})
184
+ Helpers.log(@config, :debug, 'Ignoring groups record due to unrecognized options', { identifier: @identifier })
185
+ next
186
+ end
187
+
188
+ # Group names follow the device-name grammar; a record with any malformed name is ignored as a whole, never repaired.
189
+ names = Helpers.ascii_lowercase(entry[:value]).split(',', -1)
190
+ if !names.all? { |name| Validator.validate_group_name(name)[:valid] }
191
+ Helpers.log(@config, :debug, 'Ignoring groups record with a malformed group name', { identifier: @identifier })
192
+ next
193
+ end
194
+
195
+ names.each do |name|
196
+ next if groups.include?(name) # duplicates never consume the cap
197
+
198
+ if groups.size >= Protocol::LIMITS[:maxGroups]
199
+ Helpers.log(@config, :debug, 'Ignoring groups and further group records over the limit', { limit: Protocol::LIMITS[:maxGroups] })
200
+ break
201
+ end
202
+ groups.add(name)
203
+ end
204
+
205
+ # `commit` record - the commitment binding these records to the identity's derivation (its lookup code)
206
+ elsif entry[:key] == 'commit'
207
+
208
+ # No critical options are defined; the value is a 43-character base64url digest
209
+ if commitment.nil? &&
210
+ Helpers.verify_options_schema(entry[:options], {}) &&
211
+ Helpers.is_base64url_string(entry[:value])
212
+ commitment = entry[:value]
213
+
214
+ else
215
+ commitment = false
216
+ Helpers.log(@config, :debug, 'Ignoring malformed or duplicated commit record(s)', { identifier: @identifier })
217
+
218
+ end
219
+
220
+ # publicProfile record - value is exposed raw, so it's the responsibility of API callers to escape it before rendering
221
+ # (the publicProfile keys are Symbols, the profile's key rule)
222
+ elsif PUBLIC_PROFILE_KEYWORDS.match?(entry[:key]) &&
223
+ !profile_value.nil? &&
224
+ Helpers.is_normal_string(profile_value, Protocol::LIMITS[:publicProfileMaxValueBytesize])
225
+
226
+ # Do not allow doubled keywords - drop the whole entry from publicProfile
227
+ if public_profile.key?(entry[:key].to_sym)
228
+ public_profile[entry[:key].to_sym] = nil
229
+ Helpers.log(@config, :debug, 'Ignoring doubled publicProfile entry', { keyword: entry[:key], identifier: @identifier })
230
+ next
231
+ end
232
+
233
+ public_profile[entry[:key].to_sym] = profile_value
234
+
235
+ # Public profile extension / experimental keyword(s) in `x-` namespace
236
+ # - 'x-' namespace is open-ended, with a cap on the number of distinct extensions kept
237
+ # - similar as with recognized publicProfile entries - keys and values exposed raw, so must be escaped by callers before rendering
238
+ elsif entry[:key].start_with?('x-') &&
239
+ Helpers.is_normal_string(entry[:key], Protocol::LIMITS[:publicProfileMaxKeyBytesize]) &&
240
+ !profile_value.nil? &&
241
+ Helpers.is_normal_string(profile_value, Protocol::LIMITS[:publicProfileMaxValueBytesize])
242
+
243
+ # Do not allow doubled keywords - drop the whole entry from publicProfile
244
+ if public_profile.key?(entry[:key].to_sym)
245
+ public_profile[entry[:key].to_sym] = nil
246
+ Helpers.log(@config, :debug, 'Ignoring doubled publicProfile entry', { keyword: entry[:key], identifier: @identifier })
247
+ next
248
+ end
249
+
250
+ if public_profile_extension_count < Protocol::LIMITS[:publicProfileMaxExtensions]
251
+ public_profile[entry[:key].to_sym] = profile_value
252
+ public_profile_extension_count += 1
253
+
254
+ else
255
+ Helpers.log(@config, :debug, 'Ignoring publicProfile extension over the limit', { keyword: entry[:key], identifier: @identifier })
256
+ end
257
+
258
+ else
259
+ Helpers.log(@config, :debug, 'Ignoring unrecognized identity record', { keyword: entry[:key], identifier: @identifier, domainName: @identity_domain.domain_name })
260
+
261
+ end
262
+ end
263
+
264
+ # When 'private' mode is set for the domain, identityRecords must carry a valid/matching 'commit ...' entry, for the identity to be resolved.
265
+ # The value of `commit ...` entry is supposed to be base64url(SHA-256(<untruncated base32 label>))
266
+ if !@identity_domain.expected_commitment.nil? &&
267
+ commitment != @identity_domain.expected_commitment
268
+ Helpers.log(@config, :info, 'Commit record missing, mismatched, or duplicated - identity unresolved', { identifier: @identifier })
269
+ @resolved = false
270
+ return [true, @resolved]
271
+ end
272
+
273
+ # Remove 'nil' records from local publicProfile and assign to this.publicProfile
274
+ @public_profile = public_profile.compact # `Object.fromEntries(Object.entries(publicProfile).filter(([, v]) => v !== null))`
275
+
276
+ # Sorted (DNS answer order is not stable) and qualified with the identifier's domain
277
+ @groups = groups.to_a.sort.map { |name| "#{name}@#{@domain_name}" }
278
+
279
+ @secure = identity_secure
280
+
281
+ Helpers.log(@config, :debug, "Parsed identity records for #{@identifier} - #{@keys.count} devices, #{@public_profile.size} profile entries, #{@groups.length} groups")
282
+
283
+ @resolved = identity_records.length > 0
284
+ return [true, @resolved]
285
+ end
286
+ resolution.call
287
+ rescue StandardError => err
288
+ Helpers.log(@config, :debug, err)
289
+
290
+ @resolved = false
291
+ [false, err]
292
+ end
293
+ end
294
+
295
+ ok, value = @resolve_promise
296
+ raise value if !ok
297
+
298
+ return value
299
+ end
300
+ end
301
+ end
@@ -0,0 +1,212 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright (c) 2026 The Triauth Authors (https://www.triauth.org/)
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+ #
17
+ # SPDX-License-Identifier: Apache-2.0
18
+
19
+
20
+ require_relative 'helpers'
21
+ require_relative 'protocol'
22
+
23
+ module Triauth
24
+ # Derives a domain name under which identity records are stored, and reads the identity records from DNS
25
+ # (e.g., `john._at.triauthdemo.org` for `mode=public` domains or `_AXD3AKE8EX._at.triauthdemo.org` for `mode=private` domains).
26
+ #
27
+ # Objects of this class start in unresolved state, and need to be resolved with a call to resolve() that queries the DNS.
28
+ class IdentityDomain
29
+ # @return [Hash{Symbol => Object}] The effective settings: the global `Triauth.config` with the constructor's overrides merged over it.
30
+ attr_reader :config
31
+ # @return [String] The identifier for which identity domain is constructed.
32
+ attr_reader :identifier
33
+ # The resolution options, as fixed by `set_options(...)`; `nil` until then.
34
+ # @return [Hash, nil]
35
+ attr_reader :options
36
+ # The domain name the identity records are published under, derived from the identifier and the
37
+ # resolution mode. `nil` until resolved.
38
+ # @return [String, nil]
39
+ attr_reader :domain_name
40
+ # The identity records as read from the DNS, in `Triauth::Resolvers::Base#resolve_config` form.
41
+ # `nil` until resolved.
42
+ # @return [Array<Hash>, nil] each record `{key: String, value: String, options: Hash, ttl: (Integer, nil), dnssec: (Boolean, nil)}`
43
+ attr_reader :identity_records
44
+ # The lookup code that addressed the records in private mode. `nil` in every other mode.
45
+ # @return [String, nil]
46
+ attr_reader :lookup_code
47
+ # The commitment a private-mode record set must carry to be accepted - the digest of the derived
48
+ # full label. `nil` in every other mode.
49
+ # @return [String, nil]
50
+ attr_reader :expected_commitment
51
+ # Whether the identity domain resolved successfully; `nil` until resolve() has run.
52
+ # @return [Boolean, nil]
53
+ attr_reader :resolved
54
+
55
+ # Returns the full derived label of a private-mode identity: the base32 encoding of the HMAC-SHA-256
56
+ # of the identifier, keyed by the identity's lookup code.
57
+ #
58
+ # The identifier needs to be in downcase, as results of this function are case-sensitive.
59
+ #
60
+ # @param identifier [String] the identifier to derive from
61
+ # @param lookup_code [String] the identity's lookup code, the HMAC key
62
+ # @return [String] the 52-character base32 string whose first PRIVATE_LABEL_LENGTH characters form the label of the identity domain, e.g., '4GIBDU53B3KGFSZUD5KRVUGITFQ2QAN2C55FNXO25S53WGT6CK6A'
63
+ def self.derive_full_label(identifier, lookup_code)
64
+ return Helpers.uint8_array_to_base32(
65
+ Helpers.hmac_sha256(Helpers.string_to_utf8_bytes(lookup_code), Helpers.string_to_utf8_bytes(identifier))
66
+ )
67
+ end
68
+
69
+ # Derives a domain name under which identity records are being kept for the passed identifier.
70
+ # Expects a pre-validated identifier (see Triauth::Validator.validate_identifier).
71
+ #
72
+ # @param identifier [String] a pre-validated user identifier
73
+ # @param options [Hash] options as read from the DNS TXT record specifying the authentication endpoint (`mode`), plus the identity's `lookupCode` under `mode=private`. Defaults to `{}`.
74
+ # @return [Hash, nil] the domain name (`{domainName: String}`, carrying the untruncated label as `fullLabel` under `mode=private`), or nil when no domain name can be derived (an unknown mode, or a missing/malformed lookup code under `mode=private`)
75
+ def self.derive(identifier, options = {})
76
+ # Extract the original domain name from the identifier
77
+ identifier_domain = identifier.split('@', -1)[1]
78
+
79
+ # The mode decides the way identity domain is derived
80
+ mode = options[:mode]
81
+
82
+ if mode == 'public'
83
+ return { domainName: identifier.sub('@', Protocol::AT_MARKER) }
84
+ end
85
+
86
+ if mode == 'private'
87
+ if !Helpers.is_lookup_code(options[:lookupCode])
88
+ return nil
89
+ end
90
+
91
+ # HMAC the identifier under the lookup code, base32 encode, and keep the fixed-length label
92
+ full_label = IdentityDomain.derive_full_label(identifier, options[:lookupCode])
93
+
94
+ return { domainName: "_#{full_label[0, Protocol::PRIVATE_LABEL_LENGTH]}#{Protocol::AT_MARKER}#{identifier_domain}", fullLabel: full_label }
95
+ end
96
+
97
+ # If an unknown mode is set, return `nil`
98
+ return nil
99
+ end
100
+
101
+ # Constructs a new instance of IdentityDomain basing on the identifier.
102
+ #
103
+ # Objects of this class start in an un-resolved state, and a call to the resolve() that
104
+ # performs the DNS resolution needs to be made before most of the other methods can be used.
105
+ #
106
+ # @param identifier [String] The identifier for which identity domain is constructed.
107
+ # @param config [Hash] Optional overrides for the global Triauth.config settings. Defaults to `{}`.
108
+ def initialize(identifier, config = {})
109
+ # Memoizes the resolution, so that concurrent and repeated `resolve()` calls share a single
110
+ # DNS round trip. A rejection is memoized too, and is replayed to every later caller.
111
+ # The memoized outcome of the first resolution: `[true, result]` or `[false, error]`, guarded by the mutex
112
+ # (the port of the `#resolvePromise` promise).
113
+ @resolve_mutex = Mutex.new
114
+ @resolve_promise = nil
115
+
116
+ @config = Helpers.merge_config(config)
117
+
118
+ @identifier = identifier
119
+
120
+ @options = nil
121
+
122
+ @domain_name = nil
123
+
124
+ @identity_records = nil
125
+
126
+ @lookup_code = nil
127
+
128
+ @expected_commitment = nil
129
+
130
+ @resolved = nil
131
+ end
132
+
133
+ # Sets the options for future `resolve` calls.
134
+ # May be called only once.
135
+ #
136
+ # @param options [Hash] Options read from the authentication endpoint DNS record (e.g., `{mode: 'public'|'private'}`), plus the identity's `lookupCode` where its mode consumes one.
137
+ #
138
+ # @return [IdentityDomain] this instance, so the call chains straight into resolve().
139
+ #
140
+ # @raise [RuntimeError] when the options have already been set.
141
+ def set_options(options)
142
+ if !@options.nil?
143
+ raise 'Options of a Triauth.IdentityDomain are already set'
144
+ end
145
+
146
+ @options = options
147
+
148
+ return self
149
+ end
150
+
151
+ # Resolves the identity domain by deriving the domain name from the identifier
152
+ # (using the authentication endpoint options) and querying the DNS for its identity records.
153
+ # Sets the `domain_name` and `identity_records` properties accordingly.
154
+ #
155
+ # Set resolution options with `set_options(...)` prior to the call.
156
+ #
157
+ # The result is memoized, and subsequent calls to `resolve` do not perform DNS lookups.
158
+ # Rejections are currently memoized too.
159
+ #
160
+ # @return [Boolean] true when identity records have been fetched, false when the domain name could not be derived.
161
+ #
162
+ # @raise [RuntimeError] when the resolution options have not been set yet - call `set_options(...)` first.
163
+ def resolve
164
+ if @options.nil?
165
+ raise 'Triauth.IdentityDomain options not set'
166
+ end
167
+
168
+ # Memoize the outcome so that multiple and/or concurrent calls return the same status.
169
+ @resolve_mutex.synchronize do
170
+ @resolve_promise ||= begin
171
+ # The body of the memoized resolution is a lambda, so that every JS `return` is a `return` of the lambda.
172
+ resolution = lambda do
173
+ derived = IdentityDomain.derive(@identifier, @options)
174
+ # `derived?.domainName`: a nil `derived` prints empty here, where JS prints `undefined`
175
+ Helpers.log(@config, :debug, "Identity domain for identifier \"#{@identifier}\" is \"#{derived&.[](:domainName)}\"")
176
+
177
+ # A derivation that cannot be performed leaves the identity without an identity domain,
178
+ # which is like an identity that publishes no records.
179
+ if derived.nil?
180
+ @resolved = false
181
+ return [true, @resolved]
182
+ end
183
+
184
+ domain_name, full_label = derived.values_at(:domainName, :fullLabel)
185
+
186
+ if @options[:mode] == 'private'
187
+ @lookup_code = @options[:lookupCode]
188
+ @expected_commitment = Helpers.sha256(full_label)
189
+ end
190
+
191
+ @domain_name = domain_name
192
+ @identity_records = @config[:resolver].resolve_config(domain_name, @config)
193
+
194
+ @resolved = true
195
+ return [true, @resolved]
196
+ end
197
+ resolution.call
198
+ rescue StandardError => err
199
+ Helpers.log(@config, :debug, err)
200
+
201
+ @resolved = false
202
+ [false, err]
203
+ end
204
+ end
205
+
206
+ ok, value = @resolve_promise
207
+ raise value if !ok
208
+
209
+ return value
210
+ end
211
+ end
212
+ end