http_signature 1.1.0 → 1.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4d7340510585b31400802574581fc40aa006889f7f43e67e6ac4d5c926ddc3ce
4
- data.tar.gz: 3364453874b93eb6b37a2ef13208dbb2792b4b50975c872459fea1a1d70a68d7
3
+ metadata.gz: e704af788a9d55b85db708db52faef4107d793d289c36a7b8839837c59590841
4
+ data.tar.gz: 4941ab8b360a30f0e37d9e8de3377a1a83feaac39b995909947846e072e3b0a3
5
5
  SHA512:
6
- metadata.gz: ba102a57a504be38d46ff962442d273dcee1866e5e49127927f638774dc30a66b92c063edb62670ba09b42b14bb2257772992183b606ef2abc7c1d1340677717
7
- data.tar.gz: b2ee1d08d85958e663760b999c45a8865e2db4f43920fbf9a77bae78479a5192e939d0cfdab26a6d47dd936c1bb17311b809ac6b8278f92d1c8ced43de957d9d
6
+ metadata.gz: 312862f8f0a06de466c992cf422138b3676b23547f273ee3c0cd1ccc99111ad394f0a60ef6578c4701310dd4a423d8c4a70b1a1c66d11811b7dcac003823e5bc
7
+ data.tar.gz: 14d1e2b66bfa36e23efd38803d83104648e7bcc18dddadb7ee442ac5abf62a2d5653ca53bf4934255cf6521af84849fa66c5dc2ebba76705e0a2743b85f14838
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- http_signature (1.1.0)
4
+ http_signature (1.2.0)
5
5
  base64
6
6
 
7
7
  GEM
data/README.md CHANGED
@@ -59,6 +59,23 @@ HTTPSignature.create(
59
59
  )
60
60
  ```
61
61
 
62
+ #### Supported components
63
+
64
+ Derived components (prefixed with `@`) per [RFC 9421 Section 2.2](https://www.rfc-editor.org/rfc/rfc9421#section-2.2):
65
+
66
+ | Component | Description |
67
+ |-----------|-------------|
68
+ | `@method` | HTTP method (e.g., `GET`, `POST`) |
69
+ | `@target-uri` | Full request URI (`https://example.com/foo?bar=1`) |
70
+ | `@authority` | Host (and port if non-default) |
71
+ | `@scheme` | URI scheme (`http` or `https`) |
72
+ | `@path` | Request path (`/foo`) |
73
+ | `@query` | Query string (including `?`; `?bar=1`) |
74
+ | `@status` | Response status code (responses only) |
75
+
76
+ Any lowercase header name (e.g., `content-type`, `date`) can also be used as a component.
77
+
78
+ Default components are: `@method @target-uri content-digest content-type`
62
79
 
63
80
  ### Validate signature
64
81
 
