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,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
|