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 +4 -4
- data/Gemfile.lock +1 -1
- data/README.md +33 -0
- data/lib/http_signature/version.rb +1 -1
- data/lib/http_signature.rb +77 -13
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e704af788a9d55b85db708db52faef4107d793d289c36a7b8839837c59590841
|
|
4
|
+
data.tar.gz: 4941ab8b360a30f0e37d9e8de3377a1a83feaac39b995909947846e072e3b0a3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 312862f8f0a06de466c992cf422138b3676b23547f273ee3c0cd1ccc99111ad394f0a60ef6578c4701310dd4a423d8c4a70b1a1c66d11811b7dcac003823e5bc
|
|
7
|
+
data.tar.gz: 14d1e2b66bfa36e23efd38803d83104648e7bcc18dddadb7ee442ac5abf62a2d5653ca53bf4934255cf6521af84849fa66c5dc2ebba76705e0a2743b85f14838
|
data/Gemfile.lock
CHANGED
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
|
data/lib/http_signature.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
145
|
-
raise ExpiredError, "Signature expired at #{
|
|
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
|