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,61 @@
1
+ module HTTPMessageSignatures
2
+ module RFC9421
3
+ # Constructs the signature base string per RFC 9421 Section 2.5.
4
+ #
5
+ # The signature base is an ASCII string containing the canonicalized HTTP
6
+ # message components covered by the signature, ending with the serialized
7
+ # @signature-params line.
8
+ #
9
+ # @example
10
+ # base = SignatureBase.create(
11
+ # signature_params: params,
12
+ # method: "POST",
13
+ # url: "https://example.com/foo",
14
+ # headers: { "content-type" => "application/json" }
15
+ # )
16
+ class SignatureBase
17
+ # Build the signature base string.
18
+ #
19
+ # @param signature_params [SignatureParams] The signature parameters
20
+ # @param headers [Hash{String => String}] HTTP headers
21
+ # @param method [String, nil] HTTP method
22
+ # @param status [Integer, nil] HTTP response status code
23
+ # @param url [String, URI, nil] Full request URL
24
+ # @return [String] The signature base string
25
+ # @raise [ParseError] If any component cannot be resolved
26
+ def self.create signature_params:, headers: {}, method: nil, status: nil, url: nil
27
+ resolver = ComponentResolver.new headers: headers, method: method, status: status, url: url
28
+
29
+ seen = {}
30
+ lines = []
31
+
32
+ signature_params.components.each do |component|
33
+ identifier = serialize_identifier component
34
+
35
+ raise ParseError, "Duplicate component identifier: #{identifier}" if seen[identifier]
36
+
37
+ seen[identifier] = true
38
+
39
+ value = resolver.resolve component
40
+ lines << "#{identifier}: #{value}"
41
+ end
42
+
43
+ lines << "\"@signature-params\": #{signature_params}"
44
+ lines.join "\n"
45
+ end
46
+
47
+ class << self
48
+ private
49
+
50
+ def serialize_identifier component
51
+ case component
52
+ when ComponentIdentifier
53
+ component.to_s
54
+ else
55
+ "\"#{component}\""
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,117 @@
1
+ require 'starry'
2
+
3
+ module HTTPMessageSignatures
4
+ module RFC9421
5
+ # Represents RFC 9421 signature parameters: the covered components
6
+ # and metadata (created, expires, nonce, alg, keyid, tag).
7
+ #
8
+ # The serialized form is a Structured Fields Inner List with parameters,
9
+ # as defined in RFC 9421 Section 2.3.
10
+ #
11
+ # @example
12
+ # params = SignatureParams.new(
13
+ # components: ["@method", "@authority", "@path"],
14
+ # created: 1618884473,
15
+ # keyid: "test-key-rsa-pss"
16
+ # )
17
+ # params.to_s
18
+ # # => '("@method" "@authority" "@path");created=1618884473;keyid="test-key-rsa-pss"'
19
+ class SignatureParams
20
+ METADATA_KEYS = %i[created expires nonce alg keyid tag].freeze
21
+
22
+ attr_reader :components
23
+ attr_accessor :created, :expires, :nonce, :alg, :keyid, :tag
24
+
25
+ def initialize components:, **metadata
26
+ @components = components
27
+ METADATA_KEYS.each { |key| instance_variable_set :"@#{key}", metadata[key] }
28
+ end
29
+
30
+ # Parse a Signature-Input member value (an Inner List with parameters).
31
+ #
32
+ # @param input [String] e.g. '("@method" "@authority");created=1618884473;keyid="test-key"'
33
+ # @return [SignatureParams]
34
+ # @raise [ParseError]
35
+ def self.parse input
36
+ list = Starry.parse_list input
37
+ inner_list = list.first
38
+
39
+ raise ParseError, "Expected inner list, got #{inner_list.class}" unless inner_list.is_a? Starry::InnerList
40
+
41
+ from_inner_list inner_list
42
+ rescue Starry::ParseError => e
43
+ raise ParseError, e.message
44
+ end
45
+
46
+ # Parse a full Signature-Input header (Dictionary of labeled inner lists).
47
+ #
48
+ # @param input [String] e.g. 'sig1=("@method");created=123, sig2=("@authority");created=456'
49
+ # @return [Hash{String => SignatureParams}]
50
+ # @raise [ParseError]
51
+ def self.parse_dictionary input
52
+ dict = Starry.parse_dictionary input
53
+ dict.transform_values { |inner_list| from_inner_list inner_list }
54
+ rescue Starry::ParseError => e
55
+ raise ParseError, e.message
56
+ end
57
+
58
+ # Serialize to the Signature-Input / @signature-params format.
59
+ #
60
+ # @return [String]
61
+ def to_s
62
+ to_inner_list.to_s
63
+ end
64
+
65
+ # Convert to a Starry::InnerList for use in dictionary serialization.
66
+ #
67
+ # @return [Starry::InnerList]
68
+ def to_inner_list
69
+ items = @components.map do |c|
70
+ case c
71
+ when ComponentIdentifier
72
+ Starry::Item.new c.name, c.params
73
+ else
74
+ c.to_s
75
+ end
76
+ end
77
+
78
+ params = {}
79
+ METADATA_KEYS.each do |key|
80
+ value = instance_variable_get :"@#{key}"
81
+ params[key.to_s] = value if value
82
+ end
83
+
84
+ Starry::InnerList.new items, params
85
+ end
86
+
87
+ class << self
88
+ private
89
+
90
+ def from_inner_list inner_list
91
+ raise ParseError, "Expected inner list, got #{inner_list.class}" unless inner_list.is_a? Starry::InnerList
92
+
93
+ components = inner_list.map { |item| item_to_component item }
94
+ metadata = extract_metadata inner_list.parameters
95
+
96
+ new(components: components, **metadata)
97
+ end
98
+
99
+ def item_to_component item
100
+ return item.to_s unless item.is_a? Starry::Item
101
+ return item.value.to_s if item.parameters.empty?
102
+
103
+ ComponentIdentifier.new item.value.to_s, item.parameters
104
+ end
105
+
106
+ def extract_metadata parameters
107
+ metadata = {}
108
+ parameters.each do |key, value|
109
+ sym = key.to_sym
110
+ metadata[sym] = value if METADATA_KEYS.include? sym
111
+ end
112
+ metadata
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,104 @@
1
+ require 'base64'
2
+
3
+ module HTTPMessageSignatures
4
+ module RFC9421
5
+ # Signs HTTP messages per RFC 9421.
6
+ #
7
+ # @example
8
+ # signer = Signer.new(
9
+ # key: private_key,
10
+ # key_id: "my-key",
11
+ # algorithm: "ed25519",
12
+ # covered_components: %w[@method @authority @path content-type]
13
+ # )
14
+ # headers = signer.sign(
15
+ # method: "POST",
16
+ # url: "https://example.com/inbox",
17
+ # headers: { "Content-Type" => "application/json" }
18
+ # )
19
+ class Signer
20
+ # @param algorithm [String] Algorithm name (e.g. "ed25519", "rsa-pss-sha512")
21
+ # @param covered_components [Array<String, ComponentIdentifier>] Components to sign
22
+ # @param key [OpenSSL::PKey, String] Private key or shared secret
23
+ # @param key_id [String] Key identifier for the keyid parameter
24
+ # @param expires_in [Integer, nil] Seconds until expiration
25
+ # @param include_alg [Boolean] Include alg parameter (default: true)
26
+ # @param label [String] Signature label (default: "sig1")
27
+ # @param nonce [String, nil] Random nonce value
28
+ # @param tag [String, nil] Application-specific tag
29
+ def initialize algorithm:, covered_components:, key:, key_id:,
30
+ expires_in: nil, include_alg: true, label: 'sig1', nonce: nil, tag: nil
31
+ @algorithm = Algorithms.resolve algorithm
32
+ @alg_name = algorithm
33
+ @components = covered_components
34
+ @key = key
35
+ @key_id = key_id
36
+
37
+ @expires_in = expires_in
38
+ @include_alg = include_alg
39
+ @label = label
40
+ @nonce = nonce
41
+ @tag = tag
42
+ end
43
+
44
+ # Sign an HTTP request.
45
+ #
46
+ # @return [Hash{String => String}] Headers with Signature-Input and Signature added
47
+ def sign method:, url:, headers: {}
48
+ sign_message headers: headers, method: method, url: url
49
+ end
50
+
51
+ # Sign an HTTP response.
52
+ #
53
+ # @return [Hash{String => String}] Headers with Signature-Input and Signature added
54
+ def sign_response status:, headers: {}
55
+ sign_message headers: headers, status: status
56
+ end
57
+
58
+ private
59
+
60
+ def sign_message headers: {}, method: nil, status: nil, url: nil
61
+ params = build_params
62
+ base = build_base params, headers: headers, method: method, status: status, url: url
63
+
64
+ signature_bytes = @algorithm.sign @key, base
65
+ attach_headers headers, params, signature_bytes
66
+ end
67
+
68
+ def build_params
69
+ metadata = { created: Time.now.to_i, keyid: @key_id }
70
+ metadata[:alg] = @alg_name if @include_alg
71
+ metadata[:expires] = metadata[:created] + @expires_in if @expires_in
72
+ metadata[:nonce] = @nonce if @nonce
73
+ metadata[:tag] = @tag if @tag
74
+
75
+ SignatureParams.new components: @components, **metadata
76
+ end
77
+
78
+ def build_base params, headers: {}, method: nil, status: nil, url: nil
79
+ SignatureBase.create(
80
+ signature_params: params,
81
+ headers: headers,
82
+ method: method,
83
+ status: status,
84
+ url: url
85
+ )
86
+ end
87
+
88
+ def attach_headers headers, params, signature_bytes
89
+ encoded = Base64.strict_encode64 signature_bytes
90
+ new_input = "#{@label}=#{params}"
91
+ new_sig = "#{@label}=:#{encoded}:"
92
+
93
+ result = headers.dup
94
+ result['Signature-Input'] = append_dictionary_member result['Signature-Input'], new_input
95
+ result['Signature'] = append_dictionary_member result['Signature'], new_sig
96
+ result
97
+ end
98
+
99
+ def append_dictionary_member existing, new_member
100
+ existing ? "#{existing}, #{new_member}" : new_member
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,115 @@
1
+ require 'base64'
2
+ require 'starry'
3
+
4
+ module HTTPMessageSignatures
5
+ module RFC9421
6
+ # Verifies HTTP message signatures per RFC 9421.
7
+ #
8
+ # @example
9
+ # verifier = Verifier.new
10
+ # result = verifier.verify(
11
+ # method: "POST",
12
+ # url: "https://example.com/inbox",
13
+ # headers: headers
14
+ # ) { |key_id, algorithm| fetch_key(key_id) }
15
+ class Verifier
16
+ # Verification result with structured data.
17
+ Result = Struct.new :verified, :label, :key_id, :algorithm, :components, keyword_init: true
18
+
19
+ # Verify a signed HTTP request.
20
+ #
21
+ # @yield [key_id, algorithm] Block to resolve the public key or shared secret
22
+ # @return [Result]
23
+ # @raise [ParseError] If headers are missing or malformed
24
+ def verify headers:, method:, url:, label: nil, &key_resolver
25
+ verify_message headers: headers, method: method, url: url, label: label, &key_resolver
26
+ end
27
+
28
+ # Verify a signed HTTP response.
29
+ #
30
+ # @yield [key_id, algorithm] Block to resolve the public key
31
+ # @return [Result]
32
+ def verify_response headers:, status:, label: nil, &key_resolver
33
+ verify_message headers: headers, status: status, label: label, &key_resolver
34
+ end
35
+
36
+ # List all signature labels present in the headers.
37
+ #
38
+ # @param headers [Hash{String => String}] HTTP headers
39
+ # @return [Array<String>] Signature labels
40
+ def labels headers
41
+ normalized = headers.transform_keys(&:downcase)
42
+ sig_input_raw = normalized['signature-input']
43
+ return [] unless sig_input_raw
44
+
45
+ SignatureParams.parse_dictionary(sig_input_raw).keys
46
+ end
47
+
48
+ # Verify all signatures on a request.
49
+ #
50
+ # @yield [key_id, algorithm] Block to resolve keys
51
+ # @return [Array<Result>]
52
+ def verify_all headers:, method:, url:, &key_resolver
53
+ labels(headers).map do |label|
54
+ verify headers: headers, method: method, url: url, label: label, &key_resolver
55
+ end
56
+ end
57
+
58
+ private
59
+
60
+ def verify_message headers: {}, label: nil, method: nil, status: nil, url: nil
61
+ params, signature_bytes, label = parse_signature_headers headers, label
62
+
63
+ key = yield params.keyid, params.alg
64
+ alg = Algorithms.resolve params.alg
65
+
66
+ base = SignatureBase.create(
67
+ signature_params: params,
68
+ headers: headers,
69
+ method: method,
70
+ status: status,
71
+ url: url
72
+ )
73
+
74
+ verified = alg.verify key, signature_bytes, base
75
+
76
+ Result.new verified: verified, label: label, key_id: params.keyid,
77
+ algorithm: params.alg, components: params.components
78
+ end
79
+
80
+ def parse_signature_headers headers, label
81
+ normalized = headers.transform_keys(&:downcase)
82
+
83
+ sig_input_raw = normalized['signature-input']
84
+ sig_raw = normalized['signature']
85
+ raise ParseError, 'Missing Signature-Input header' unless sig_input_raw
86
+ raise ParseError, 'Missing Signature header' unless sig_raw
87
+
88
+ sig_input_dict = SignatureParams.parse_dictionary sig_input_raw
89
+ sig_dict = Starry.parse_dictionary sig_raw
90
+
91
+ label ||= sig_input_dict.keys.first
92
+ raise ParseError, "No signature found with label: #{label}" unless sig_input_dict.key? label
93
+
94
+ params = sig_input_dict[label]
95
+ signature_bytes = extract_signature_bytes sig_dict, sig_raw, label
96
+
97
+ [params, signature_bytes, label]
98
+ end
99
+
100
+ def extract_signature_bytes sig_dict, sig_raw, label
101
+ sig_entry = sig_dict[label]
102
+ raise ParseError, "No signature value for label: #{label}" unless sig_entry
103
+
104
+ sig_value = sig_entry.is_a?(Starry::Item) ? sig_entry.value : sig_entry
105
+ return sig_value if sig_value.is_a?(String) && sig_value.encoding == Encoding::ASCII_8BIT
106
+
107
+ # Fallback: manually extract base64 from the raw header
108
+ pattern = %r{#{Regexp.escape(label)}=:([A-Za-z0-9+/=]+):}
109
+ raise ParseError, "Cannot extract signature bytes for label: #{label}" unless sig_raw =~ pattern
110
+
111
+ Base64.strict_decode64 ::Regexp.last_match(1)
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,3 @@
1
+ module HTTPMessageSignatures
2
+ VERSION = '0.1.0'.freeze
3
+ end
@@ -0,0 +1,34 @@
1
+ require 'starry'
2
+ require 'strscan'
3
+
4
+ require_relative 'http_message_signatures/version'
5
+ require_relative 'http_message_signatures/errors'
6
+ require_relative 'http_message_signatures/keys'
7
+ require_relative 'http_message_signatures/cavage/signature_header'
8
+ require_relative 'http_message_signatures/cavage/signing_string'
9
+ require_relative 'http_message_signatures/cavage/signer'
10
+ require_relative 'http_message_signatures/cavage/verifier'
11
+ require_relative 'http_message_signatures/rfc9421/component_identifier'
12
+ require_relative 'http_message_signatures/rfc9421/component_resolver'
13
+ require_relative 'http_message_signatures/rfc9421/signature_params'
14
+ require_relative 'http_message_signatures/rfc9421/signature_base'
15
+ require_relative 'http_message_signatures/rfc9421/algorithms'
16
+ require_relative 'http_message_signatures/rfc9421/signer'
17
+ require_relative 'http_message_signatures/rfc9421/verifier'
18
+ require_relative 'http_message_signatures/rack_middleware'
19
+ require_relative 'http_message_signatures/http_signer'
20
+
21
+ # HTTP Message Signatures for the Fediverse
22
+ #
23
+ # Sign and verify HTTP messages using draft-cavage (legacy Fediverse standard)
24
+ # and RFC 9421 (the final HTTP Message Signatures standard).
25
+ #
26
+ # @example Signing a request (draft-cavage)
27
+ # signer = HTTPMessageSignatures::CavageSigner.new(key_id: 'https://example.com/actor#main-key', private_key: key)
28
+ # headers = signer.sign(method: 'POST', url: 'https://remote.example/inbox', headers: headers, body: body)
29
+ #
30
+ # @example Verifying a request (draft-cavage)
31
+ # verifier = HTTPMessageSignatures::CavageVerifier.new
32
+ # result = verifier.verify(method: 'POST', url: '/inbox', headers: headers, body: body) { |key_id| fetch_key(key_id) }
33
+ module HTTPMessageSignatures
34
+ end
metadata ADDED
@@ -0,0 +1,108 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: http_message_signatures
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Shane Becker
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: base64
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.3'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.3'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rack
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '2.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '2.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: starry
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '0.2'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '0.2'
54
+ description: |
55
+ Sign and verify HTTP messages using draft-cavage (legacy Fediverse standard)
56
+ and RFC 9421 (the final HTTP Message Signatures standard).
57
+ Supports RSA-SHA256, RSA-PSS, HMAC-SHA256, ECDSA, and Ed25519.
58
+ email:
59
+ - veganstraightedge@gmail.com
60
+ executables: []
61
+ extensions: []
62
+ extra_rdoc_files: []
63
+ files:
64
+ - CHANGELOG.md
65
+ - LICENSE.txt
66
+ - README.md
67
+ - lib/http_message_signatures.rb
68
+ - lib/http_message_signatures/cavage/signature_header.rb
69
+ - lib/http_message_signatures/cavage/signer.rb
70
+ - lib/http_message_signatures/cavage/signing_string.rb
71
+ - lib/http_message_signatures/cavage/verifier.rb
72
+ - lib/http_message_signatures/errors.rb
73
+ - lib/http_message_signatures/http_signer.rb
74
+ - lib/http_message_signatures/keys.rb
75
+ - lib/http_message_signatures/rack_middleware.rb
76
+ - lib/http_message_signatures/rfc9421/algorithms.rb
77
+ - lib/http_message_signatures/rfc9421/component_identifier.rb
78
+ - lib/http_message_signatures/rfc9421/component_resolver.rb
79
+ - lib/http_message_signatures/rfc9421/signature_base.rb
80
+ - lib/http_message_signatures/rfc9421/signature_params.rb
81
+ - lib/http_message_signatures/rfc9421/signer.rb
82
+ - lib/http_message_signatures/rfc9421/verifier.rb
83
+ - lib/http_message_signatures/version.rb
84
+ homepage: https://github.com/xoengineering/http_message_signatures
85
+ licenses:
86
+ - AGPL-3.0
87
+ metadata:
88
+ source_code_uri: https://github.com/xoengineering/http_message_signatures
89
+ changelog_uri: https://github.com/xoengineering/http_message_signatures/blob/main/CHANGELOG.md
90
+ rubygems_mfa_required: 'true'
91
+ rdoc_options: []
92
+ require_paths:
93
+ - lib
94
+ required_ruby_version: !ruby/object:Gem::Requirement
95
+ requirements:
96
+ - - ">="
97
+ - !ruby/object:Gem::Version
98
+ version: 4.0.0
99
+ required_rubygems_version: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: '0'
104
+ requirements: []
105
+ rubygems_version: 4.0.3
106
+ specification_version: 4
107
+ summary: HTTP Message Signatures for the Fediverse
108
+ test_files: []