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,405 @@
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 'json'
21
+ require_relative 'error'
22
+ require_relative 'helpers'
23
+ require_relative 'validator'
24
+ require_relative 'protocol'
25
+ require_relative 'identity'
26
+
27
+ module Triauth
28
+ # A single triauth signature envelope, that carries structured information about the signer, and other data,
29
+ # along with the plain cryptographic signatures.
30
+ #
31
+ # Wire format: `|type;identifier;actor;via;v1;ts;signedMetadata;unsignedMetadata;cryptoSignature(s)|`
32
+ class Signature
33
+ # @return [Hash{Symbol => Object}] The effective settings: the global `Triauth.config` with the constructor's overrides merged over it.
34
+ attr_reader :config
35
+ # @return [String] The wrapped signature envelope this instance was parsed from.
36
+ attr_reader :raw_data
37
+ # @return [String] The flow the signature was made for - one of `attest`, `auth`, `ping`, `sign`, `stamp`.
38
+ attr_reader :type
39
+ # @return [String] The identifier of the account the signature speaks for.
40
+ attr_reader :identifier
41
+ # @return [String] The delegated actor that produced the signature, or `''` when the account signed for itself.
42
+ attr_reader :actor
43
+ # @return [String] The canonical URL of the client application the signature was issued to.
44
+ attr_reader :via
45
+ # The protocol version token as published (e.g., `v1`); re-parsed into its integer below.
46
+ # @return [Integer]
47
+ attr_reader :ver
48
+ # The signing timestamp as published; re-parsed into an integer (ms) below.
49
+ # @return [Integer]
50
+ attr_reader :ts
51
+ # @return [String] The signed metadata slot, base64url as carried on the wire, or `''` when the envelope carries none.
52
+ attr_reader :encoded_signed_metadata
53
+ # @return [String] The unsigned metadata slot, base64url as carried on the wire, or `''` when the envelope carries none.
54
+ attr_reader :encoded_unsigned_metadata
55
+ # @return [Array<String>] The envelope's crypto signatures (base64url), one per key in the signing key group.
56
+ attr_accessor :crypto_signatures
57
+ # @return [Hash{Symbol => Object}] The decoded metadata covered by the crypto signatures; `{}` when the envelope carries none.
58
+ attr_reader :signed_metadata
59
+ # @return [Hash{Symbol => Object}] The decoded metadata NOT covered by the crypto signatures; `{}` when the envelope carries none.
60
+ attr_reader :unsigned_metadata
61
+
62
+ # Generates a signature envelope by signing the payload with the provided crypto_signer.
63
+ #
64
+ # @param crypto_signer [Proc] a callable that receives the payload string (and a mutable unsignedMetadata reference) and returns one or more base64url crypto signatures.
65
+ # @param type [String] the flow type of the signature ('attest'|'auth'|'ping'|'sign'|'stamp').
66
+ # @param identifier [String] the identifier (the subject) the signature is made for.
67
+ # @param actor [String] the identifier of the delegate whose keys sign on behalf of the `identifier`, or `''` for a non-delegated signature.
68
+ # @param via [String] the base URL of the client application the signature is produced for.
69
+ # @param message [String] the message to sign.
70
+ # @param signed_metadata [Hash] additional data embedded under the crypto signatures. Defaults to `{}`.
71
+ # @param unsigned_metadata [Hash] additional data embedded outside the signed payload (the crypto_signer may populate it). Defaults to `{}`.
72
+ # @param config [Hash] optional overrides for the global Triauth.config settings. Defaults to `{}`.
73
+ #
74
+ # @return [String, false] the signature envelope, or false when the passed values fail validation.
75
+ def self.generate(crypto_signer, type, identifier, actor, via, message, signed_metadata = {}, unsigned_metadata = {}, config = {})
76
+ config = Helpers.merge_config(config)
77
+
78
+ Helpers.log(config, :debug, 'Generating signature', { type: type, identifier: identifier, actor: actor, via: via, message: message, signedMetadata: signed_metadata, unsignedMetadata: unsigned_metadata })
79
+
80
+ if !(!type.nil? && type != '' && Protocol::FLOWS.include?(type)) ||
81
+ !Validator.validate_identifier(identifier)[:valid] ||
82
+ !(actor == '' || Validator.validate_identifier(actor)[:valid]) ||
83
+ !(Helpers.is_canonical_url(via) && !via.include?(Protocol::DELIMITER) && !via.include?(Protocol::WRAPPER))
84
+ return false
85
+ end
86
+
87
+ envelope_fields = [
88
+ type,
89
+ identifier,
90
+ actor,
91
+ via,
92
+ Protocol::VERSION_TOKEN,
93
+ Helpers.date_now,
94
+ !signed_metadata.nil? && signed_metadata.size > 0 ? Signature.encode_metadata(signed_metadata) : '',
95
+ '',
96
+ message.to_s
97
+ ]
98
+
99
+ # Prepare the payload that is to be signed by cryptoSigner
100
+ signer_payload = envelope_fields.join(Protocol::DELIMITER)
101
+
102
+ # Make a copy of received unsignedMetadata that can be safely modified in-place by cryptoSigner
103
+ unsigned_metadata_ref = Helpers.object_assign({}, unsigned_metadata)
104
+
105
+ # Let the cryptoSigner sign the payload, and populate the unsignedMetadataRef if needed
106
+ Helpers.log(config, :debug, 'Generating signature for payload', { signerPayload: signer_payload, unsignedMetadataRef: unsigned_metadata_ref })
107
+ crypto_signatures = crypto_signer.call(signer_payload, unsigned_metadata_ref)
108
+
109
+ # Replace the original message with cryptoSignatures in envelopeFields
110
+ envelope_fields[8] = crypto_signatures.is_a?(Array) ? crypto_signatures.join(Protocol::DELIMITER) : crypto_signatures.to_s
111
+
112
+ # Populate the unsignedMetadata in envelopeFields now (after signing)
113
+ envelope_fields[7] = unsigned_metadata_ref.size > 0 ? Signature.encode_metadata(unsigned_metadata_ref) : ''
114
+
115
+ # Construct the envelope of Signature by joining the items with ';' and wrapping everything in '|'
116
+ return Protocol::WRAPPER + envelope_fields.join(Protocol::DELIMITER) + Protocol::WRAPPER
117
+ end
118
+
119
+ # Parses a raw signature envelope string.
120
+ #
121
+ # @param raw_data [String] the wrapped signature envelope.
122
+ # @param config [Hash] optional overrides for the global Triauth.config settings. Defaults to `{}`.
123
+ #
124
+ # @raise [Triauth::Error] `invalid_signature` when the envelope or any of its fields is malformed.
125
+ def initialize(raw_data, config = {})
126
+ @config = Helpers.merge_config(config)
127
+
128
+ # Pre-check the basic sanity and format of received arguments
129
+ if !Helpers.is_string(raw_data) ||
130
+ raw_data.length <= 3 ||
131
+ (raw_data[0] != Protocol::WRAPPER || raw_data[-1] != Protocol::WRAPPER) ||
132
+ Helpers.byte_size(raw_data) > Protocol::LIMITS[:signatureBytesize] ||
133
+ raw_data.split(Protocol::WRAPPER, -1).length != 3
134
+ raise Triauth::Error.new({ code: 'invalid_signature', reason: 'invalid', message: 'Invalid signature' })
135
+ end
136
+
137
+ @raw_data = raw_data
138
+
139
+ # Extract information from the signature segment's envelope
140
+ envelope = @raw_data[1...-1].split(Protocol::DELIMITER, -1)
141
+
142
+ # A `shift` past the end of the envelope yields nil (JS: undefined), which every format check below refuses.
143
+ @type = envelope.shift
144
+ @identifier = envelope.shift
145
+ @actor = envelope.shift
146
+ @via = envelope.shift
147
+ @ver = envelope.shift
148
+ @ts = envelope.shift
149
+ @encoded_signed_metadata = envelope.shift
150
+ @encoded_unsigned_metadata = envelope.shift
151
+ @crypto_signatures = envelope.dup
152
+
153
+ # Check the format of data extracted from the envelope
154
+ if !(!@type.nil? && @type != '' && Protocol::FLOWS.include?(@type)) ||
155
+ !(!@identifier.nil? && @identifier != '' && Validator.validate_identifier(@identifier)[:valid]) ||
156
+ !(@actor == '' || Validator.validate_identifier(@actor)[:valid]) ||
157
+ !(!@via.nil? && @via != '' && Helpers.is_canonical_url(@via)) ||
158
+ !(!@ver.nil? && @ver != '' && /\Av[1-9][0-9]{0,15}\z/.match?(@ver)) ||
159
+ !(@ver == Protocol::VERSION_TOKEN) ||
160
+ !(!@ts.nil? && @ts != '' && /\A[1-9][0-9]{0,15}\z/.match?(@ts)) ||
161
+ !(@encoded_signed_metadata == '' || Helpers.is_base64url_string(@encoded_signed_metadata)) ||
162
+ !(@encoded_unsigned_metadata == '' || Helpers.is_base64url_string(@encoded_unsigned_metadata)) ||
163
+ @crypto_signatures.length < 1 || # there must be at least one crypto signature
164
+ @crypto_signatures.any? { |cs| cs.length < 1 || !Helpers.is_base64url_string(cs) }
165
+ raise Triauth::Error.new({ code: 'invalid_signature', reason: 'invalid', message: 'Invalid signature' })
166
+ end
167
+
168
+ # Parse the numeric properties from envelope - they have been pre-checked with regexp
169
+ # (`parseInt` through a double: a 16-digit slot rounds above 2**53 exactly as JS does)
170
+ @ver = Float(@ver[1..]).to_i
171
+ @ts = Float(@ts).to_i
172
+
173
+ # De-serialize metadata back to JS objects
174
+ begin
175
+ @signed_metadata = @encoded_signed_metadata != '' ? decode_metadata(@encoded_signed_metadata) : {}
176
+ @unsigned_metadata = @encoded_unsigned_metadata != '' ? decode_metadata(@encoded_unsigned_metadata) : {}
177
+ rescue StandardError => err
178
+ raise Triauth::Error.new({ code: 'invalid_signature', reason: 'invalid', message: 'Invalid signature' }, { cause: err })
179
+ end
180
+ end
181
+
182
+ # Extracts object from encoded_metadata
183
+ #
184
+ # Triauth signature's envelope may contain additional information,
185
+ # which can be either covered by the signatures (signedMetadata), or not (unsignedMetadata).
186
+ #
187
+ # @param encoded_metadata [String] A base64url encoded, JSON serialized object
188
+ #
189
+ # @return [Hash, Object] restored object
190
+ def decode_metadata(encoded_metadata)
191
+ return Helpers.safe_parse_json(
192
+ Helpers.base64url_to_string(encoded_metadata)
193
+ )
194
+ end
195
+
196
+ # Encodes an object into the encoded_metadata form carried by a signature envelope.
197
+ #
198
+ # The inverse of `decode_metadata`.
199
+ #
200
+ # @param metadata [Hash] An object to be carried in the envelope's signedMetadata or unsignedMetadata slot
201
+ #
202
+ # @return [String] a base64url encoded, JSON serialized representation of the object
203
+ def self.encode_metadata(metadata)
204
+ return Helpers.string_to_base64url(JSON.generate(metadata))
205
+ end
206
+
207
+ # Verifies that the signature segment is valid.
208
+ #
209
+ # @param message [String] Message, should be the same as originally signed.
210
+ # @param constraints [Hash] Optional constraints that the signature must satisfy. Defaults to `{}`.
211
+ # @option constraints [String] :type If given, must match the signature's `type` slot ('attest'|'auth'|'ping'|'sign'|'stamp').
212
+ # @option constraints [String] :identifier If given, must match the identifier for which it was created.
213
+ # @option constraints [String] :actor If given, must match the signature's `actor` slot — the delegate whose keys made the signature on behalf of the `identifier`; `''` requires a non-delegated signature.
214
+ # @option constraints [Integer] :ver If given, must match the signature's protocol version.
215
+ # @option constraints [String] :via If given, must match the URL of the client application that requested the signature.
216
+ # @option constraints [Integer] :notBefore If given, must be a timestamp after which the signature was created.
217
+ # @option constraints [Integer] :notAfter If given, must be a timestamp before which the signature was created.
218
+ #
219
+ # @return [Hash, false, nil] `{valid: true, type: String, identifier: String, identityDomain: String, lookupCode: String, actor: String, actorIdentityDomain: String, actorLookupCode: String, via: String, ver: Integer, signedAt: Integer, verifiedAt: Integer, publicProfile: Hash, groups: Array<String>, deviceName: String, deviceTag: String, keys: Array<Hash>, secure: Boolean, expires: (Integer, nil), signedMetadata: Hash, unsignedMetadata: Hash}`, false or nil:
220
+ # false upon failed verification, nil upon time constraints failure, or a Hash with the following properties:
221
+ # valid - set to true
222
+ # type - a type of signature that was verified
223
+ # identifier - the identifier for which the signature was made (the subject),
224
+ # identityDomain - the domain name under which the identifier's identity records are stored (the subject's, for delegated signatures; for a direct signature, the records the signing keys were read from),
225
+ # lookupCode - the lookup code the subject identity's domain derivation consumed (under `mode=private` its records live at a code-derived label), or `''` where no code was consumed - the same code the deviceTag carries as a `~` suffix,
226
+ # actor - the identifier of the delegate whose keys made the signature on behalf of the identifier, or `''` when the signature was made with the identifier's own keys,
227
+ # actorIdentityDomain - the domain name under which the actor's identity records are stored — the records the signing keys of a delegated signature were read from — or `''` when the signature was made with the identifier's own keys,
228
+ # actorLookupCode - the lookup code the actor's domain derivation consumed, or `''` where none was (an actor domain not running private mode, or a signature under the identifier's own keys),
229
+ # via - the URL of the client application that requested the signature,
230
+ # ver - the version slot of the signature envelope,
231
+ # signedAt - the device-asserted timestamp at which the signature was made (the envelope `ts` slot; the user's device clock, only proven to fall within the freshness window),
232
+ # verifiedAt - this host's wall-clock timestamp at which the signature's crypto was confirmed,
233
+ # publicProfile - a public profile of identifier as read from the DNS records,
234
+ # groups - fully-qualified group names the identifier's identity records claim membership in (e.g., ['admins@triauthdemo.org']), sorted and deduplicated; for delegated signatures these are the subject's groups, never the actor's,
235
+ # keys - an array of public keys, read from identity records, that were used for verification,
236
+ # deviceName - a user-given name of the device that was used for signing (the actor's device for delegated signatures),
237
+ # deviceTag - a string identifying the device; for delegated signatures the composite `<includeTag>:<actorIdentifier>:<actorDeviceTag>`, covering the grant (`include` statement) as well; under private mode a tag component carries the lookupCode as a `~<lookupCode>` suffix (`<deviceTag>~<lookupCode>` direct, `<includeTag>~<lookupCode>:<actor>:<deviceTag>` and/or `...:<deviceTag>~<actorLookupCode>` delegated),
238
+ # secure - a boolean, true only when DNSSEC was reported for the domain's triauth endpoint record and for every well-formed record of the signer's identity answer — and for delegated signatures, of the subject's too; a single non-validated identity record degrades it,
239
+ # expires - a timestamp when dns records from which keys were extracted expire (calculated from TTL values in DNS response; nil when none carried a TTL),
240
+ # signedMetadata - an object, containing any additional data that is covered by the embedded crypto signatures,
241
+ # unsignedMetadata - an object, containing data that for various reasons was not covered by the embedded crypto signatures, but may be needed to verify it
242
+ #
243
+ # @raise [Triauth::Error] `dns_unavailable` when DNS resolution of the signer's identity fails - intentionally propagated so that API methods report a retryable error instead of a false "signature invalid" verdict
244
+ def verify(message, constraints = {})
245
+ # Verify constraints to avoid bugs stemming from misspelling of keys in constraints object
246
+ if !Helpers.has_only_known_properties(constraints, %i[type identifier actor ver via notBefore notAfter], false)
247
+ Helpers.log(@config, :error, 'Unrecognized constraint')
248
+ return false
249
+ end
250
+
251
+ # Make sure that we actually have signatures to verify
252
+ if !(@crypto_signatures.length > 0)
253
+ return false
254
+ end
255
+
256
+ # Fail closed if time bounds are provided in a non-numeric form rather than silently
257
+ # coercing to NaN (which would compare as false and bypass the window check).
258
+ if (!constraints[:notBefore].nil? && (!Helpers.is_number(constraints[:notBefore]) || (constraints[:notBefore].is_a?(Float) && constraints[:notBefore].nan?))) ||
259
+ (!constraints[:notAfter].nil? && (!Helpers.is_number(constraints[:notAfter]) || (constraints[:notAfter].is_a?(Float) && constraints[:notAfter].nan?)))
260
+ Helpers.log(@config, :error, 'Invalid notBefore/notAfter constraint')
261
+ return false
262
+ end
263
+
264
+ # 3-state return contract (shared by Signature/MultiSignature/Response.verify and relied on by
265
+ # every API method to choose between request_expired ("expired") and verification_failed ("invalid")):
266
+ # - a result object => success
267
+ # - `null` => time-related failure ("expired")
268
+ # - `false` => any other failure ("invalid")
269
+ # `null` and `false` are deliberately distinct and must stay distinct as the value propagates up.
270
+ if (!constraints[:notBefore].nil? && constraints[:notBefore] > @ts) ||
271
+ (!constraints[:notAfter].nil? && constraints[:notAfter] < @ts)
272
+ return nil
273
+ end
274
+
275
+ # All other constraint mismatches are generic failures.
276
+ if (!constraints[:type].nil? && constraints[:type] != @type) ||
277
+ (!constraints[:identifier].nil? && constraints[:identifier] != @identifier) ||
278
+ (!constraints[:actor].nil? && constraints[:actor] != @actor) ||
279
+ (!constraints[:ver].nil? && constraints[:ver] != @ver) ||
280
+ (!constraints[:via].nil? && constraints[:via] != @via)
281
+ return false
282
+ end
283
+
284
+ # Re-construct the envelope (payload) against which the signature will be verified.
285
+ # envelopeFields[7] (the unsignedMetadata slot) is intentionally empty in the signed payload:
286
+ # it is the ONE field of the envelope that is NOT cryptographically signed.
287
+ envelope_fields = [
288
+ @type,
289
+ @identifier,
290
+ @actor.nil? || @actor.empty? ? '' : @actor,
291
+ @via,
292
+ "v#{@ver}",
293
+ @ts,
294
+ @encoded_signed_metadata.nil? || @encoded_signed_metadata.empty? ? '' : @encoded_signed_metadata,
295
+ '',
296
+ message.to_s
297
+ ]
298
+
299
+ payload = envelope_fields.join(Protocol::DELIMITER)
300
+
301
+ # Extract lookup codes that may be needed for mode == 'private' domains
302
+ subject_lookup_code = Helpers.is_lookup_code(@signed_metadata[:lookupCode]) ? @signed_metadata[:lookupCode] : nil
303
+ actor_lookup_code = Helpers.is_lookup_code(@signed_metadata[:actorLookupCode]) ? @signed_metadata[:actorLookupCode] : nil
304
+
305
+ # Build the identity object for the identifier as embedded in the signature
306
+ subject_identity = Identity.new(@identifier, { lookupCode: subject_lookup_code }, @config)
307
+ actor_identity = @actor.nil? || @actor.empty? ? nil : Identity.new(@actor, { lookupCode: actor_lookup_code }, @config)
308
+ signer_identity = actor_identity || subject_identity
309
+
310
+ # Attempt to resolve identity records of the identifier. A definitive negative (domain not
311
+ # configured for triauth, or no identity records published) fails the verification. A thrown
312
+ # resolution error (e.g., TriauthError dns_unavailable on DNS failure) intentionally propagates instead.
313
+ if !signer_identity.resolve
314
+ Helpers.log(@config, :debug, 'Identifier does not exist or is not configured for triauth')
315
+ return false
316
+ end
317
+
318
+ # Prepare a context for this check
319
+ context = {
320
+ mode: @type, # signature type ('auth'|'sign'|'stamp'|'ping'|'attest') — enforces the key's "use" option
321
+ webAuthnOrigin: "https://#{signer_identity.authentication_endpoint.host}" # for WebAuthn verifications - clientData.origin must equal the RESOLVED authentication endpoint origin (the host the Authenticator is actually served from, e.g. https://auth.example.com), NOT the identifier's domain; the endpoint record grammar fixes the endpoint scheme to https with no port, so the origin is exactly this concatenation
322
+ }
323
+
324
+ kv_result = signer_identity.keys.verify(payload, @crypto_signatures, context, @signed_metadata, @unsigned_metadata)
325
+ if !kv_result || kv_result[:valid] != true
326
+ return false
327
+ end
328
+
329
+ device_tag = kv_result[:tag]
330
+ secure = signer_identity.secure # DNSSEC status is resolution-level - any non-validated identity record degrades it
331
+ expires = Helpers.earliest(signer_identity.authentication_endpoint.expires, kv_result[:expires]) # the endpoint record selects the keys, so it bounds their freshness
332
+
333
+ # For delegated signatures (with actor set), now make sure that the proper include is present in the main identity records
334
+ # (covering the actor, the flow, and - through its `scope` - the service the signature was made via),
335
+ # and update the `secure`, `expires`, and `deviceTag` with information from it.
336
+ if !actor_identity.nil?
337
+
338
+ # Resolve the main identity for which the signature was made
339
+ if !subject_identity.resolve
340
+ Helpers.log(@config, :debug, 'Main identifier does not exist or is not configured for triauth')
341
+ return false
342
+ end
343
+
344
+ include_statement = subject_identity.includes.find(actor_identity, nil, @type, Helpers.url_host(@via))
345
+
346
+ # Make sure the include statement is present
347
+ if !include_statement
348
+ Helpers.log(@config, :debug, 'No matching include statements found for this actor')
349
+ return false
350
+ end
351
+
352
+ # Device tag for delegated signatures is the ':' joined: include record tag, actor identifier, and actor's deviceTag,
353
+ # e.g., '5I4NnX-4lW3gdTqGW3zKpLsLR9oRiWip8zp6mqQgkL4:actor@triauthdemo.org:3bi5oE_jXyjGY0WY5n6YqOuzRxfo40fuqDmLGaA5bKQ'.
354
+ # We need all of this so that deviceTag is guaranteed to change when:
355
+ # - any significant changes to the `include` statement were made (e.g., change in `use` or `scope`),
356
+ # - any significant changes to actor's identity domain configuration (e.g., a lookup code rotation) were made,
357
+ # - and any changes to actor's device keys were made.
358
+ # Also, the full actor's identifier in the deviceTag allows for simpler 'Triauth.check' code.
359
+ # (The JS declares block-scoped constants that shadow the two lookup codes above; Ruby has no block scope
360
+ # for `if`, so the locals are reassigned - the earlier values are not read again.)
361
+ subject_lookup_code = subject_identity.identity_domain.lookup_code
362
+ actor_lookup_code = actor_identity.identity_domain.lookup_code
363
+
364
+ device_tag =
365
+ include_statement[:tag] + (subject_lookup_code.nil? || subject_lookup_code.empty? ? '' : "~#{subject_lookup_code}") +
366
+ ":#{actor_identity.identifier}:" +
367
+ device_tag + (actor_lookup_code.nil? || actor_lookup_code.empty? ? '' : "~#{actor_lookup_code}")
368
+
369
+ secure &&= subject_identity.secure
370
+ expires = Helpers.earliest(expires, Helpers.earliest(subject_identity.authentication_endpoint.expires, include_statement[:expires]))
371
+
372
+ elsif !(subject_identity.identity_domain.lookup_code.nil? || subject_identity.identity_domain.lookup_code.empty?)
373
+ device_tag += "~#{subject_identity.identity_domain.lookup_code}"
374
+
375
+ end
376
+
377
+ # Wall-clock instant at which this signature was confirmed
378
+ verified_at = Helpers.date_now
379
+
380
+ return {
381
+ valid: true,
382
+ type: @type,
383
+ identifier: subject_identity.identifier,
384
+ identityDomain: subject_identity.identity_domain.domain_name,
385
+ lookupCode: subject_identity.identity_domain.lookup_code.nil? || subject_identity.identity_domain.lookup_code.empty? ? '' : subject_identity.identity_domain.lookup_code,
386
+ actor: actor_identity.nil? ? '' : actor_identity.identifier,
387
+ actorIdentityDomain: actor_identity.nil? ? '' : actor_identity.identity_domain.domain_name,
388
+ actorLookupCode: actor_identity.nil? || actor_identity.identity_domain.lookup_code.nil? || actor_identity.identity_domain.lookup_code.empty? ? '' : actor_identity.identity_domain.lookup_code,
389
+ via: @via,
390
+ ver: @ver,
391
+ signedAt: @ts,
392
+ verifiedAt: verified_at,
393
+ publicProfile: subject_identity.public_profile,
394
+ groups: subject_identity.groups,
395
+ deviceName: kv_result[:name],
396
+ deviceTag: device_tag,
397
+ keys: kv_result[:keys],
398
+ secure: secure,
399
+ expires: expires,
400
+ signedMetadata: @signed_metadata,
401
+ unsignedMetadata: @unsigned_metadata
402
+ }
403
+ end
404
+ end
405
+ end