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.
@@ -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