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 +4 -4
- data/.github/workflows/ci.yml +6 -2
- data/AGENTS.md +12 -0
- data/Gemfile +0 -2
- data/Gemfile.lock +4 -4
- data/README.md +146 -3
- data/http_signature.gemspec +1 -0
- data/lib/http_signature/rack.rb +8 -3
- data/lib/http_signature/version.rb +1 -1
- data/lib/http_signature.rb +260 -26
- metadata +15 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ad920771f621bd3462777e0eb8f9bbdeea23073a2ef2f6a6b7f28bc539ff981c
|
|
4
|
+
data.tar.gz: 14ad2f375a92b2ee0a98d1d08b8eb4b8c53bf955d6a61074f84c8227de5606e8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 07d414681a84629b24484340d72913df7b0611fc8cb637f0e1e5f8d83a6d65e95fb6070ebfd60830ba7b504e4339f7b10c4ec16a1e37f1aa353bdeb00f88ee39
|
|
7
|
+
data.tar.gz: 5aad2434674f6a8b26e6d30084ea9f06cbc12c4b70c22be4071356431aa30a15924707ff213164800adfc5729cbad32fddbda324e00a6e15bc3259356c568745
|
data/.github/workflows/ci.yml
CHANGED
|
@@ -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:
|
|
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
data/Gemfile.lock
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
http_signature (1.
|
|
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
|
data/http_signature.gemspec
CHANGED
data/lib/http_signature/rack.rb
CHANGED
|
@@ -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 |
|
|
56
|
-
if
|
|
57
|
-
request_headers[
|
|
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
|
|
data/lib/http_signature.rb
CHANGED
|
@@ -4,7 +4,7 @@ require "openssl"
|
|
|
4
4
|
require "base64"
|
|
5
5
|
require "uri"
|
|
6
6
|
require "digest"
|
|
7
|
-
require "
|
|
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]
|
|
192
|
-
signature_expires = parsed_input[:params][:expires]
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
283
|
-
|
|
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
|
-
|
|
286
|
-
raise MissingComponent, "Missing required component: #{
|
|
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
|
-
|
|
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
|
-
|
|
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"
|
|
304
|
-
|
|
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|
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|