http_signature 1.2.0 → 1.3.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: e704af788a9d55b85db708db52faef4107d793d289c36a7b8839837c59590841
4
- data.tar.gz: 4941ab8b360a30f0e37d9e8de3377a1a83feaac39b995909947846e072e3b0a3
3
+ metadata.gz: ad920771f621bd3462777e0eb8f9bbdeea23073a2ef2f6a6b7f28bc539ff981c
4
+ data.tar.gz: 14ad2f375a92b2ee0a98d1d08b8eb4b8c53bf955d6a61074f84c8227de5606e8
5
5
  SHA512:
6
- metadata.gz: 312862f8f0a06de466c992cf422138b3676b23547f273ee3c0cd1ccc99111ad394f0a60ef6578c4701310dd4a423d8c4a70b1a1c66d11811b7dcac003823e5bc
7
- data.tar.gz: 14d1e2b66bfa36e23efd38803d83104648e7bcc18dddadb7ee442ac5abf62a2d5653ca53bf4934255cf6521af84849fa66c5dc2ebba76705e0a2743b85f14838
6
+ metadata.gz: 07d414681a84629b24484340d72913df7b0611fc8cb637f0e1e5f8d83a6d65e95fb6070ebfd60830ba7b504e4339f7b10c4ec16a1e37f1aa353bdeb00f88ee39
7
+ data.tar.gz: 5aad2434674f6a8b26e6d30084ea9f06cbc12c4b70c22be4071356431aa30a15924707ff213164800adfc5729cbad32fddbda324e00a6e15bc3259356c568745
@@ -9,13 +9,17 @@ on:
9
9
  jobs:
10
10
  test:
11
11
  runs-on: ubuntu-latest
12
+ strategy:
13
+ fail-fast: false
14
+ matrix:
15
+ ruby-version: ['3.4', '4.0']
12
16
  steps:
13
17
  - uses: actions/checkout@v4
14
18
 
15
19
  - name: Set up Ruby
16
20
  uses: ruby/setup-ruby@v1
17
21
  with:
18
- ruby-version: '3.4'
22
+ ruby-version: ${{ matrix.ruby-version }}
19
23
  bundler-cache: true
20
24
 
21
25
  - name: Run tests with coverage
@@ -26,7 +30,7 @@ jobs:
26
30
  - name: Upload coverage artifact
27
31
  uses: actions/upload-artifact@v4
28
32
  with:
29
- name: coverage-html
33
+ name: coverage-html-${{ matrix.ruby-version }}
30
34
  path: coverage/
31
35
  if-no-files-found: warn
32
36
 