@@ -76,6 +93,22 @@ HTTPSignature.valid?(
76
93
  # Raises `SignatureError` for invalid signatures
77
94
  ```
78
95
 
96
+ #### Limiting signature age
97
+
98
+ Use `max_age` to reject signatures older than a specified number of seconds, regardless of the signature's `expires` parameter. This helps protect against replay attacks.
99
+
100
+ ```ruby
101
+ HTTPSignature.valid?(
102
+ url: "https://example.com/foo",
103
+ method: :get,
104
+ headers: headers,
105
+ key: "secret",
106
+ max_age: 300 # Reject signatures older than 5 minutes
107
+ )
108
+
109
+ # Raises `ExpiredError` if the signature was created more than 300 seconds ago
110
+ ```
111
+
79
112
  ## Outgoing request examples
80
113
 
81
114
  ### NET::HTTP
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module HTTPSignature
4
- VERSION = "1.1.0"
4
+ VERSION = "1.2.0"
5
5
  end
@@ -16,9 +16,12 @@ module HTTPSignature
16
16
 
17
17
  class SignatureError < StandardError; end
18
18
  class MissingComponent < SignatureError; end
19
+ class UnsupportedComponent < SignatureError; end
19
20
  class UnsupportedAlgorithm < SignatureError; end
20
21
  class ExpiredError < SignatureError; end
21
22
 
23
+ SUPPORTED_DERIVED_COMPONENTS = %w[@method @authority @target-uri @scheme @path @query @status].freeze
24
+
22
25
  Algorithm = Struct.new(:type, :digest_name, :curve)
23
26
  ALGORITHMS = {
24
27
  "hmac-sha256" => Algorithm.new(:hmac, "SHA256"),
@@ -48,7 +51,27 @@ module HTTPSignature
48
51
 
49
52
  # Create RFC 9421 Signature-Input and Signature headers
50
53
  #
54
+ # @param url [String] The full URL of the request being signed
55
+ # @param key [String, OpenSSL::PKey::PKey] The signing key (secret for HMAC, private key for asymmetric)
56
+ # @param key_id [String] Identifier for the key, included in the signature metadata
57
+ # @param method [Symbol] HTTP method (default: :get)
58
+ # @param headers [Hash] Request headers to potentially include in signature (default: {})
59
+ # @param body [String] Request body, used to generate content-digest if needed (default: "")
60
+ # @param algorithm [String] Signing algorithm (default: "hmac-sha256")
61
+ # @param components [Array<String>, nil] Components to sign. If nil, uses @method, @target-uri,
62
+ # and includes content-digest/content-type when present
63
+ # @param created [Integer] Unix timestamp for signature creation (default: Time.now.to_i)
64
+ # @param expires [Integer, nil] Unix timestamp when signature expires (default: nil)
65
+ # @param nonce [String, nil] Random value for signature uniqueness (default: nil)
66
+ # @param label [String] Signature label in headers (default: "sig1")
67
+ # @param query_string_params [Hash] Additional query params to merge into URL (default: {})
68
+ # @param include_alg [Boolean] Whether to include alg in signature metadata (default: true)
69
+ # @param status [Integer, nil] HTTP status code, required when signing responses with @status component
51
70
  # @return [Hash] { 'Signature-Input' => header, 'Signature' => header }
71
+ # @raise [ArgumentError] If created/expires are not integers or expires < created
72
+ # @raise [UnsupportedAlgorithm] If the algorithm is not supported
73
+ # @raise [UnsupportedComponent] If a derived component (e.g. @foo) is not supported
74
+ # @raise [MissingComponent] If a required component is missing
52
75
  def self.create(
53
76
  url:,
54
77
  key:,
@@ -62,7 +85,9 @@ module HTTPSignature
62
85
  expires: nil,
63
86
  nonce: nil,
64
87
  label: DEFAULT_LABEL,
65
- query_string_params: {}
88
+ query_string_params: {},
89
+ include_alg: true,
90
+ status: nil
66
91
  )
67
92
  unless created.is_a?(Integer)
68
93
  raise ArgumentError, "created must be a Unix timestamp integer"
@@ -80,6 +105,8 @@ module HTTPSignature
80
105
  components ||=
81
106
  default_components(normalized_headers, body:)
82
107
 
108
+ validate_components!(components)
109
+
83
110
  normalized_headers =
84
111
  if components.include?("content-digest")
85
112
  ensure_content_digest(normalized_headers, body)
@@ -91,7 +118,8 @@ module HTTPSignature
91
118
  uri:,
92
119
  method:,
93
120
  headers: normalized_headers,
94
- components:
121
+ components:,
122
+ status:
95
123
  )
96
124
 
97
125
  signature_input_header, base_string = build_signature_input(
@@ -100,7 +128,7 @@ module HTTPSignature
100
128
  created:,
101
129
  expires:,
102
130
  key_id:,
103
- alg: algorithm,
131
+ alg: include_alg ? algorithm : nil,
104
132
  nonce:,
105
133
  canonical_components:
106
134
  )
@@ -116,7 +144,23 @@ module HTTPSignature
116
144
 
117
145
  # Verify RFC 9421 Signature headers
118
146
  #
147
+ # @param url [String] The full URL of the request being verified
148
+ # @param method [Symbol] HTTP method of the request
149
+ # @param headers [Hash] Request headers, must include Signature-Input and Signature headers
150
+ # @param body [String] Request body (default: "")
151
+ # @param key [String, OpenSSL::PKey::PKey, nil] Verification key. If nil, uses key_resolver or configured keys
152
+ # @param key_resolver [Proc, nil] Callable that receives key_id and returns the key (default: nil)
153
+ # @param label [String] Signature label to verify (default: "sig1")
154
+ # @param query_string_params [Hash] Additional query params to merge into URL (default: {})
155
+ # @param max_age [Integer, nil] Maximum signature age in seconds. Takes precedence over
156
+ # the expires timestamp in the signature (default: nil)
157
+ # @param algorithm [String, nil] Override algorithm for verification. If nil, uses alg from
158
+ # signature params or defaults to hmac-sha256 (default: nil)
159
+ # @param status [Integer, nil] HTTP status code, required when verifying responses with @status component
119
160
  # @return [Boolean] true when signature verification succeeds
161
+ # @raise [ArgumentError] If max_age is not a non-negative integer
162
+ # @raise [SignatureError] If signature headers are missing, key is missing, or signature is invalid
163
+ # @raise [ExpiredError] If the signature has expired
120
164
  def self.valid?(
121
165
  url:,
122
166
  method:,
@@ -125,8 +169,14 @@ module HTTPSignature
125
169
  key: nil,
126
170
  key_resolver: nil,
127
171
  label: DEFAULT_LABEL,
128
- query_string_params: {}
172
+ query_string_params: {},
173
+ max_age: nil,
174
+ algorithm: nil,
175
+ status: nil
129
176
  )
177
+ if max_age && (!max_age.is_a?(Integer) || max_age < 0)
178
+ raise ArgumentError, "max_age must be a non-negative integer"
179
+ end
130
180
  normalized_headers = normalize_headers(headers)
131
181
 
132
182
  signature_input_header = normalized_headers["signature-input"]
@@ -136,13 +186,14 @@ module HTTPSignature
136
186
  parsed_input = parse_signature_input(signature_input_header, label)
137
187
  parsed_signature = parse_signature(signature_header, label)
138
188
 
139
- algorithm_entry = algorithm_entry_for(parsed_input[:params][:alg] || DEFAULT_ALGORITHM)
189
+ algorithm_entry = algorithm_entry_for(algorithm || parsed_input[:params][:alg] || DEFAULT_ALGORITHM)
140
190
  key_id = parsed_input[:params][:keyid]
141
191
  created = parsed_input[:params][:created].to_i
142
- expires = parsed_input[:params][:expires]&.to_i
192
+ signature_expires = parsed_input[:params][:expires]&.to_i
193
+ effective_expires = max_age ? created + max_age : signature_expires
143
194
  now = Time.now.to_i
144
- if expires && (created > expires || now > expires)
145
- raise ExpiredError, "Signature expired at #{expires}"
195
+ if effective_expires && (created > effective_expires || now > effective_expires)
196
+ raise ExpiredError, "Signature expired at #{effective_expires}"
146
197
  end
147
198
  resolved_key = key || key_resolver&.call(key_id) || key_from_store(key_id)
148
199
  raise SignatureError, "Key is required for verification" unless resolved_key
@@ -156,14 +207,15 @@ module HTTPSignature
156
207
  uri:,
157
208
  method:,
158
209
  headers: normalized_headers,
159
- components: parsed_input[:components]
210
+ components: parsed_input[:components],
211
+ status:
160
212
  )
161
213
 
162
214
  _, base_string = build_signature_input(
163
215
  label:,
164
216
  components: parsed_input[:components],
165
217
  created:,
166
- expires:,
218
+ expires: signature_expires,
167
219
  key_id:,
168
220
  alg: parsed_input[:params][:alg],
169
221
  nonce: parsed_input[:params][:nonce],
@@ -192,6 +244,15 @@ module HTTPSignature
192
244
  new_uri
193
245
  end
194
246
 
247
+ def self.validate_components!(components)
248
+ components.each do |component|
249
+ next unless component.start_with?("@")
250
+ next if SUPPORTED_DERIVED_COMPONENTS.include?(component)
251
+
252
+ raise UnsupportedComponent, "Unsupported component: #{component}"
253
+ end
254
+ end
255
+
195
256
  def self.default_components(headers, body: nil)
196
257
  components = DEFAULT_COMPONENTS.dup
197
258
  DEFAULT_HEADERS.each do |header|
@@ -216,10 +277,10 @@ module HTTPSignature
216
277
  headers.merge("content-digest" => "sha-256=:#{Base64.strict_encode64(digest)}:")
217
278
  end
218
279
 
219
- def self.build_components(uri:, method:, headers:, components:)
280
+ def self.build_components(uri:, method:, headers:, components:, status: nil)
220
281
  components.map do |component|
221
282
  if component.start_with?("@")
222
- [component, derived_component(component, uri, method)]
283
+ [component, derived_component(component, uri, method, status:)]
223
284
  else
224
285
  value = headers[component]
225
286
  raise MissingComponent, "Missing required component: #{component}" unless value
@@ -229,7 +290,7 @@ module HTTPSignature
229
290
  end
230
291
  end
231
292
 
232
- def self.derived_component(component, uri, method)
293
+ def self.derived_component(component, uri, method, status: nil)
233
294
  case component
234
295
  when "@method" then method.to_s.upcase
235
296
  when "@authority"
@@ -241,6 +302,9 @@ module HTTPSignature
241
302
  when "@scheme" then uri.scheme
242
303
  when "@path" then uri.path
243
304
  when "@query" then uri.query.to_s
305
+ when "@status"
306
+ raise MissingComponent, "@status requires a status code" unless status
307
+ status.to_s
244
308
  else
245
309
  raise MissingComponent, "Unsupported derived component: #{component}"
246
310
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: http_signature
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.0
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Joel Larsson