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,147 @@
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 'openssl'
21
+
22
+ module Triauth
23
+ module Verifiers
24
+ # The base abstract class from which all Verifiers inherit common functions.
25
+ class Base
26
+ # Constructs a verifier for a public key as it is published in an identity record.
27
+ #
28
+ # This is the abstract contract that concrete verifiers (e.g., Ecdsa, Ed25519, WebAuthn) implement.
29
+ #
30
+ # @param raw_public_key [String] The publishable key material, as read from the identity record.
31
+ # @param options [Hash] Key options as read from the DNS record that carried the key (e.g., `use`, `uv`). Defaults to `{}`.
32
+ # @param context [Hash] Per-verification context threaded through from `Triauth::IdentityKeys#verify`
33
+ # (e.g., `idx`, `mode`, `webAuthnOrigin`). Defaults to `{}`.
34
+ # @param config [Hash] Optional overrides for the global Triauth.config settings. Defaults to `{}`.
35
+ #
36
+ # @return [Base, nil] A verifier instance, or nil when the key
37
+ # material cannot be imported. A key whose verifier cannot be constructed is skipped, never trusted.
38
+ def self.from_publishable_key(raw_public_key, options = {}, context = {}, config = {}) # rubocop:disable Lint/UnusedMethodArgument -- the abstract contract keeps its parameters
39
+ raise 'Triauth.Verifiers.Base.fromPublishableKey is abstract and must be implemented by a subclass'
40
+ end
41
+
42
+ # Verifies that a given signature matches the verifier's public key and the given message.
43
+ #
44
+ # This is the abstract contract that concrete verifiers (e.g., Ecdsa, Ed25519, WebAuthn) implement.
45
+ #
46
+ # @param message [String] The message to verify against.
47
+ # @param signature_base64url [String] Base64Url encoded signature.
48
+ # @param signed_data [Hash] Signature envelope's signedMetadata.
49
+ # @param unsigned_data [Hash] Signature envelope's unsignedMetadata.
50
+ #
51
+ # @return [Boolean] true or false basing on the verification result.
52
+ def verify(message, signature_base64url, signed_data, unsigned_data) # rubocop:disable Lint/UnusedMethodArgument -- the abstract contract keeps its parameters
53
+ raise 'Triauth.Verifiers.Base#verify is abstract and must be implemented by a subclass'
54
+ end
55
+
56
+ # Crypto encodings with no triauth-js counterpart (the profile's idiom table): byte conversions only, all math is OpenSSL's.
57
+
58
+ # The `SubjectPublicKeyInfo` prefix of an Ed25519 public key (`SEQUENCE { SEQUENCE { OID 1.3.101.112 }, BIT STRING }` ahead
59
+ # of the 32 raw key bytes): `OpenSSL::PKey` reads a raw key through this wrapper only.
60
+ ED25519_SPKI_PREFIX = ['302a300506032b6570032100'].pack('H*').freeze
61
+ private_constant :ED25519_SPKI_PREFIX
62
+
63
+ # The port of `crypto.subtle.importKey('raw', raw_bytes, algorithm, true, ['verify'])`: reads a raw public key through
64
+ # its SPKI DER wrapper, so that OpenSSL validates the key material (a P-256 point that is not on the curve is rejected).
65
+ #
66
+ # @param raw_bytes [String] The raw public key (a binary String): the 65-byte uncompressed point `04 || X || Y` for
67
+ # ECDSA P-256 (the verifiers decode a compressed point to this form first, with
68
+ # `Helpers.decode_p256_public_key`; the import itself still refuses every other form before
69
+ # OpenSSL sees it), the 32 raw bytes for Ed25519.
70
+ # @param algorithm [Hash] The WebCrypto import algorithm identifier, kept as data:
71
+ # `{name: 'ECDSA', namedCurve: 'P-256', hash: 'SHA-256'}` or `{name: 'Ed25519'}`.
72
+ #
73
+ # @return [OpenSSL::PKey::PKey] the public key (an `OpenSSL::PKey::EC` for ECDSA).
74
+ #
75
+ # @raise [ArgumentError] when the bytes do not have the length (and prefix) of the algorithm's raw form, or the
76
+ # algorithm identifier is not one of the two above.
77
+ # @raise [OpenSSL::PKey::PKeyError] when OpenSSL rejects the key material (e.g., a point that is not on the curve).
78
+ def self.import_key(raw_bytes, algorithm)
79
+ case algorithm[:name]
80
+ when 'ECDSA'
81
+ raise ArgumentError, "Unsupported ECDSA curve: #{algorithm[:namedCurve]}" unless algorithm[:namedCurve] == 'P-256'
82
+ raise ArgumentError, 'ECDSA raw public key must be the 65-byte uncompressed point' unless raw_bytes.bytesize == 65 && raw_bytes.getbyte(0) == 0x04
83
+
84
+ # SPKI: SEQUENCE { SEQUENCE { id-ecPublicKey, prime256v1 }, BIT STRING point }
85
+ algorithm_identifier = OpenSSL::ASN1::Sequence([OpenSSL::ASN1::ObjectId('id-ecPublicKey'), OpenSSL::ASN1::ObjectId('prime256v1')])
86
+ der = OpenSSL::ASN1::Sequence([algorithm_identifier, OpenSSL::ASN1::BitString(raw_bytes)]).to_der
87
+ return OpenSSL::PKey::EC.new(der)
88
+ when 'Ed25519'
89
+ raise ArgumentError, 'Ed25519 raw public key must be 32 bytes' unless raw_bytes.bytesize == 32
90
+
91
+ return OpenSSL::PKey.read(ED25519_SPKI_PREFIX + raw_bytes)
92
+ else
93
+ raise ArgumentError, "Unsupported algorithm: #{algorithm[:name]}"
94
+ end
95
+ end
96
+
97
+ # The port of `crypto.subtle.verify(algorithm, public_key, signature_bytes, data_bytes)`: verifies the raw signature of
98
+ # the wire (`r || s` for ECDSA, the 64 raw bytes for Ed25519) over the data bytes. The length is checked before any
99
+ # conversion, so a DER-encoded ECDSA signature is refused exactly as WebCrypto refuses it (no widened accept-set).
100
+ #
101
+ # @param algorithm [Hash] The WebCrypto verify algorithm identifier, kept as data:
102
+ # `{name: 'ECDSA', hash: 'SHA-256'}` or `{name: 'Ed25519'}`.
103
+ # @param public_key [OpenSSL::PKey::PKey] The public key, as returned by {Triauth::Verifiers::Base.import_key}.
104
+ # @param signature_bytes [String] The raw signature (a binary String): exactly 64 bytes for both algorithms.
105
+ # @param data_bytes [String] The signed data (a binary String).
106
+ #
107
+ # @return [Boolean] true when the signature verifies; false for a signature of another length, a signature that does
108
+ # not verify, or an operation OpenSSL refuses (`OpenSSL::PKey::PKeyError`).
109
+ #
110
+ # @raise [ArgumentError] when the algorithm identifier is not one of the two above.
111
+ def self.verify_signature(algorithm, public_key, signature_bytes, data_bytes)
112
+ case algorithm[:name]
113
+ when 'ECDSA'
114
+ raise ArgumentError, "Unsupported ECDSA hash: #{algorithm[:hash]}" unless algorithm[:hash] == 'SHA-256'
115
+ return false unless signature_bytes.bytesize == 64
116
+
117
+ return public_key.verify('SHA256', raw_to_der(signature_bytes), data_bytes)
118
+ when 'Ed25519'
119
+ return false unless signature_bytes.bytesize == 64
120
+
121
+ return public_key.verify(nil, signature_bytes, data_bytes)
122
+ else
123
+ raise ArgumentError, "Unsupported algorithm: #{algorithm[:name]}"
124
+ end
125
+ rescue OpenSSL::PKey::PKeyError
126
+ return false
127
+ end
128
+
129
+ # Converts a raw ECDSA signature `r || s` (two 32-byte big-endian unsigned integers) into the DER
130
+ # `SEQUENCE { INTEGER r, INTEGER s }` that OpenSSL verifies. `OpenSSL::ASN1::Integer` writes the minimal positive
131
+ # encodings (a leading zero byte ahead of a high bit, leading zero bytes dropped).
132
+ #
133
+ # @param signature_bytes [String] The 64-byte raw signature (a binary String).
134
+ #
135
+ # @return [String] the DER-encoded signature (a binary String).
136
+ #
137
+ # @raise [ArgumentError] when the signature is not 64 bytes.
138
+ def self.raw_to_der(signature_bytes)
139
+ raise ArgumentError, 'ECDSA raw signature must be 64 bytes' unless signature_bytes.bytesize == 64
140
+
141
+ r = OpenSSL::BN.new(signature_bytes.byteslice(0, 32), 2)
142
+ s = OpenSSL::BN.new(signature_bytes.byteslice(32, 32), 2)
143
+ return OpenSSL::ASN1::Sequence([OpenSSL::ASN1::Integer(r), OpenSSL::ASN1::Integer(s)]).to_der
144
+ end
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,87 @@
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 'base'
21
+ require_relative 'registry'
22
+ require_relative '../helpers'
23
+
24
+ module Triauth
25
+ module Verifiers
26
+ # The verifier for ECDSA signatures (P-256 curve with SHA-256).
27
+ # This is the default verifier in triauth.
28
+ class Ecdsa < Base
29
+ # @return [OpenSSL::PKey::EC] The public key the signatures are verified against.
30
+ attr_reader :public_key
31
+ # @return [Hash{Symbol => Object}] The effective settings: the global `Triauth.config` with the constructor's overrides merged over it.
32
+ attr_reader :config
33
+
34
+ # Constructs a new instance of ECDSA verifier for the given public crypto key and options.
35
+ #
36
+ # @param public_key [OpenSSL::PKey::EC] A public key in form of an `OpenSSL::PKey` object.
37
+ # @param options [Hash] Key options as read from the DNS record that contained the public_key. Defaults to `{}`.
38
+ # @param context [Hash] Per-verification context. Unused by this verifier. Defaults to `{}`.
39
+ # @param config [Hash] Optional overrides for the global Triauth.config settings. Defaults to `{}`.
40
+ def initialize(public_key, options = {}, context = {}, config = {}) # rubocop:disable Lint/UnusedMethodArgument -- `options` and `context` are the verifier constructor contract, unused here as in triauth-js
41
+ super()
42
+
43
+ @public_key = public_key
44
+ @config = Helpers.merge_config(config)
45
+ end
46
+
47
+ # Builds an instance of the Verifier from the raw key information read from the DNS.
48
+ #
49
+ # @param raw_public_key [String] A key as published in the DNS record (base64url of the 65-byte uncompressed or the 33-byte compressed P-256 point).
50
+ # @param options [Hash] Key options as read from the DNS record that contained the raw_public_key. Defaults to `{}`.
51
+ # @param context [Hash] Per-verification context. Unused by this verifier. Defaults to `{}`.
52
+ # @param config [Hash] Optional overrides for the global Triauth.config settings. Defaults to `{}`.
53
+ #
54
+ # @return [Ecdsa, nil] The instance of this verifier, or nil if key is invalid or other problem occurred.
55
+ def self.from_publishable_key(raw_public_key, options = {}, context = {}, config = {})
56
+ config = Helpers.merge_config(config)
57
+
58
+ begin
59
+ public_key = Base.import_key(Helpers.decode_p256_public_key(Helpers.base64url_to_uint8(raw_public_key)), { name: 'ECDSA', namedCurve: 'P-256', hash: 'SHA-256' })
60
+ return new(public_key, options, context, config)
61
+ rescue StandardError => err
62
+ Helpers.log(config, :debug, err)
63
+ return nil
64
+ end
65
+ end
66
+
67
+ # Verifies that a given signature matches the verifier's public key and the given message.
68
+ #
69
+ # @param message [String] The message to verify against.
70
+ # @param signature_base64url [String] Base64Url encoded signature.
71
+ # @param signed_data [Hash] Signature envelope's signedMetadata. Unused by this verifier.
72
+ # @param unsigned_data [Hash] Signature envelope's unsignedMetadata. Unused by this verifier.
73
+ #
74
+ # @return [Boolean] true or false basing on the verification result.
75
+ def verify(message, signature_base64url, signed_data, unsigned_data) # rubocop:disable Lint/UnusedMethodArgument -- `signed_data` and `unsigned_data` are the verifier contract, unused here as in triauth-js
76
+ data = Helpers.string_to_utf8_bytes(message)
77
+ signature = Helpers.base64url_to_uint8(signature_base64url)
78
+ return Base.verify_signature({ name: 'ECDSA', hash: 'SHA-256' }, @public_key, signature, data)
79
+ rescue StandardError => err
80
+ Helpers.log(@config, :debug, err)
81
+ return false
82
+ end
83
+ end
84
+
85
+ Triauth::Verifiers.register('es256', Ecdsa)
86
+ end
87
+ end
@@ -0,0 +1,89 @@
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 'base'
21
+ require_relative 'registry'
22
+ require_relative '../helpers'
23
+
24
+ module Triauth
25
+ module Verifiers
26
+ # A verifier for Ed25519 (EdDSA over Curve25519) signatures.
27
+ #
28
+ # Support for Ed25519 keys in triauth is considered experimental: it needs the openssl gem 3.x over
29
+ # OpenSSL 1.1.1 or later (the Ruby 3.2 default gem over a current OpenSSL library has it).
30
+ class Ed25519 < Base
31
+ # @return [OpenSSL::PKey::PKey] The public key the signatures are verified against.
32
+ attr_reader :public_key
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
+
36
+ # Constructs a new instance of Ed25519 verifier for the given public crypto key and options.
37
+ #
38
+ # @param public_key [OpenSSL::PKey::PKey] A public key in form of an `OpenSSL::PKey` object.
39
+ # @param options [Hash] Key options as read from the DNS record that contained the public_key. Defaults to `{}`.
40
+ # @param context [Hash] Per-verification context. Unused by this verifier. Defaults to `{}`.
41
+ # @param config [Hash] Optional overrides for the global Triauth.config settings. Defaults to `{}`.
42
+ def initialize(public_key, options = {}, context = {}, config = {}) # rubocop:disable Lint/UnusedMethodArgument -- `options` and `context` are the verifier constructor contract, unused here as in triauth-js
43
+ super()
44
+
45
+ @public_key = public_key
46
+ @config = Helpers.merge_config(config)
47
+ end
48
+
49
+ # Builds an instance of the Verifier from the raw key information read from the DNS.
50
+ #
51
+ # @param raw_public_key [String] A key as published in the DNS record (base64url of the 32-byte raw public key).
52
+ # @param options [Hash] Key options as read from the DNS record that contained the raw_public_key. Defaults to `{}`.
53
+ # @param context [Hash] Per-verification context. Unused by this verifier. Defaults to `{}`.
54
+ # @param config [Hash] Optional overrides for the global Triauth.config settings. Defaults to `{}`.
55
+ #
56
+ # @return [Ed25519, nil] The instance of this verifier, or nil if key is invalid or other problem occurred.
57
+ def self.from_publishable_key(raw_public_key, options = {}, context = {}, config = {})
58
+ config = Helpers.merge_config(config)
59
+
60
+ begin
61
+ public_key = Base.import_key(Helpers.base64url_to_uint8(raw_public_key), { name: 'Ed25519' })
62
+ return new(public_key, options, context, config)
63
+ rescue StandardError => err
64
+ Helpers.log(config, :debug, err)
65
+ return nil
66
+ end
67
+ end
68
+
69
+ # Verifies that a given signature matches the verifier's public key and the given message.
70
+ #
71
+ # @param message [String] The message to verify against.
72
+ # @param signature_base64url [String] Base64Url encoded signature.
73
+ # @param signed_data [Hash] Signature envelope's signedMetadata. Unused by this verifier.
74
+ # @param unsigned_data [Hash] Signature envelope's unsignedMetadata. Unused by this verifier.
75
+ #
76
+ # @return [Boolean] true or false basing on the verification result.
77
+ def verify(message, signature_base64url, signed_data, unsigned_data) # rubocop:disable Lint/UnusedMethodArgument -- `signed_data` and `unsigned_data` are the verifier contract, unused here as in triauth-js
78
+ data = Helpers.string_to_utf8_bytes(message)
79
+ signature = Helpers.base64url_to_uint8(signature_base64url)
80
+ return Base.verify_signature({ name: 'Ed25519' }, @public_key, signature, data)
81
+ rescue StandardError => err
82
+ Helpers.log(@config, :debug, err)
83
+ return false
84
+ end
85
+ end
86
+
87
+ Triauth::Verifiers.register('ed25519', Ed25519)
88
+ end
89
+ end
@@ -0,0 +1,52 @@
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
+
22
+ module Triauth
23
+ # Holds information about registered digital signature verifiers.
24
+ # Each public key stored in the identity records in the DNS may be associated with a type of verifier that is to be used with it (e.g., `type=es256`).
25
+ module Verifiers
26
+ # the registry keys are wire values: the type String as published in the key record (`'es256'`), never a Symbol - like the LRU cache keys
27
+ @verifiers = {}
28
+
29
+ # Registers a verifier class that should be used for keys of the given type.
30
+ #
31
+ # @param type [String] the type of key, e.g., 'es256'
32
+ # @param verifier_class [Class] a class descending from `Triauth::Verifiers::Base` that should handle the keys of given type
33
+ def self.register(type, verifier_class)
34
+ @verifiers[type] = verifier_class
35
+ end
36
+
37
+ # Returns a verifier registered for the given key type.
38
+ #
39
+ # @param type [String] the type of the key for which verifier is being looked for
40
+ # @return [Class, nil] A class that descends from `Triauth::Verifiers::Base` (with a `from_publishable_key` factory) that is responsible for handling given keys, or nil if not found.
41
+ def self.get(type)
42
+ return @verifiers[type]
43
+ end
44
+
45
+ # Returns a list of registered verifier's types
46
+ #
47
+ # @return [Array<String>]
48
+ def self.list
49
+ return Helpers.ordered_entries(@verifiers).map(&:first) # `Object.keys`: an integer-like type lists first, in numeric order
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,253 @@
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 'base'
21
+ require_relative 'registry'
22
+ require_relative '../helpers'
23
+
24
+ module Triauth
25
+ module Verifiers
26
+ # The base abstract verifier for WebAuthn signatures.
27
+ #
28
+ # Performs the WebAuthn-level assertion validation.
29
+ # Subclasses must define concrete import_algorithm, verify_algorithm, and register themselves (e.g., as `webauthn-es256`).
30
+ class WebAuthn < Base
31
+ # The Web Crypto parameters used to import the publishable key and to verify
32
+ # assertion signatures. Concrete subclasses MUST override these to bind a
33
+ # credential algorithm; left nil, the verifier fails closed.
34
+
35
+ # @return [Hash, nil] The import algorithm identifier a concrete subclass binds (`{name: 'ECDSA', namedCurve: 'P-256', hash: 'SHA-256'}` or `{name: 'Ed25519'}`); nil here.
36
+ def self.import_algorithm = nil
37
+
38
+ # @return [Hash, nil] The verify algorithm identifier a concrete subclass binds (`{name: 'ECDSA', hash: 'SHA-256'}` or `{name: 'Ed25519'}`); nil here.
39
+ def self.verify_algorithm = nil
40
+
41
+ # Returns the bytes of a publishable key in the form that the `raw` import of `import_algorithm` accepts.
42
+ # Subclasses whose credential algorithm has more than one key form override it.
43
+ #
44
+ # @param raw_public_key [String] A key as published in the DNS record.
45
+ # @return [String] the key bytes (a binary String).
46
+ #
47
+ # @raise [TypeError] when the key is not in a form that the credential algorithm accepts.
48
+ def self.public_key_bytes(raw_public_key)
49
+ return Helpers.base64url_to_uint8(raw_public_key)
50
+ end
51
+
52
+ # @return [OpenSSL::PKey::PKey] The public key the assertion signatures are verified against.
53
+ attr_reader :public_key
54
+ # @return [Hash] Key options as read from the DNS record that contained the public key (see {#initialize}).
55
+ attr_reader :options
56
+ # @return [Hash] Per-verification context (see {#initialize}).
57
+ attr_reader :context
58
+ # @return [Hash{Symbol => Object}] The effective settings: the global `Triauth.config` with the constructor's overrides merged over it.
59
+ attr_reader :config
60
+
61
+ # Constructs a new instance of verifier for the given public crypto key and options.
62
+ #
63
+ # @param public_key [OpenSSL::PKey::PKey] A public key in form of an `OpenSSL::PKey` object.
64
+ # @param options [Hash] Key options as read from the DNS record that contained the public_key. Defaults to `{}`.
65
+ # Supports `uv: 'required'` - assertions are then accepted only with the User Verified (UV) flag set
66
+ # (i.e., the authenticator checked PIN/biometrics).
67
+ # @param context [Hash] Per-verification context (`idx`, `mode`, `webAuthnOrigin`). `idx` (key's index in its keyGroup) selects
68
+ # which `unsignedData.sig[idx]` entry to verify against; `webAuthnOrigin`, if set, is enforced against `clientData.origin`.
69
+ # Stored on `context`. Defaults to `{}`.
70
+ # @param config [Hash] Optional overrides for the global Triauth.config settings. Defaults to `{}`.
71
+ def initialize(public_key, options = {}, context = {}, config = {})
72
+ super()
73
+
74
+ @public_key = public_key
75
+ @options = options
76
+ @context = context
77
+ @config = Helpers.merge_config(config)
78
+ end
79
+
80
+ # Builds an instance of the Verifier from the raw key information read from the DNS.
81
+ #
82
+ # @param raw_public_key [String] A key as published in the DNS record.
83
+ # @param options [Hash] Key options as read from the DNS record that contained the raw_public_key. Defaults to `{}`.
84
+ # @param context [Hash] Per-verification context (`idx`, `mode`, `webAuthnOrigin`). `idx` (key's index in its keyGroup) selects
85
+ # which `unsignedData.sig[idx]` entry to verify against; `webAuthnOrigin`, if set, is enforced against `clientData.origin`.
86
+ # Stored on `context`. Defaults to `{}`.
87
+ # @param config [Hash] Optional overrides for the global Triauth.config settings. Defaults to `{}`.
88
+ #
89
+ # @return [WebAuthn, nil] The instance of this verifier, or nil if key is invalid or other problem occurred.
90
+ def self.from_publishable_key(raw_public_key, options = {}, context = {}, config = {})
91
+ config = Helpers.merge_config(config)
92
+
93
+ import_algorithm = self.import_algorithm
94
+ if import_algorithm.nil?
95
+ Helpers.log(config, :debug, 'WebAuthn is an algorithm-agnostic base verifier - use a concrete subclass (e.g., WebAuthnEs256)')
96
+ return nil
97
+ end
98
+
99
+ begin
100
+ public_key = Base.import_key(public_key_bytes(raw_public_key), import_algorithm)
101
+
102
+ return new(public_key, options, context, config)
103
+ rescue StandardError => err
104
+ Helpers.log(config, :debug, err)
105
+ return nil
106
+ end
107
+ end
108
+
109
+ # Verifies that a given signature matches the verifier's public key and the given message.
110
+ #
111
+ # @param message [String] The message that was signed; its SHA-256 digest must match `clientData.challenge`.
112
+ # @param signature_base64url [String] Base64Url encoded signature over `(authenticatorData || sha256(clientDataJSON))`.
113
+ # @param signed_data [Hash] Signature envelope's signedMetadata. Unused by this verifier.
114
+ # @param unsigned_data [Hash{Symbol => Object}] Signature envelope's unsignedMetadata. Must contain `sig[idx]` for this key's
115
+ # index (set on `context[:idx]`; `sig` is an object keyed by the index string, or an array read by position),
116
+ # carrying the original `clientDataJSON` (string) and `authenticatorData` (base64url, minimum 37 bytes, with the
117
+ # User Present flag set - silent CTAP-level assertions are rejected).
118
+ #
119
+ # @return [Boolean] true or false basing on the verification result.
120
+ def verify(message, signature_base64url, signed_data, unsigned_data) # rubocop:disable Lint/UnusedMethodArgument -- `signed_data` is the verifier contract, unused here as in triauth-js
121
+ verify_algorithm = self.class.verify_algorithm
122
+ if verify_algorithm.nil?
123
+ Helpers.log(@config, :debug, 'WebAuthn is an algorithm-agnostic base verifier - use a concrete subclass (e.g., WebAuthnEs256)')
124
+ return false
125
+ end
126
+
127
+ idx = @context[:idx]
128
+ idx_key = idx.to_s.to_sym # the JS `sig[idx]` looks up the object key `"0"` for the index 0; the JSON key is the Symbol `:"0"` here
129
+ expected_challenge = Helpers.sha256(message)
130
+ expected_origin = @context[:webAuthnOrigin]
131
+
132
+ # SECURITY NOTE: `unsigned_data` is NOT covered by the triauth envelope's signature, but:
133
+ # - clientData.challenge is checked to be the expected_challenge
134
+ # - (authenticatorData + sha256(clientDataJSON)) is covered by the WebAuthn-signed payload
135
+ if !unsigned_data.is_a?(Hash)
136
+ return false
137
+ end
138
+
139
+ sig = unsigned_data[:sig]
140
+ # the JS `sig[idx]` reads an Array by position and an object by its "0" key; both shapes are accepted, as in triauth-js
141
+ entry = nil
142
+ if sig.is_a?(Array)
143
+ entry = sig[idx] if idx.is_a?(Integer) && idx >= 0 # a negative or non-integer index never reads an Array from its end
144
+ elsif sig.is_a?(Hash)
145
+ entry = sig[idx_key]
146
+ end
147
+ if !entry.is_a?(Hash) ||
148
+ !Helpers.is_string(entry[:clientDataJSON]) ||
149
+ !Helpers.is_base64url_string(entry[:authenticatorData])
150
+ return false
151
+ end
152
+
153
+ authenticator_data, client_data_json = entry.values_at(:authenticatorData, :clientDataJSON)
154
+
155
+ # Verify clientData structure, including that the clientData.challenge is an SHA-256 digest of the message
156
+ client_data = Helpers.safe_parse_json(client_data_json)
157
+ if client_data[:challenge] != expected_challenge || # <--- challenge MUST MATCH --- !!!
158
+ client_data_json.scan('"challenge":').length != 1 || # Ensure that "challenge" key appears exactly once in the clientDataJSON, as an extra safety precaution
159
+ client_data[:type] != 'webauthn.get' || # type should always be 'webauthn.get'
160
+ (client_data.key?(:crossOrigin) && client_data[:crossOrigin] != false) || # if present, must be exactly false (a cross-origin <iframe> assertion carries true); the member is optional and absent means top-level same-origin (WebKit omits it) - `key?`, not `nil?`: an explicit null is present and rejected, as in triauth-js
161
+ (!expected_origin.nil? && !expected_origin.empty? && client_data[:origin] != expected_origin) # origin, if available from context, must match
162
+ return false
163
+ end
164
+
165
+ # Parse the authenticatorData: 32-byte rpIdHash, 1 flags byte, 4-byte signCount (37 bytes minimum)
166
+ authenticator_data_bytes = Helpers.base64url_to_uint8(authenticator_data)
167
+ if authenticator_data_bytes.bytesize < 37
168
+ Helpers.log(@config, :debug, 'WebAuthn assertion rejected - authenticatorData shorter than 37 bytes')
169
+ return false
170
+ end
171
+
172
+ # Require the User Present (UP) flag, as the W3C Web Authentication specification mandates for assertions.
173
+ # Browser-mediated assertions always carry UP=1 (the client performs a presence test before
174
+ # returning one); its absence indicates a CTAP-level "silent" assertion (options.up=false),
175
+ # e.g., minted by malware with direct access to a connected authenticator.
176
+ #
177
+ # The rpIdHash (bytes 0-31) is intentionally NOT checked: the public key is pinned in the
178
+ # DNS identity records and a WebAuthn credential only ever signs under its own rpId, so
179
+ # there is no cross-RP credential confusion to prevent; the phishing angle is covered by
180
+ # the clientData.origin check above. The signCount (bytes 33-36) is not checked either:
181
+ # clone detection requires server-side state that this stateless verifier does not have
182
+ # (and modern passkeys report a constant 0).
183
+ if (authenticator_data_bytes.getbyte(32) & 0x01) != 0x01
184
+ Helpers.log(@config, :debug, 'WebAuthn assertion rejected - User Present (UP) flag not set')
185
+ return false
186
+ end
187
+
188
+ # When the key record demands user verification (`uv=required`), also require the
189
+ # User Verified (UV) flag - i.e., the authenticator must have checked PIN/biometrics.
190
+ # Key records carrying any other `uv` value never reach this point - they taint their
191
+ # whole keyGroup during Triauth::IdentityKeys#add (fail closed, never silently weaker).
192
+ if @options[:uv] == 'required' && (authenticator_data_bytes.getbyte(32) & 0x04) != 0x04
193
+ Helpers.log(@config, :debug, 'WebAuthn assertion rejected - User Verified (UV) flag required by key options but not set')
194
+ return false
195
+ end
196
+
197
+ # Reconstruct the payload that was signed by webauthn
198
+ client_data_hash = OpenSSL::Digest::SHA256.digest(Helpers.string_to_utf8_bytes(client_data_json))
199
+
200
+ web_authn_payload = authenticator_data_bytes + client_data_hash
201
+
202
+ # Convert the signature being verified back to a binary String for verify_signature
203
+ signature = Helpers.base64url_to_uint8(signature_base64url)
204
+
205
+ # Verify the digital signature (may raise an exception if arguments are invalid)
206
+ return Base.verify_signature(verify_algorithm, @public_key, signature, web_authn_payload)
207
+ rescue StandardError => err
208
+ Helpers.log(@config, :debug, err)
209
+ return false
210
+ end
211
+ end
212
+
213
+ # A verifier for WebAuthn signatures backed by ECDSA P-256 (ES256) credentials.
214
+ #
215
+ # Shares all WebAuthn assertion validation with the WebAuthn base class; this subclass
216
+ # only binds the ES256 credential algorithm. The publishable key is the 65-byte uncompressed
217
+ # or the 33-byte compressed P-256 point (see `Triauth::Helpers.decode_p256_public_key`).
218
+ class WebAuthnEs256 < WebAuthn
219
+ # @return [Hash] `{name: 'ECDSA', namedCurve: 'P-256', hash: 'SHA-256'}`
220
+ def self.import_algorithm = { name: 'ECDSA', namedCurve: 'P-256', hash: 'SHA-256' }
221
+
222
+ # @return [Hash] `{name: 'ECDSA', hash: 'SHA-256'}`
223
+ def self.verify_algorithm = { name: 'ECDSA', hash: 'SHA-256' }
224
+
225
+ # Returns the publishable key as the 65-byte uncompressed P-256 point.
226
+ #
227
+ # @param raw_public_key [String] A key as published in the DNS record.
228
+ # @return [String] the 65-byte uncompressed point (a binary String).
229
+ #
230
+ # @raise [TypeError] when the key is not an uncompressed or a compressed P-256 point.
231
+ def self.public_key_bytes(raw_public_key)
232
+ return Helpers.decode_p256_public_key(Helpers.base64url_to_uint8(raw_public_key))
233
+ end
234
+ end
235
+
236
+ # A verifier for WebAuthn signatures backed by Ed25519 (EdDSA over Curve25519) credentials.
237
+ #
238
+ # Shares all WebAuthn assertion validation with the WebAuthn base class; this subclass
239
+ # only binds the Ed25519 credential algorithm. Like the plain Ed25519 verifier, it requires
240
+ # Ed25519 support in the openssl gem (3.x over OpenSSL 1.1.1 or later) - on runtimes without it,
241
+ # `from_publishable_key` returns nil and the keyGroup fails verification (fail closed).
242
+ class WebAuthnEd25519 < WebAuthn
243
+ # @return [Hash] `{name: 'Ed25519'}`
244
+ def self.import_algorithm = { name: 'Ed25519' }
245
+
246
+ # @return [Hash] `{name: 'Ed25519'}`
247
+ def self.verify_algorithm = { name: 'Ed25519' }
248
+ end
249
+
250
+ Triauth::Verifiers.register('webauthn-es256', WebAuthnEs256)
251
+ Triauth::Verifiers.register('webauthn-ed25519', WebAuthnEd25519)
252
+ end
253
+ end
@@ -0,0 +1,37 @@
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 'verifiers/registry'
21
+ require_relative 'verifiers/base'
22
+ # Importing the concrete verifier modules for their side effects: each one calls
23
+ # `register(...)` at module-load time so that `Triauth::Verifiers.get(...)` works
24
+ # out of the box for 'es256', 'ed25519', 'webauthn-es256', and 'webauthn-ed25519'.
25
+ require_relative 'verifiers/ecdsa'
26
+ require_relative 'verifiers/ed25519'
27
+ require_relative 'verifiers/web_authn'
28
+
29
+ module Triauth
30
+ # A collection holding information about registered digital signature verifiers.
31
+ #
32
+ # Each public key stored in the identity records in the DNS may be associated with a type of verifier that is to be used with it.
33
+ module Verifiers
34
+ # the JS re-exports (`register`, `get`, `list`, `Base`, `Ecdsa`, `Ed25519`, `WebAuthn`, `WebAuthnEs256`, `WebAuthnEd25519`)
35
+ # have no counterpart: the module functions and the classes live on `Triauth::Verifiers` once their files are required
36
+ end
37
+ end