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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +56 -0
- data/LICENSE +202 -28
- data/README.md +2036 -7
- data/lib/triauth/api/attestation.rb +419 -0
- data/lib/triauth/api/authentication.rb +651 -0
- data/lib/triauth/api/identity.rb +245 -0
- data/lib/triauth/api/signing.rb +740 -0
- data/lib/triauth/api/validation.rb +162 -0
- data/lib/triauth/api.rb +33 -0
- data/lib/triauth/authentication_endpoint.rb +196 -0
- data/lib/triauth/challenge.rb +115 -0
- data/lib/triauth/challenge_response_flow.rb +344 -0
- data/lib/triauth/config.rb +93 -0
- data/lib/triauth/error.rb +95 -0
- data/lib/triauth/helpers.rb +1222 -0
- data/lib/triauth/identity.rb +301 -0
- data/lib/triauth/identity_domain.rb +212 -0
- data/lib/triauth/identity_includes.rb +249 -0
- data/lib/triauth/identity_keys.rb +309 -0
- data/lib/triauth/logger.rb +156 -0
- data/lib/triauth/multi_signature.rb +177 -0
- data/lib/triauth/protocol.rb +74 -0
- data/lib/triauth/request_cipher.rb +121 -0
- data/lib/triauth/resolvers/base.rb +147 -0
- data/lib/triauth/resolvers/caching_resolver.rb +202 -0
- data/lib/triauth/resolvers/cloudflare.rb +40 -0
- data/lib/triauth/resolvers/dns_json.rb +391 -0
- data/lib/triauth/resolvers/dns_sb.rb +40 -0
- data/lib/triauth/resolvers/google.rb +39 -0
- data/lib/triauth/resolvers/multi_resolver.rb +202 -0
- data/lib/triauth/resolvers/system_dns.rb +259 -0
- data/lib/triauth/resolvers.rb +33 -0
- data/lib/triauth/response.rb +142 -0
- data/lib/triauth/response_cipher.rb +341 -0
- data/lib/triauth/signature.rb +405 -0
- data/lib/triauth/validator.rb +549 -0
- data/lib/triauth/verifiers/base.rb +147 -0
- data/lib/triauth/verifiers/ecdsa.rb +87 -0
- data/lib/triauth/verifiers/ed25519.rb +89 -0
- data/lib/triauth/verifiers/registry.rb +52 -0
- data/lib/triauth/verifiers/web_authn.rb +253 -0
- data/lib/triauth/verifiers.rb +37 -0
- data/lib/triauth/version.rb +23 -0
- data/lib/triauth.rb +104 -7
- 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
|