http_message_signatures 0.1.0
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 +7 -0
- data/CHANGELOG.md +14 -0
- data/LICENSE.txt +661 -0
- data/README.md +288 -0
- data/lib/http_message_signatures/cavage/signature_header.rb +112 -0
- data/lib/http_message_signatures/cavage/signer.rb +77 -0
- data/lib/http_message_signatures/cavage/signing_string.rb +56 -0
- data/lib/http_message_signatures/cavage/verifier.rb +127 -0
- data/lib/http_message_signatures/errors.rb +16 -0
- data/lib/http_message_signatures/http_signer.rb +108 -0
- data/lib/http_message_signatures/keys.rb +58 -0
- data/lib/http_message_signatures/rack_middleware.rb +141 -0
- data/lib/http_message_signatures/rfc9421/algorithms.rb +155 -0
- data/lib/http_message_signatures/rfc9421/component_identifier.rb +61 -0
- data/lib/http_message_signatures/rfc9421/component_resolver.rb +148 -0
- data/lib/http_message_signatures/rfc9421/signature_base.rb +61 -0
- data/lib/http_message_signatures/rfc9421/signature_params.rb +117 -0
- data/lib/http_message_signatures/rfc9421/signer.rb +104 -0
- data/lib/http_message_signatures/rfc9421/verifier.rb +115 -0
- data/lib/http_message_signatures/version.rb +3 -0
- data/lib/http_message_signatures.rb +34 -0
- metadata +108 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
require 'uri'
|
|
2
|
+
|
|
3
|
+
module HTTPMessageSignatures
|
|
4
|
+
# Wraps the `http` gem client to automatically sign outgoing requests.
|
|
5
|
+
#
|
|
6
|
+
# Supports both draft-cavage and RFC 9421 signing.
|
|
7
|
+
#
|
|
8
|
+
# @example Draft-Cavage signing (Fediverse standard)
|
|
9
|
+
# client = HTTPMessageSignatures::HTTPSigner.new(
|
|
10
|
+
# key_id: 'https://example.com/users/alice#main-key',
|
|
11
|
+
# private_key: private_key
|
|
12
|
+
# )
|
|
13
|
+
# response = client.post('https://remote.example/inbox',
|
|
14
|
+
# headers: { 'Content-Type' => 'application/activity+json' },
|
|
15
|
+
# body: '{"type":"Follow"}'
|
|
16
|
+
# )
|
|
17
|
+
#
|
|
18
|
+
# @example RFC 9421 signing
|
|
19
|
+
# client = HTTPMessageSignatures::HTTPSigner.new(
|
|
20
|
+
# key_id: 'my-key-id',
|
|
21
|
+
# private_key: private_key,
|
|
22
|
+
# mode: :rfc9421,
|
|
23
|
+
# algorithm: 'ed25519'
|
|
24
|
+
# )
|
|
25
|
+
class HTTPSigner
|
|
26
|
+
# @param key_id [String] Key identifier
|
|
27
|
+
# @param private_key [OpenSSL::PKey] Private key for signing
|
|
28
|
+
# @param algorithm [String] Algorithm for RFC 9421 (default: 'rsa-v1_5-sha256')
|
|
29
|
+
# @param covered_components [Array<String>] Components for RFC 9421
|
|
30
|
+
# @param http [HTTP::Client, nil] Custom HTTP client (default: HTTP gem)
|
|
31
|
+
# @param mode [:cavage, :rfc9421] Signing mode (default: :cavage)
|
|
32
|
+
def initialize key_id:, private_key:,
|
|
33
|
+
algorithm: 'rsa-v1_5-sha256', covered_components: nil,
|
|
34
|
+
http: nil, mode: :cavage
|
|
35
|
+
@key_id = key_id
|
|
36
|
+
@private_key = private_key
|
|
37
|
+
@mode = mode
|
|
38
|
+
@algorithm = algorithm
|
|
39
|
+
@components = covered_components
|
|
40
|
+
@http = http || default_http
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
%i[get head delete].each do |verb|
|
|
44
|
+
define_method verb do |url, headers: {}, body: nil|
|
|
45
|
+
request verb, url, headers: headers, body: body
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
%i[post put patch].each do |verb|
|
|
50
|
+
define_method verb do |url, headers: {}, body: nil|
|
|
51
|
+
request verb, url, headers: headers, body: body
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def request verb, url, headers: {}, body: nil
|
|
58
|
+
signed = sign_headers verb, url, headers, body
|
|
59
|
+
@http.request verb, url, headers: signed, body: body
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def sign_headers verb, url, headers, body
|
|
63
|
+
case @mode
|
|
64
|
+
when :cavage then sign_cavage(verb, url, headers, body)
|
|
65
|
+
when :rfc9421 then sign_rfc9421(verb, url, headers, body)
|
|
66
|
+
else raise ArgumentError, "Unknown signing mode: #{@mode}"
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def sign_cavage verb, url, headers, body
|
|
71
|
+
uri = URI.parse url.to_s
|
|
72
|
+
headers = ensure_host headers, uri
|
|
73
|
+
|
|
74
|
+
signer = Cavage::Signer.new key_id: @key_id, private_key: @private_key
|
|
75
|
+
signer.sign headers: headers, method: verb.to_s.upcase, path: uri.request_uri, body: body
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def sign_rfc9421 verb, url, headers, body
|
|
79
|
+
uri = URI.parse url.to_s
|
|
80
|
+
headers = ensure_host headers, uri
|
|
81
|
+
|
|
82
|
+
components = @components || default_rfc9421_components(verb, body)
|
|
83
|
+
|
|
84
|
+
signer = RFC9421::Signer.new(
|
|
85
|
+
key: @private_key,
|
|
86
|
+
key_id: @key_id,
|
|
87
|
+
algorithm: @algorithm,
|
|
88
|
+
covered_components: components
|
|
89
|
+
)
|
|
90
|
+
signer.sign method: verb.to_s.upcase, url: url.to_s, headers: headers
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def ensure_host headers, uri
|
|
94
|
+
result = headers.dup
|
|
95
|
+
result['Host'] ||= uri.host
|
|
96
|
+
result
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def default_rfc9421_components _verb, _body
|
|
100
|
+
%w[@method @authority @path content-type]
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def default_http
|
|
104
|
+
require 'http'
|
|
105
|
+
HTTP
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
require 'openssl'
|
|
2
|
+
|
|
3
|
+
module HTTPMessageSignatures
|
|
4
|
+
# Key generation and PEM parsing helpers for HTTP Message Signatures.
|
|
5
|
+
#
|
|
6
|
+
# Supports RSA and Ed25519 — the two key types used in the Fediverse.
|
|
7
|
+
module Keys
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# Generate an RSA key pair.
|
|
11
|
+
#
|
|
12
|
+
# @param bits [Integer] key size in bits (default: 2048)
|
|
13
|
+
# @return [OpenSSL::PKey::RSA] private key (call .public_key for the public half)
|
|
14
|
+
def generate_rsa bits: 2048
|
|
15
|
+
OpenSSL::PKey::RSA.generate bits
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Generate an Ed25519 key pair.
|
|
19
|
+
#
|
|
20
|
+
# @return [OpenSSL::PKey::PKey] private key
|
|
21
|
+
def generate_ed25519
|
|
22
|
+
OpenSSL::PKey.generate_key 'ED25519'
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Parse a PEM-encoded private key.
|
|
26
|
+
#
|
|
27
|
+
# @param pem [String] PEM string
|
|
28
|
+
# @return [OpenSSL::PKey::PKey]
|
|
29
|
+
def parse_private_key pem
|
|
30
|
+
OpenSSL::PKey.read pem
|
|
31
|
+
rescue OpenSSL::PKey::PKeyError => e
|
|
32
|
+
raise KeyError, "Cannot parse private key: #{e.message}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Parse a PEM-encoded public key.
|
|
36
|
+
#
|
|
37
|
+
# @param pem [String] PEM string
|
|
38
|
+
# @return [OpenSSL::PKey::PKey]
|
|
39
|
+
def parse_public_key pem
|
|
40
|
+
OpenSSL::PKey.read pem
|
|
41
|
+
rescue OpenSSL::PKey::PKeyError => e
|
|
42
|
+
raise KeyError, "Cannot parse public key: #{e.message}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Export a key to PEM format.
|
|
46
|
+
#
|
|
47
|
+
# @param key [OpenSSL::PKey::PKey] any OpenSSL key object
|
|
48
|
+
# @param private_key [Boolean] export private key (default: false, exports public)
|
|
49
|
+
# @return [String] PEM string
|
|
50
|
+
def to_pem key, private_key: false
|
|
51
|
+
if private_key
|
|
52
|
+
key.private_to_pem
|
|
53
|
+
else
|
|
54
|
+
key.public_to_pem
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
require 'rack'
|
|
2
|
+
|
|
3
|
+
module HTTPMessageSignatures
|
|
4
|
+
# Rack middleware that verifies HTTP message signatures on incoming requests.
|
|
5
|
+
#
|
|
6
|
+
# Supports both draft-cavage and RFC 9421 signatures. Detects which format
|
|
7
|
+
# is present based on the headers: RFC 9421 uses Signature-Input, while
|
|
8
|
+
# draft-cavage uses the Signature header alone.
|
|
9
|
+
#
|
|
10
|
+
# @example Basic usage in config.ru
|
|
11
|
+
# use HTTPMessageSignatures::RackMiddleware do |key_id|
|
|
12
|
+
# fetch_public_key(key_id)
|
|
13
|
+
# end
|
|
14
|
+
#
|
|
15
|
+
# @example With options
|
|
16
|
+
# use HTTPMessageSignatures::RackMiddleware,
|
|
17
|
+
# paths: ['/inbox'],
|
|
18
|
+
# clock_skew: 300,
|
|
19
|
+
# on_failure: ->(env, error) { [403, {}, ['Forbidden']] }
|
|
20
|
+
# do |key_id|
|
|
21
|
+
# fetch_public_key(key_id)
|
|
22
|
+
# end
|
|
23
|
+
class RackMiddleware
|
|
24
|
+
DEFAULT_FAILURE_RESPONSE = [401, { 'content-type' => 'text/plain' }, ['Unauthorized']].freeze
|
|
25
|
+
|
|
26
|
+
# @param app [#call] The next Rack app
|
|
27
|
+
# @param clock_skew [Integer, nil] Clock skew for Cavage Date validation (default: 12h)
|
|
28
|
+
# @param on_failure [Proc, nil] Custom failure handler, receives (env, error) and returns Rack response
|
|
29
|
+
# @param paths [Array<String>, nil] Only verify requests matching these path prefixes (nil = all)
|
|
30
|
+
# @yield [key_id] Block to resolve a key_id to an OpenSSL public key
|
|
31
|
+
def initialize app, clock_skew: Cavage::Verifier::DEFAULT_CLOCK_SKEW, on_failure: nil, paths: nil, &key_resolver
|
|
32
|
+
@app = app
|
|
33
|
+
@paths = paths
|
|
34
|
+
@key_resolver = key_resolver
|
|
35
|
+
@on_failure = on_failure
|
|
36
|
+
@cavage = Cavage::Verifier.new clock_skew: clock_skew
|
|
37
|
+
@rfc9421 = RFC9421::Verifier.new
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def call env
|
|
41
|
+
return @app.call(env) unless verify_path?(env)
|
|
42
|
+
|
|
43
|
+
headers = extract_headers env
|
|
44
|
+
return @app.call(env) unless signed_request?(headers)
|
|
45
|
+
|
|
46
|
+
body = read_body env
|
|
47
|
+
verify_request env, headers, body
|
|
48
|
+
rescue ParseError, VerificationError, KeyError => e
|
|
49
|
+
failure_response env, e
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def verify_path? env
|
|
55
|
+
return true unless @paths
|
|
56
|
+
|
|
57
|
+
path = env['PATH_INFO']
|
|
58
|
+
@paths.any? { |prefix| path.start_with? prefix }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def signed_request? headers
|
|
62
|
+
headers.key?('Signature') || headers.key?('Signature-Input')
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def extract_headers env
|
|
66
|
+
headers = {}
|
|
67
|
+
env.each do |key, value|
|
|
68
|
+
next unless key.start_with? 'HTTP_'
|
|
69
|
+
|
|
70
|
+
header_name = key[5..].tr('_', '-').split('-').map(&:capitalize).join('-')
|
|
71
|
+
headers[header_name] = value
|
|
72
|
+
end
|
|
73
|
+
headers['Host'] = env['HTTP_HOST'] || env['SERVER_NAME']
|
|
74
|
+
headers
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def read_body env
|
|
78
|
+
input = env['rack.input']
|
|
79
|
+
return nil unless input
|
|
80
|
+
|
|
81
|
+
body = input.read
|
|
82
|
+
input.rewind
|
|
83
|
+
body
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def verify_request env, headers, body
|
|
87
|
+
if headers.key? 'Signature-Input'
|
|
88
|
+
verify_rfc9421 env, headers
|
|
89
|
+
else
|
|
90
|
+
verify_cavage env, headers, body
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def verify_cavage env, headers, body
|
|
95
|
+
method = env['REQUEST_METHOD']
|
|
96
|
+
path = build_path env
|
|
97
|
+
|
|
98
|
+
verified = @cavage.verify(headers: headers, method: method, path: path, body: body, &@key_resolver)
|
|
99
|
+
|
|
100
|
+
return failure_response(env, VerificationError.new('Invalid signature')) unless verified
|
|
101
|
+
|
|
102
|
+
env['http_signature.verified'] = true
|
|
103
|
+
@app.call env
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def verify_rfc9421 env, headers
|
|
107
|
+
method = env['REQUEST_METHOD']
|
|
108
|
+
url = build_url env
|
|
109
|
+
|
|
110
|
+
result = @rfc9421.verify(headers: headers, method: method, url: url) do |key_id, _alg|
|
|
111
|
+
@key_resolver.call key_id
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
return failure_response(env, VerificationError.new('Invalid signature')) unless result.verified
|
|
115
|
+
|
|
116
|
+
env['http_signature.verified'] = true
|
|
117
|
+
env['http_signature.key_id'] = result.key_id
|
|
118
|
+
env['http_signature.algorithm'] = result.algorithm
|
|
119
|
+
@app.call env
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def build_path env
|
|
123
|
+
path = env['PATH_INFO']
|
|
124
|
+
query = env['QUERY_STRING']
|
|
125
|
+
query && !query.empty? ? "#{path}?#{query}" : path
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def build_url env
|
|
129
|
+
scheme = env['rack.url_scheme'] || 'https'
|
|
130
|
+
host = env['HTTP_HOST'] || env['SERVER_NAME']
|
|
131
|
+
path = build_path env
|
|
132
|
+
"#{scheme}://#{host}#{path}"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def failure_response env, error
|
|
136
|
+
return @on_failure.call(env, error) if @on_failure
|
|
137
|
+
|
|
138
|
+
DEFAULT_FAILURE_RESPONSE
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
require 'openssl'
|
|
2
|
+
|
|
3
|
+
module HTTPMessageSignatures
|
|
4
|
+
module RFC9421
|
|
5
|
+
# Algorithm implementations for RFC 9421 Section 3.3.
|
|
6
|
+
module Algorithms
|
|
7
|
+
# RSA-PSS using SHA-512 (Section 3.3.1)
|
|
8
|
+
module RsaPssSha512
|
|
9
|
+
ALG_NAME = 'rsa-pss-sha512'.freeze
|
|
10
|
+
PADDING_OPTS = {
|
|
11
|
+
'rsa_padding_mode' => 'pss',
|
|
12
|
+
'rsa_pss_saltlen' => '64',
|
|
13
|
+
'rsa_mgf1_md' => 'SHA512'
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
def sign private_key, data
|
|
19
|
+
private_key.sign 'SHA512', data, PADDING_OPTS
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def verify public_key, signature, data
|
|
23
|
+
public_key.verify 'SHA512', signature, data, PADDING_OPTS
|
|
24
|
+
rescue OpenSSL::PKey::PKeyError
|
|
25
|
+
false
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# RSASSA-PKCS1-v1_5 using SHA-256 (Section 3.3.2)
|
|
30
|
+
module RsaV15Sha256
|
|
31
|
+
ALG_NAME = 'rsa-v1_5-sha256'.freeze
|
|
32
|
+
|
|
33
|
+
module_function
|
|
34
|
+
|
|
35
|
+
def sign private_key, data
|
|
36
|
+
private_key.sign 'SHA256', data
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def verify public_key, signature, data
|
|
40
|
+
public_key.verify 'SHA256', signature, data
|
|
41
|
+
rescue OpenSSL::PKey::PKeyError
|
|
42
|
+
false
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# HMAC using SHA-256 (Section 3.3.3)
|
|
47
|
+
module HmacSha256
|
|
48
|
+
ALG_NAME = 'hmac-sha256'.freeze
|
|
49
|
+
|
|
50
|
+
module_function
|
|
51
|
+
|
|
52
|
+
def sign key, data
|
|
53
|
+
OpenSSL::HMAC.digest 'SHA256', key, data
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def verify key, signature, data
|
|
57
|
+
expected = sign key, data
|
|
58
|
+
OpenSSL.fixed_length_secure_compare signature, expected
|
|
59
|
+
rescue ArgumentError
|
|
60
|
+
false
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# ECDSA using curve P-256 and SHA-256 (Section 3.3.4)
|
|
65
|
+
# Signatures are raw r||s (64 bytes), not DER-encoded.
|
|
66
|
+
module EcdsaP256Sha256
|
|
67
|
+
ALG_NAME = 'ecdsa-p256-sha256'.freeze
|
|
68
|
+
COORD_SIZE = 32
|
|
69
|
+
|
|
70
|
+
module_function
|
|
71
|
+
|
|
72
|
+
def sign private_key, data
|
|
73
|
+
der_sig = private_key.sign 'SHA256', data
|
|
74
|
+
der_to_raw der_sig, COORD_SIZE
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def verify public_key, signature, data
|
|
78
|
+
der_sig = raw_to_der signature, COORD_SIZE
|
|
79
|
+
public_key.verify 'SHA256', der_sig, data
|
|
80
|
+
rescue OpenSSL::PKey::PKeyError, OpenSSL::ASN1::ASN1Error
|
|
81
|
+
false
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def der_to_raw der_sig, coord_size
|
|
85
|
+
asn1 = OpenSSL::ASN1.decode der_sig
|
|
86
|
+
r = asn1.value[0].value
|
|
87
|
+
s = asn1.value[1].value
|
|
88
|
+
r.to_s(2).rjust(coord_size, "\x00") + s.to_s(2).rjust(coord_size, "\x00")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def raw_to_der raw_sig, coord_size
|
|
92
|
+
r_bytes = raw_sig[0, coord_size]
|
|
93
|
+
s_bytes = raw_sig[coord_size, coord_size]
|
|
94
|
+
r = OpenSSL::BN.new r_bytes, 2
|
|
95
|
+
s = OpenSSL::BN.new s_bytes, 2
|
|
96
|
+
OpenSSL::ASN1::Sequence.new([
|
|
97
|
+
OpenSSL::ASN1::Integer.new(r),
|
|
98
|
+
OpenSSL::ASN1::Integer.new(s)
|
|
99
|
+
]).to_der
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# ECDSA using curve P-384 and SHA-384 (Section 3.3.5)
|
|
104
|
+
# Signatures are raw r||s (96 bytes), not DER-encoded.
|
|
105
|
+
module EcdsaP384Sha384
|
|
106
|
+
ALG_NAME = 'ecdsa-p384-sha384'.freeze
|
|
107
|
+
COORD_SIZE = 48
|
|
108
|
+
|
|
109
|
+
module_function
|
|
110
|
+
|
|
111
|
+
def sign private_key, data
|
|
112
|
+
der_sig = private_key.sign 'SHA384', data
|
|
113
|
+
EcdsaP256Sha256.der_to_raw der_sig, COORD_SIZE
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def verify public_key, signature, data
|
|
117
|
+
der_sig = EcdsaP256Sha256.raw_to_der signature, COORD_SIZE
|
|
118
|
+
public_key.verify 'SHA384', der_sig, data
|
|
119
|
+
rescue OpenSSL::PKey::PKeyError, OpenSSL::ASN1::ASN1Error
|
|
120
|
+
false
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Ed25519 (Section 3.3.6)
|
|
125
|
+
module Ed25519
|
|
126
|
+
ALG_NAME = 'ed25519'.freeze
|
|
127
|
+
|
|
128
|
+
module_function
|
|
129
|
+
|
|
130
|
+
def sign private_key, data
|
|
131
|
+
private_key.sign nil, data
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def verify public_key, signature, data
|
|
135
|
+
public_key.verify nil, signature, data
|
|
136
|
+
rescue OpenSSL::PKey::PKeyError
|
|
137
|
+
false
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
REGISTRY = {
|
|
142
|
+
'rsa-pss-sha512' => RsaPssSha512,
|
|
143
|
+
'rsa-v1_5-sha256' => RsaV15Sha256,
|
|
144
|
+
'hmac-sha256' => HmacSha256,
|
|
145
|
+
'ecdsa-p256-sha256' => EcdsaP256Sha256,
|
|
146
|
+
'ecdsa-p384-sha384' => EcdsaP384Sha384,
|
|
147
|
+
'ed25519' => Ed25519
|
|
148
|
+
}.freeze
|
|
149
|
+
|
|
150
|
+
def self.resolve name
|
|
151
|
+
REGISTRY[name] || raise(ParseError, "Unknown algorithm: #{name}")
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
module HTTPMessageSignatures
|
|
2
|
+
module RFC9421
|
|
3
|
+
# Represents a component identifier with optional parameters.
|
|
4
|
+
#
|
|
5
|
+
# A component identifier names a message component (e.g. "@method", "content-type")
|
|
6
|
+
# and may include parameters (e.g. "@query-param";name="Pet").
|
|
7
|
+
#
|
|
8
|
+
# @example Simple identifier
|
|
9
|
+
# ComponentIdentifier.new("@method")
|
|
10
|
+
#
|
|
11
|
+
# @example With parameters
|
|
12
|
+
# ComponentIdentifier.new("@query-param", "name" => "Pet")
|
|
13
|
+
class ComponentIdentifier
|
|
14
|
+
attr_reader :name, :params
|
|
15
|
+
|
|
16
|
+
def initialize name, params = {}
|
|
17
|
+
@name = name
|
|
18
|
+
@params = params
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Is this a derived component (starts with @)?
|
|
22
|
+
def derived?
|
|
23
|
+
@name.start_with? '@'
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Serialize as an sf-string with parameters.
|
|
27
|
+
#
|
|
28
|
+
# @return [String] e.g. '"@query-param";name="Pet"'
|
|
29
|
+
def to_s
|
|
30
|
+
result = "\"#{@name}\""
|
|
31
|
+
@params.each do |key, value|
|
|
32
|
+
result += if value == true
|
|
33
|
+
";#{key}"
|
|
34
|
+
else
|
|
35
|
+
";#{key}=\"#{value}\""
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
result
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def == other
|
|
42
|
+
case other
|
|
43
|
+
when String
|
|
44
|
+
@params.empty? && @name == other
|
|
45
|
+
when ComponentIdentifier
|
|
46
|
+
@name == other.name && @params == other.params
|
|
47
|
+
else
|
|
48
|
+
false
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def eql? other
|
|
53
|
+
self == other
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def hash
|
|
57
|
+
[@name, @params].hash
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
require 'uri'
|
|
2
|
+
|
|
3
|
+
module HTTPMessageSignatures
|
|
4
|
+
module RFC9421
|
|
5
|
+
# Resolves HTTP message component values per RFC 9421 Section 2.
|
|
6
|
+
#
|
|
7
|
+
# Given a component identifier and a message context (method, URL, headers,
|
|
8
|
+
# status), returns the canonicalized component value.
|
|
9
|
+
class ComponentResolver
|
|
10
|
+
# @param headers [Hash{String => String}] HTTP headers (lowercase keys)
|
|
11
|
+
# @param method [String, nil] HTTP method (e.g. "POST")
|
|
12
|
+
# @param status [Integer, nil] HTTP response status code
|
|
13
|
+
# @param url [String, URI] Full request URL (e.g. "https://example.com/path?q=1")
|
|
14
|
+
def initialize headers: {}, method: nil, status: nil, url: nil
|
|
15
|
+
@method = method
|
|
16
|
+
@uri = url ? URI.parse(url.to_s) : nil
|
|
17
|
+
@headers = normalize_headers headers
|
|
18
|
+
@status = status
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Resolve the value for a component identifier.
|
|
22
|
+
#
|
|
23
|
+
# @param component [String, ComponentIdentifier] Component name or identifier
|
|
24
|
+
# @return [String] Canonicalized component value
|
|
25
|
+
# @raise [ParseError] If the component cannot be resolved
|
|
26
|
+
DERIVED_RESOLVERS = {
|
|
27
|
+
'@method' => :resolve_method,
|
|
28
|
+
'@target-uri' => :resolve_target_uri,
|
|
29
|
+
'@authority' => :resolve_authority,
|
|
30
|
+
'@scheme' => :resolve_scheme,
|
|
31
|
+
'@request-target' => :resolve_request_target,
|
|
32
|
+
'@path' => :resolve_path,
|
|
33
|
+
'@query' => :resolve_query,
|
|
34
|
+
'@query-param' => :resolve_query_param,
|
|
35
|
+
'@status' => :resolve_status
|
|
36
|
+
}.freeze
|
|
37
|
+
|
|
38
|
+
# Resolve the value for a component identifier.
|
|
39
|
+
#
|
|
40
|
+
# @param component [String, ComponentIdentifier] Component name or identifier
|
|
41
|
+
# @return [String] Canonicalized component value
|
|
42
|
+
# @raise [ParseError] If the component cannot be resolved
|
|
43
|
+
def resolve component
|
|
44
|
+
name = component.is_a?(ComponentIdentifier) ? component.name : component.to_s
|
|
45
|
+
params = component.is_a?(ComponentIdentifier) ? component.params : {}
|
|
46
|
+
|
|
47
|
+
if name.start_with? '@'
|
|
48
|
+
resolve_derived name, params
|
|
49
|
+
else
|
|
50
|
+
resolve_field name
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def normalize_headers headers
|
|
57
|
+
headers.transform_keys(&:downcase)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def resolve_derived name, params
|
|
61
|
+
resolver = DERIVED_RESOLVERS[name]
|
|
62
|
+
raise ParseError, "Unknown derived component: #{name}" unless resolver
|
|
63
|
+
|
|
64
|
+
method(resolver).arity.zero? ? send(resolver) : send(resolver, params)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def resolve_method
|
|
68
|
+
raise ParseError, '@method requires a request message' unless @method
|
|
69
|
+
|
|
70
|
+
@method.to_s
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def resolve_target_uri
|
|
74
|
+
raise ParseError, '@target-uri requires a request URL' unless @uri
|
|
75
|
+
|
|
76
|
+
@uri.to_s
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def resolve_authority
|
|
80
|
+
raise ParseError, '@authority requires a request URL' unless @uri
|
|
81
|
+
|
|
82
|
+
authority = @uri.host.downcase
|
|
83
|
+
default_port = @uri.scheme == 'https' ? 443 : 80
|
|
84
|
+
authority = "#{authority}:#{@uri.port}" if @uri.port && @uri.port != default_port
|
|
85
|
+
authority
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def resolve_scheme
|
|
89
|
+
raise ParseError, '@scheme requires a request URL' unless @uri
|
|
90
|
+
|
|
91
|
+
@uri.scheme.downcase
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def resolve_request_target
|
|
95
|
+
raise ParseError, '@request-target requires a request URL' unless @uri
|
|
96
|
+
|
|
97
|
+
target = @uri.path.empty? ? '/' : @uri.path
|
|
98
|
+
target = "#{target}?#{@uri.query}" if @uri.query
|
|
99
|
+
target
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def resolve_path
|
|
103
|
+
raise ParseError, '@path requires a request URL' unless @uri
|
|
104
|
+
|
|
105
|
+
@uri.path.empty? ? '/' : @uri.path
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def resolve_query
|
|
109
|
+
raise ParseError, '@query requires a request URL' unless @uri
|
|
110
|
+
|
|
111
|
+
"?#{@uri.query || ''}"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def resolve_query_param params
|
|
115
|
+
raise ParseError, '@query-param requires a request URL' unless @uri
|
|
116
|
+
raise ParseError, '@query-param requires a name parameter' unless params['name']
|
|
117
|
+
|
|
118
|
+
target_name = params['name']
|
|
119
|
+
query_string = @uri.query || ''
|
|
120
|
+
|
|
121
|
+
# Parse query params per application/x-www-form-urlencoded
|
|
122
|
+
pairs = URI.decode_www_form query_string
|
|
123
|
+
|
|
124
|
+
match = pairs.find { |name, _value| name == target_name }
|
|
125
|
+
raise ParseError, "Query parameter not found: #{target_name}" unless match
|
|
126
|
+
|
|
127
|
+
# Re-encode the value per the RFC
|
|
128
|
+
URI.encode_www_form_component match[1]
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def resolve_status
|
|
132
|
+
raise ParseError, '@status requires a response message' unless @status
|
|
133
|
+
|
|
134
|
+
@status.to_s
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def resolve_field name
|
|
138
|
+
field_name = name.downcase
|
|
139
|
+
value = @headers[field_name]
|
|
140
|
+
|
|
141
|
+
raise ParseError, "Header field not found: #{name}" unless value
|
|
142
|
+
|
|
143
|
+
# Canonicalize: strip leading/trailing whitespace, collapse internal whitespace
|
|
144
|
+
value.strip.gsub(/\s+/, ' ')
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|