data/AGENTS.md CHANGED
@@ -6,3 +6,15 @@ This is a Ruby gem implementing the [HTTP Message Signatures RFC 9421 standard](
6
6
  - Run all tests: `bundle exec rake test`
7
7
  - Run single test file: `bundle exec rake test TEST=test/http_signature_test.rb`
8
8
  - Run single test: `bundle exec rake test TEST=test/http_signature_test.rb TESTOPTS="--name=/test_rsa_pss_sha512/"`
9
+
10
+ ## Linting
11
+ - Run standardrb: `bundle exec standardrb`
12
+ - Fix issues automatically: `bundle exec standardrb --fix`
13
+
14
+ Always run linters before commit
15
+
16
+ ## Bump gem version
17
+
18
+ - Update version number in lib/http_signature/version.rb
19
+ - Run `bundle install`
20
+ - Commit with message "Version x.x.x" and push
data/Gemfile CHANGED
@@ -1,5 +1,3 @@
1
1
  source "https://rubygems.org"
2
2
 
3
- ruby "3.4.8"
4
-
5
3
  gemspec
data/Gemfile.lock CHANGED
@@ -1,8 +1,9 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- http_signature (1.2.0)
4
+ http_signature (1.3.0)
5
5
  base64
6
+ starry (~> 0.2)
6
7
 
7
8
  GEM
8
9
  remote: https://rubygems.org/
@@ -129,6 +130,8 @@ GEM
129
130
  standard-performance (1.9.0)
130
131
  lint_roller (~> 1.1)
131
132
  rubocop-performance (~> 1.26.0)
133
+ starry (0.2.0)
134
+ base64
132
135
  tzinfo (2.0.6)
133
136
  concurrent-ruby (~> 1.0)
134
137
  unicode-display_width (3.2.0)
@@ -152,8 +155,5 @@ DEPENDENCIES
152
155
  simplecov
153
156
  standard
154
157
 
155
- RUBY VERSION
156
- ruby 3.4.8
157
-
158
158
  BUNDLED WITH
159
159
  4.0.2
data/README.md CHANGED
@@ -53,12 +53,28 @@ HTTPSignature.create(
53
53
  created: Time.now.to_i, # Default: Time.now.to_i
54
54
  expires: Time.now.to_i + 600, # Default: nil
55
55
  nonce: "1", # Default: nil
56
- label: "sig1", # Default: "sig1",
57
- query_string_params: {pet2: "cat"} # Default: {}, you can pass query string params both here and in the `url` param
58
- algorithm: "hmac-sha512" # Default: "hmac-sha256"
56
+ label: "sig1", # Default: "sig1"
57
+ query_string_params: {pet2: "cat"}, # Default: {}, you can pass query string params both here and in the `url` param
58
+ algorithm: "hmac-sha512", # Default: "hmac-sha256"
59
+ include_alg: true, # Default: true, set to false to omit the alg parameter from Signature-Input
60
+ status: 200, # Default: nil, required when signing responses with @status component
61
+ attached_request: { headers: req_headers } # Default: nil, required when using ;req component parameter
59
62
  )
60
63
  ```
61
64
 
65
+ #### Supported algorithms
66
+
67
+ | Algorithm | Key type |
68
+ |-----------|----------|
69
+ | `hmac-sha256` (default) | Shared secret string |
70
+ | `hmac-sha512` | Shared secret string |
71
+ | `rsa-pss-sha256` | RSA key (`OpenSSL::PKey::RSA`) |
72
+ | `rsa-pss-sha512` | RSA key |
73
+ | `rsa-v1_5-sha256` | RSA key |
74
+ | `ecdsa-p256-sha256` | EC key (P-256 curve) |
75
+ | `ecdsa-p384-sha384` | EC key (P-384 curve) |
76
+ | `ed25519` | Ed25519 key |
77
+
62
78
  #### Supported components
63
79
 
64
80
  Derived components (prefixed with `@`) per [RFC 9421 Section 2.2](https://www.rfc-editor.org/rfc/rfc9421#section-2.2):
@@ -71,12 +87,64 @@ Derived components (prefixed with `@`) per [RFC 9421 Section 2.2](https://www.rf
71
87
  | `@scheme` | URI scheme (`http` or `https`) |
72
88
  | `@path` | Request path (`/foo`) |
73
89
  | `@query` | Query string (including `?`; `?bar=1`) |
90
+ | `@query-param` | Individual query parameter (requires `;name`, e.g., `@query-param;name="pet"`) |
91
+ | `@request-target` | Request target (path + query) |
74
92
  | `@status` | Response status code (responses only) |
75
93
 
76
94
  Any lowercase header name (e.g., `content-type`, `date`) can also be used as a component.
77
95
 
78
96
  Default components are: `@method @target-uri content-digest content-type`
79
97
 
98
+ #### Component parameters
99
+
100
+ Components can have parameters per [RFC 9421 Section 2.1](https://www.rfc-editor.org/rfc/rfc9421#section-2.1):
101
+
102
+ | Parameter | Description |
103
+ |-----------|-------------|
104
+ | `;sf` | Serialize the value as a structured field ([RFC 8941](https://www.rfc-editor.org/rfc/rfc8941)) |
105
+ | `;key="member"` | Extract a single dictionary member from a structured field |
106
+ | `;bs` | Use byte sequence serialization for the value |
107
+ | `;req` | Resolve the component from the attached request (see below) |
108
+ | `;name="param"` | Select a named query parameter (only with `@query-param`) |
109
+
110
+ Example using `;key` to sign only the `sha-256` member of `content-digest`:
111
+
112
+ ```ruby
113
+ components: ["@method", "@target-uri", 'content-digest;key="sha-256"']
114
+ ```
115
+
116
+ #### Signing responses bound to a request
117
+
118
+ When signing an HTTP response, you can bind it to the original request using the `;req` component parameter ([RFC 9421 Section 2.4](https://www.rfc-editor.org/rfc/rfc9421#section-2.4)). This cryptographically ties the response signature to values from the request, proving the response was generated for that specific request.
119
+
120
+ Pass the original request headers via `attached_request`:
121
+
122
+ ```ruby
123
+ sig_headers = HTTPSignature.create(
124
+ url: "https://example.com/api/data",
125
+ method: "POST",
126
+ headers: response_headers,
127
+ components: ["@status", "content-type", "content-type;req", "@method;req"],
128
+ status: 200,
129
+ key: "secret",
130
+ key_id: "key-1",
131
+ attached_request: { headers: { "content-type" => "application/json" } }
132
+ )
133
+ ```
134
+
135
+ Components with `;req` are resolved from the attached request's headers instead of the response headers. For verification, pass the same `attached_request`:
136
+
137
+ ```ruby
138
+ HTTPSignature.valid?(
139
+ url: "https://example.com/api/data",
140
+ method: "POST",
141
+ headers: response_headers,
142
+ key: "secret",
143
+ status: 200,
144
+ attached_request: { headers: original_request_headers }
145
+ )
146
+ ```
147
+
80
148
  ### Validate signature
81
149
 
82
150
  Call `valid?` with the incoming request headers (including `Signature-Input` and `Signature`)
@@ -93,6 +161,50 @@ HTTPSignature.valid?(
93
161
  # Raises `SignatureError` for invalid signatures
94
162
  ```
95
163
 
164
+ #### All verification options
165
+
166
+ ```ruby
167
+ HTTPSignature.valid?(
168
+ url: "https://example.com/foo",
169
+ method: :get,
170
+ headers: headers,
171
+ body: request_body, # Default: ""
172
+ key: "secret", # Default: nil, uses key_resolver or configured keys if nil
173
+ key_resolver: ->(key_id) { find_key(key_id) }, # Default: nil, called with the key_id from Signature-Input
174
+ label: "sig1", # Default: "sig1"
175
+ query_string_params: {}, # Default: {}
176
+ max_age: 300, # Default: nil, reject signatures older than N seconds
177
+ algorithm: "hmac-sha256", # Default: nil, uses alg from Signature-Input or hmac-sha256
178
+ status: 200, # Default: nil, required when verifying responses with @status
179
+ require_content_digest: true, # Default: false, raise if body present but content-digest not signed
180
+ attached_request: { headers: req_headers } # Default: nil, required when ;req components were signed
181
+ )
182
+ ```
183
+
184
+ #### Dynamic key resolution
185
+
186
+ Use `key_resolver` when you need to look up keys dynamically based on the `key_id` from the incoming signature:
187
+
188
+ ```ruby
189
+ HTTPSignature.valid?(
190
+ url: "https://example.com/foo",
191
+ method: :get,
192
+ headers: headers,
193
+ key_resolver: ->(key_id) { KeyStore.find(key_id) }
194
+ )
195
+ ```
196
+
197
+ Alternatively, configure keys globally:
198
+
199
+ ```ruby
200
+ HTTPSignature.configure do |config|
201
+ config.keys = [
202
+ {id: "key-1", value: "secret1"},
203
+ {id: "key-2", value: "secret2"}
204
+ ]
205
+ end
206
+ ```
207
+
96
208
  #### Limiting signature age
97
209
 
98
210
  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.
@@ -109,6 +221,37 @@ HTTPSignature.valid?(
109
221
  # Raises `ExpiredError` if the signature was created more than 300 seconds ago
110
222
  ```
111
223
 
224
+ ### Content-Digest
225
+
226
+ When `content-digest` is included in the signed components and a body is present, the gem handles [RFC 9530](https://www.rfc-editor.org/rfc/rfc9530) Content-Digest automatically:
227
+
228
+ - **Signing** (`create`): If the request has a body and no `Content-Digest` header, the gem generates one using SHA-256 and adds it to the headers. If the header already exists, it verifies it matches the body.
229
+ - **Verification** (`valid?`): Verifies that the `Content-Digest` header matches the body. Raises `MissingComponent` if the header is absent, and `SignatureError` on mismatch.
230
+
231
+ To enforce that every request with a body must have `content-digest` in its signed components, use `require_content_digest: true` during verification.
232
+
233
+ ### Error handling
234
+
235
+ All errors inherit from `HTTPSignature::SignatureError`:
236
+
237
+ | Error | Raised when |
238
+ |-------|-------------|
239
+ | `SignatureError` | Signature is invalid, headers are missing, or Content-Digest mismatches |
240
+ | `MissingComponent` | A signed component is not present in the request |
241
+ | `UnsupportedComponent` | An unknown derived component is used (e.g., `@foo`) |
242
+ | `UnsupportedAlgorithm` | The signing algorithm is not supported |
243
+ | `ExpiredError` | The signature has expired (via `expires` or `max_age`) |
244
+
245
+ ```ruby
246
+ begin
247
+ HTTPSignature.valid?(url:, method:, headers:, key:)
248
+ rescue HTTPSignature::ExpiredError
249
+ # Signature too old
250
+ rescue HTTPSignature::SignatureError => e
251
+ # Any other signature problem
252
+ end
253
+ ```
254
+
112
255
  ## Outgoing request examples
113
256
 
114
257
  ### NET::HTTP
@@ -30,4 +30,5 @@ Gem::Specification.new do |spec|
30
30
  spec.add_development_dependency "actionpack", ">= 6.1"
31
31
 
32
32
  spec.add_dependency "base64"
33
+ spec.add_dependency "starry", "~> 0.2"
33
34
  end
@@ -52,12 +52,17 @@ class HTTPSignature::Rack
52
52
  def parse_request_headers(request)
53
53
  request_headers = {}
54
54
 
55
- request.each_header do |header|
56
- if header[0].include?("HTTP_") && header[0] != "HTTP_VERSION"
57
- request_headers[header[0].gsub("HTTP_", "").tr("_", "-").downcase] = header[1]
55
+ request.each_header do |key, value|
56
+ if key.start_with?("HTTP_") && key != "HTTP_VERSION"
57
+ request_headers[key.sub("HTTP_", "").tr("_", "-").downcase] = value
58
58
  end
59
59
  end
60
60
 
61
+ %w[CONTENT_TYPE CONTENT_LENGTH].each do |env_key|
62
+ value = request.get_header(env_key)
63
+ request_headers[env_key.downcase.tr("_", "-")] = value if value
64
+ end
65
+
61
66
  request_headers
62
67
  end
63
68
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module HTTPSignature
4
- VERSION = "1.2.0"
4
+ VERSION = "1.3.0"
5
5
  end
@@ -4,7 +4,7 @@ require "openssl"
4
4
  require "base64"
5
5
  require "uri"
6
6
  require "digest"
7
- require "rack/utils"
7
+ require "starry"
8
8
 
9
9
  # Implements HTTP Message Signatures per RFC 9421.
10
10
  module HTTPSignature
@@ -20,7 +20,7 @@ module HTTPSignature
20
20
  class UnsupportedAlgorithm < SignatureError; end
21
21
  class ExpiredError < SignatureError; end
22
22
 
23
- SUPPORTED_DERIVED_COMPONENTS = %w[@method @authority @target-uri @scheme @path @query @status].freeze
23
+ SUPPORTED_DERIVED_COMPONENTS = %w[@method @authority @target-uri @request-target @scheme @path @query @query-param @status].freeze
24
24
 
25
25
  Algorithm = Struct.new(:type, :digest_name, :curve)
26
26
  ALGORITHMS = {
@@ -87,7 +87,8 @@ module HTTPSignature
87
87
  label: DEFAULT_LABEL,
88
88
  query_string_params: {},
89
89
  include_alg: true,
90
- status: nil
90
+ status: nil,
91
+ attached_request: nil
91
92
  )
92
93
  unless created.is_a?(Integer)
93
94
  raise ArgumentError, "created must be a Unix timestamp integer"
@@ -114,12 +115,15 @@ module HTTPSignature
114
115
  normalized_headers
115
116
  end
116
117
 
118
+ normalized_attached = attached_request ? {headers: normalize_headers(attached_request[:headers])} : nil
119
+
117
120
  canonical_components = build_components(
118
121
  uri:,
119
122
  method:,
120
123
  headers: normalized_headers,
121
124
  components:,
122
- status:
125
+ status:,
126
+ attached_request: normalized_attached
123
127
  )
124
128
 
125
129
  signature_input_header, base_string = build_signature_input(
@@ -157,6 +161,8 @@ module HTTPSignature
157
161
  # @param algorithm [String, nil] Override algorithm for verification. If nil, uses alg from
158
162
  # signature params or defaults to hmac-sha256 (default: nil)
159
163
  # @param status [Integer, nil] HTTP status code, required when verifying responses with @status component
164
+ # @param require_content_digest [Boolean] When true, raises if a body is present but content-digest
165
+ # is not in the signed components. Useful for enforcing body integrity (default: false)
160
166
  # @return [Boolean] true when signature verification succeeds
161
167
  # @raise [ArgumentError] If max_age is not a non-negative integer
162
168
  # @raise [SignatureError] If signature headers are missing, key is missing, or signature is invalid
@@ -172,7 +178,9 @@ module HTTPSignature
172
178
  query_string_params: {},
173
179
  max_age: nil,
174
180
  algorithm: nil,
175
- status: nil
181
+ status: nil,
182
+ require_content_digest: false,
183
+ attached_request: nil
176
184
  )
177
185
  if max_age && (!max_age.is_a?(Integer) || max_age < 0)
178
186
  raise ArgumentError, "max_age must be a non-negative integer"
@@ -184,12 +192,13 @@ module HTTPSignature
184
192
  raise SignatureError, "Signature headers are required for verification" unless signature_input_header && signature_header
185
193
 
186
194
  parsed_input = parse_signature_input(signature_input_header, label)
195
+ validate_components!(parsed_input[:components])
187
196
  parsed_signature = parse_signature(signature_header, label)
188
197
 
189
198
  algorithm_entry = algorithm_entry_for(algorithm || parsed_input[:params][:alg] || DEFAULT_ALGORITHM)
190
199
  key_id = parsed_input[:params][:keyid]
191
- created = parsed_input[:params][:created].to_i
192
- signature_expires = parsed_input[:params][:expires]&.to_i
200
+ created = parse_signature_timestamp(parsed_input[:params][:created], :created)
201
+ signature_expires = parse_signature_timestamp(parsed_input[:params][:expires], :expires, required: false)
193
202
  effective_expires = max_age ? created + max_age : signature_expires
194
203
  now = Time.now.to_i
195
204
  if effective_expires && (created > effective_expires || now > effective_expires)
@@ -199,16 +208,26 @@ module HTTPSignature
199
208
  raise SignatureError, "Key is required for verification" unless resolved_key
200
209
 
201
210
  uri = apply_query_params(URI(url), query_string_params)
211
+ if require_content_digest && !body.to_s.empty? && !parsed_input[:components].include?("content-digest")
212
+ raise SignatureError, "content-digest component is required when body is present"
213
+ end
214
+
202
215
  if parsed_input[:components].include?("content-digest")
203
- normalized_headers = ensure_content_digest(normalized_headers, body)
216
+ unless normalized_headers["content-digest"]
217
+ raise MissingComponent, "Missing required component: content-digest"
218
+ end
219
+ verify_content_digest!(normalized_headers["content-digest"], body) unless body.to_s.empty?
204
220
  end
205
221
 
222
+ normalized_attached = attached_request ? {headers: normalize_headers(attached_request[:headers])} : nil
223
+
206
224
  canonical_components = build_components(
207
225
  uri:,
208
226
  method:,
209
227
  headers: normalized_headers,
210
228
  components: parsed_input[:components],
211
- status:
229
+ status:,
230
+ attached_request: normalized_attached
212
231
  )
213
232
 
214
233
  _, base_string = build_signature_input(
@@ -244,15 +263,50 @@ module HTTPSignature
244
263
  new_uri
245
264
  end
246
265
 
266
+ KNOWN_PARAMETERS = %w[sf key bs req tr name].freeze
267
+
247
268
  def self.validate_components!(components)
269
+ if components.include?("@signature-params")
270
+ raise UnsupportedComponent, "@signature-params cannot be included as a component"
271
+ end
272
+
273
+ seen = {}
248
274
  components.each do |component|
275
+ if seen[component]
276
+ raise UnsupportedComponent, "Duplicate component: #{component}"
277
+ end
278
+ seen[component] = true
279
+
280
+ params = parse_component_params(component)
281
+ has_sf = params["sf"] || params.key?("key")
282
+ has_bs = params["bs"]
283
+
284
+ if has_sf && has_bs
285
+ raise UnsupportedComponent, "Cannot combine ;sf/;key with ;bs: #{component}"
286
+ end
287
+
288
+ if params.key?("name") && !component.start_with?("@query-param")
289
+ raise UnsupportedComponent, ";name parameter only valid with @query-param: #{component}"
290
+ end
291
+
249
292
  next unless component.start_with?("@")
250
- next if SUPPORTED_DERIVED_COMPONENTS.include?(component)
293
+ base = component.split(";").first
294
+ next if SUPPORTED_DERIVED_COMPONENTS.include?(base)
251
295
 
252
296
  raise UnsupportedComponent, "Unsupported component: #{component}"
253
297
  end
254
298
  end
255
299
 
300
+ def self.parse_component_params(component)
301
+ _, raw_params = component.split(";", 2)
302
+ return {} unless raw_params
303
+
304
+ raw_params.split(";").each_with_object({}) do |param, hash|
305
+ key, value = param.split("=", 2)
306
+ hash[key] = value ? value.delete_prefix('"').delete_suffix('"') : true
307
+ end
308
+ end
309
+
256
310
  def self.default_components(headers, body: nil)
257
311
  components = DEFAULT_COMPONENTS.dup
258
312
  DEFAULT_HEADERS.each do |header|
@@ -271,27 +325,111 @@ module HTTPSignature
271
325
 
272
326
  def self.ensure_content_digest(headers, body)
273
327
  return headers if body.to_s.empty?
274
- return headers if headers["content-digest"]
328
+
329
+ if headers["content-digest"]
330
+ verify_content_digest!(headers["content-digest"], body)
331
+ return headers
332
+ end
275
333
 
276
334
  digest = Digest::SHA256.digest(body)
277
335
  headers.merge("content-digest" => "sha-256=:#{Base64.strict_encode64(digest)}:")
278
336
  end
279
337
 
280
- def self.build_components(uri:, method:, headers:, components:, status: nil)
338
+ def self.verify_content_digest!(header_value, body)
339
+ verified = false
340
+
341
+ header_value.scan(/([a-z0-9-]+)=:([A-Za-z0-9+\/=]+):/).each do |alg, encoded_digest|
342
+ digest = case alg
343
+ when "sha-256" then Digest::SHA256.digest(body)
344
+ when "sha-512" then Digest::SHA512.digest(body)
345
+ end
346
+ next unless digest
347
+
348
+ begin
349
+ decoded = Base64.strict_decode64(encoded_digest)
350
+ rescue ArgumentError
351
+ raise SignatureError, "Invalid Content-Digest encoding for #{alg}"
352
+ end
353
+ unless digest == decoded
354
+ raise SignatureError, "Content-Digest mismatch: body does not match #{alg} digest"
355
+ end
356
+ verified = true
357
+ end
358
+
359
+ raise SignatureError, "Content-Digest header contains no supported algorithm" unless verified
360
+ end
361
+
362
+ def self.build_components(uri:, method:, headers:, components:, status: nil, attached_request: nil)
281
363
  components.map do |component|
282
- if component.start_with?("@")
283
- [component, derived_component(component, uri, method, status:)]
364
+ params = parse_component_params(component)
365
+ base = component.split(";").first
366
+
367
+ if params["req"]
368
+ raise MissingComponent, ";req requires an attached request" unless attached_request
369
+ req_component = component.sub(/;req(?:=\S+)?/, "")
370
+ value = resolve_component_value(req_component, uri, method, attached_request[:headers], status:)
371
+ elsif base.start_with?("@")
372
+ value = derived_component(component, uri, method, status:)
284
373
  else
285
- value = headers[component]
286
- raise MissingComponent, "Missing required component: #{component}" unless value
374
+ raw = headers[base]
375
+ raise MissingComponent, "Missing required component: #{base}" unless raw
376
+ value = canonical_header_value(raw)
377
+ end
378
+
379
+ value = apply_structured_params(value, params)
287
380
 
288
- [component, canonical_header_value(value)]
381
+ [component, value]
382
+ end
383
+ end
384
+
385
+ def self.resolve_component_value(component, uri, method, headers, status: nil)
386
+ base = component.split(";").first
387
+ if base.start_with?("@")
388
+ derived_component(component, uri, method, status:)
389
+ else
390
+ raw = headers[base]
391
+ raise MissingComponent, "Missing required component: #{base}" unless raw
392
+ canonical_header_value(raw)
393
+ end
394
+ end
395
+
396
+ def self.apply_structured_params(value, params)
397
+ has_sf = params["sf"] || params.key?("key")
398
+ has_bs = params["bs"]
399
+
400
+ if has_sf
401
+ key = params["key"]
402
+ if key
403
+ dict = Starry.parse_dictionary(value)
404
+ obj = dict[key]
405
+ raise MissingComponent, "Dictionary member not found: #{key}" unless obj
406
+ Starry.serialize(obj.is_a?(Starry::InnerList) ? [obj] : obj)
407
+ else
408
+ Starry.serialize(parse_structured_field(value))
289
409
  end
410
+ elsif has_bs
411
+ Starry.serialize(value.encode(Encoding::ASCII_8BIT))
412
+ else
413
+ value
414
+ end
415
+ rescue Starry::ParseError
416
+ raise SignatureError, "Failed to parse structured field value"
417
+ end
418
+
419
+ def self.parse_structured_field(value)
420
+ Starry.parse_dictionary(value)
421
+ rescue Starry::ParseError
422
+ begin
423
+ Starry.parse_list(value)
424
+ rescue Starry::ParseError
425
+ Starry.parse_item(value)
290
426
  end
291
427
  end
292
428
 
293
429
  def self.derived_component(component, uri, method, status: nil)
294
- case component
430
+ base = component.split(";").first
431
+
432
+ case base
295
433
  when "@method" then method.to_s.upcase
296
434
  when "@authority"
297
435
  port = uri.port
@@ -299,9 +437,21 @@ module HTTPSignature
299
437
  (uri.port && port != default_port) ? "#{uri.host}:#{uri.port}" : uri.host
300
438
  when "@target-uri"
301
439
  uri.dup.tap { |u| u.fragment = nil }.to_s
440
+ when "@request-target"
441
+ path = uri.path.empty? ? "/" : uri.path
442
+ uri.query ? "#{path}?#{uri.query}" : path
302
443
  when "@scheme" then uri.scheme
303
- when "@path" then uri.path
304
- when "@query" then uri.query.to_s
444
+ when "@path"
445
+ path = uri.path
446
+ path.empty? ? "/" : path
447
+ when "@query" then "?#{uri.query}"
448
+ when "@query-param"
449
+ encoded_name = component.match(/;name="([^"]*)"/)&.[](1)
450
+ raise MissingComponent, "@query-param requires a name parameter" unless encoded_name
451
+ name = URI.decode_www_form_component(encoded_name)
452
+ pair = uri.query.to_s.split("&").map { |p| p.split("=", 2) }.find { |n, _| URI.decode_www_form_component(n) == name }
453
+ raise MissingComponent, "Query parameter not found: #{encoded_name}" unless pair
454
+ pair[1].to_s.tr("+", "%20")
305
455
  when "@status"
306
456
  raise MissingComponent, "@status requires a status code" unless status
307
457
  status.to_s
@@ -314,10 +464,24 @@ module HTTPSignature
314
464
  value.is_a?(Array) ? value.join(", ") : value.to_s
315
465
  end
316
466
 
467
+ def self.serialize_component_id(component)
468
+ base, params = component.split(";", 2)
469
+ serialized = %("#{escape_structured_string(base)}")
470
+ serialized << ";#{params}" if params
471
+ serialized
472
+ end
473
+
317
474
  def self.escape_structured_string(value)
318
475
  value.to_s.gsub("\\") { "\\\\" }.gsub('"') { '\"' }
319
476
  end
320
477
 
478
+ def self.unescape_structured_string(value)
479
+ return value unless value
480
+
481
+ value.delete_prefix('"').delete_suffix('"')
482
+ .gsub('\\"', '"').gsub("\\\\", "\\")
483
+ end
484
+
321
485
  def self.build_signature_input(
322
486
  label:,
323
487
  components:,
@@ -328,7 +492,7 @@ module HTTPSignature
328
492
  nonce:,
329
493
  canonical_components:
330
494
  )
331
- component_tokens = components.map { |c| %("#{escape_structured_string(c)}") }.join(" ")
495
+ component_tokens = components.map { |c| serialize_component_id(c) }.join(" ")
332
496
  params = ["created=#{created}"]
333
497
  params << "expires=#{expires}" unless expires.nil?
334
498
  params << %(keyid="#{escape_structured_string(key_id)}")
@@ -339,7 +503,7 @@ module HTTPSignature
339
503
  signature_input_header = "#{label}=#{signature_params}"
340
504
 
341
505
  base_lines = canonical_components.map do |name, value|
342
- %("#{name}": #{value})
506
+ "#{serialize_component_id(name)}: #{value}"
343
507
  end
344
508
  base_lines << %("@signature-params": #{signature_params})
345
509
 
@@ -361,6 +525,10 @@ module HTTPSignature
361
525
  end
362
526
 
363
527
  def self.sign(base_string, key:, algorithm:)
528
+ if algorithm.type == :hmac && asymmetric_key?(key)
529
+ raise SignatureError, "HMAC algorithm cannot be used with an asymmetric key"
530
+ end
531
+
364
532
  case algorithm.type
365
533
  when :hmac
366
534
  OpenSSL::HMAC.digest(algorithm.digest_name, key, base_string)
@@ -387,10 +555,15 @@ module HTTPSignature
387
555
  end
388
556
 
389
557
  def self.verify_signature(base_string, signature_bytes, key, algorithm)
558
+ if algorithm.type == :hmac && asymmetric_key?(key)
559
+ raise SignatureError, "HMAC algorithm cannot be used with an asymmetric key"
560
+ end
561
+
390
562
  case algorithm.type
391
563
  when :hmac
392
564
  expected = OpenSSL::HMAC.digest(algorithm.digest_name, key, base_string)
393
- ::Rack::Utils.secure_compare(expected, signature_bytes)
565
+ expected.bytesize == signature_bytes.bytesize &&
566
+ OpenSSL.fixed_length_secure_compare(expected, signature_bytes)
394
567
  when :rsa_pss
395
568
  pkey = rsa_key(key)
396
569
  # Use generic verify with RSA-PSS options (works with all key types)
@@ -418,12 +591,17 @@ module HTTPSignature
418
591
  raise SignatureError, "Signature-Input missing" unless entry
419
592
 
420
593
  components_match = entry.match(/\((.*?)\)/)
421
- components =
422
- components_match ? components_match[1].scan(/"([^"]+)"/).flatten : []
594
+ components = if components_match
595
+ components_match[1].scan(/"([^"]+)"((?:;[a-z]+(?:=(?:"[^"]*"|\S+))?)*)/).map do |base, params|
596
+ params.empty? ? base : "#{base}#{params}"
597
+ end
598
+ else
599
+ []
600
+ end
423
601
 
424
602
  params = entry.split(");").last&.split(";")&.map do |p|
425
603
  key, value = p.split("=", 2)
426
- [key.to_sym, value&.tr('"', "")]
604
+ [key.to_sym, unescape_structured_string(value)]
427
605
  end.to_h
428
606
 
429
607
  {components:, params:}
@@ -452,6 +630,19 @@ module HTTPSignature
452
630
  key(key_id)
453
631
  end
454
632
 
633
+ def self.parse_signature_timestamp(value, param_name, required: true)
634
+ if value.nil?
635
+ raise SignatureError, "Missing required #{param_name} parameter" if required
636
+ return nil
637
+ end
638
+
639
+ unless value.match?(/\A\d+\z/)
640
+ raise SignatureError, "Invalid #{param_name} parameter"
641
+ end
642
+
643
+ value.to_i
644
+ end
645
+
455
646
  def self.rsa_key(key)
456
647
  return key if key.is_a?(OpenSSL::PKey::RSA) || key.is_a?(OpenSSL::PKey::PKey)
457
648
 
@@ -468,6 +659,45 @@ module HTTPSignature
468
659
  OpenSSL::PKey.read(key)
469
660
  end
470
661
 
662
+ def self.asymmetric_key?(key)
663
+ return true if key.is_a?(OpenSSL::PKey::PKey)
664
+ return false unless key.is_a?(String)
665
+
666
+ pkey = nil
667
+
668
+ # Try parsing as an OpenSSL PKey
669
+ # Fast path for PEM encoded keys
670
+ if key.match?(/\A\s*-----BEGIN/)
671
+ begin
672
+ pkey = OpenSSL::PKey.read(key)
673
+ rescue ArgumentError, OpenSSL::PKey::PKeyError
674
+ return false
675
+ end
676
+ # For binary DER, only attempt parse if it looks like ASN.1 SEQUENCE (0x30)
677
+ # to avoid misclassifying raw HMAC secrets that start with 0x30 as asymmetric
678
+ elsif key.bytes[0] == 0x30
679
+ begin
680
+ pkey = OpenSSL::PKey.read(key)
681
+ rescue ArgumentError, OpenSSL::PKey::PKeyError
682
+ return false
683
+ end
684
+ end
685
+
686
+ return false unless pkey
687
+
688
+ # Only treat as asymmetric if parsed to a known key type with sane structure,
689
+ # so raw binary that parses as arbitrary DER is not misclassified as a key
690
+ case pkey
691
+ when OpenSSL::PKey::RSA
692
+ pkey.n&.num_bits.to_i >= 512
693
+ when OpenSSL::PKey::EC
694
+ true
695
+ else
696
+ # Ed25519 or other PKey subclass
697
+ true
698
+ end
699
+ end
700
+
471
701
  # Convert ECDSA DER signature to raw (r || s) format per RFC 9421
472
702
  def self.ecdsa_der_to_raw(der_signature, curve)
473
703
  byte_size = (curve == "prime256v1") ? 32 : 48
@@ -485,6 +715,10 @@ module HTTPSignature
485
715
  # Convert raw (r || s) signature to ECDSA DER format
486
716
  def self.ecdsa_raw_to_der(raw_signature, curve)
487
717
  byte_size = (curve == "prime256v1") ? 32 : 48
718
+ expected_size = byte_size * 2
719
+ unless raw_signature.is_a?(String) && raw_signature.bytesize == expected_size
720
+ raise SignatureError, "Invalid ECDSA signature length"
721
+ end
488
722
 
489
723
  r_bytes = raw_signature[0, byte_size]
490
724
  s_bytes = raw_signature[byte_size, byte_size]
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.2.0
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Joel Larsson
@@ -135,6 +135,20 @@ dependencies:
135
135
  - - ">="
136
136
  - !ruby/object:Gem::Version
137
137
  version: '0'
138
+ - !ruby/object:Gem::Dependency
139
+ name: starry
140
+ requirement: !ruby/object:Gem::Requirement
141
+ requirements:
142
+ - - "~>"
143
+ - !ruby/object:Gem::Version
144
+ version: '0.2'
145
+ type: :runtime
146
+ prerelease: false
147
+ version_requirements: !ruby/object:Gem::Requirement
148
+ requirements:
149
+ - - "~>"
150
+ - !ruby/object:Gem::Version
151
+ version: '0.2'
138
152
  description: 'Create and validate HTTP Message Signatures according to RFC 9421: https://www.rfc-editor.org/rfc/rfc9421.html'
139
153
  email:
140
154
  - bolmaster2@gmail.com