http_message_signatures 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +14 -0
- data/LICENSE.txt +661 -0
- data/README.md +288 -0
- data/lib/http_message_signatures/cavage/signature_header.rb +112 -0
- data/lib/http_message_signatures/cavage/signer.rb +77 -0
- data/lib/http_message_signatures/cavage/signing_string.rb +56 -0
- data/lib/http_message_signatures/cavage/verifier.rb +127 -0
- data/lib/http_message_signatures/errors.rb +16 -0
- data/lib/http_message_signatures/http_signer.rb +108 -0
- data/lib/http_message_signatures/keys.rb +58 -0
- data/lib/http_message_signatures/rack_middleware.rb +141 -0
- data/lib/http_message_signatures/rfc9421/algorithms.rb +155 -0
- data/lib/http_message_signatures/rfc9421/component_identifier.rb +61 -0
- data/lib/http_message_signatures/rfc9421/component_resolver.rb +148 -0
- data/lib/http_message_signatures/rfc9421/signature_base.rb +61 -0
- data/lib/http_message_signatures/rfc9421/signature_params.rb +117 -0
- data/lib/http_message_signatures/rfc9421/signer.rb +104 -0
- data/lib/http_message_signatures/rfc9421/verifier.rb +115 -0
- data/lib/http_message_signatures/version.rb +3 -0
- data/lib/http_message_signatures.rb +34 -0
- metadata +108 -0
data/README.md
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
# HTTP Message Signatures
|
|
2
|
+
|
|
3
|
+
Sign and verify HTTP messages for the Fediverse.
|
|
4
|
+
|
|
5
|
+
Supports [draft-cavage-http-signatures](https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures)
|
|
6
|
+
(the legacy standard used by Mastodon, Pleroma, PeerTube, etc.)
|
|
7
|
+
and [RFC 9421](https://www.rfc-editor.org/rfc/rfc9421.html)
|
|
8
|
+
(the final HTTP Message Signatures standard).
|
|
9
|
+
|
|
10
|
+
## Features
|
|
11
|
+
|
|
12
|
+
- Framework agnostic — Works with any Ruby framework or plain scripts
|
|
13
|
+
- Draft-Cavage — Sign and verify using the legacy Fediverse standard
|
|
14
|
+
- RFC 9421 — Sign and verify using the final standard
|
|
15
|
+
- Key helpers — Generate and parse RSA and Ed25519 keys
|
|
16
|
+
- Rack middleware — Automatic signature verification for incoming requests
|
|
17
|
+
- HTTP.rb client — Automatic signature signing for outgoing requests
|
|
18
|
+
- Algorithms — RSA-PSS-SHA512, RSA-V1.5-SHA256, HMAC-SHA256, ECDSA-P256, ECDSA-P384, Ed25519
|
|
19
|
+
- Interop — Workarounds for Pleroma, PeerTube, etc based on Mastodon's prior art
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
Add this line to your application's Gemfile:
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
gem 'http_message_signatures'
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
And then execute:
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
bundle install
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Or install it yourself as:
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
gem install http_message_signatures
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Usage
|
|
42
|
+
|
|
43
|
+
### Key Management
|
|
44
|
+
|
|
45
|
+
Generate keys:
|
|
46
|
+
|
|
47
|
+
```ruby
|
|
48
|
+
# Generate an RSA key pair (default 2048-bit)
|
|
49
|
+
rsa_key = HTTPMessageSignatures::Keys.generate_rsa
|
|
50
|
+
|
|
51
|
+
# Generate an Ed25519 key pair
|
|
52
|
+
ed_key = HTTPMessageSignatures::Keys.generate_ed25519
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Parse PEM keys:
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
private_key = HTTPMessageSignatures::Keys.parse_private_key File.read('private.pem')
|
|
59
|
+
public_key = HTTPMessageSignatures::Keys.parse_public_key File.read('public.pem')
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Export to PEM:
|
|
63
|
+
|
|
64
|
+
```ruby
|
|
65
|
+
HTTPMessageSignatures::Keys.to_pem key # => public PEM
|
|
66
|
+
HTTPMessageSignatures::Keys.to_pem key, private_key: true # => private PEM
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### Draft-Cavage (Legacy Fediverse)
|
|
70
|
+
|
|
71
|
+
This is what Mastodon, Pleroma, PeerTube, and most Fediverse software uses today.
|
|
72
|
+
|
|
73
|
+
#### Signing a request
|
|
74
|
+
|
|
75
|
+
```ruby
|
|
76
|
+
signer = HTTPMessageSignatures::Cavage::Signer.new(
|
|
77
|
+
key_id: 'https://example.com/users/alice#main-key',
|
|
78
|
+
private_key: private_key
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
headers = signer.sign(
|
|
82
|
+
method: 'POST',
|
|
83
|
+
path: '/inbox',
|
|
84
|
+
headers: {
|
|
85
|
+
'Host' => 'remote.example',
|
|
86
|
+
'Content-Type' => 'application/activity+json'
|
|
87
|
+
},
|
|
88
|
+
body: '{"type":"Follow"}'
|
|
89
|
+
)
|
|
90
|
+
# Returns a new hash with Signature, Date, and Digest headers added
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
#### Verifying a request
|
|
94
|
+
|
|
95
|
+
```ruby
|
|
96
|
+
verifier = HTTPMessageSignatures::Cavage::Verifier.new
|
|
97
|
+
|
|
98
|
+
result = verifier.verify(
|
|
99
|
+
method: 'POST',
|
|
100
|
+
path: '/inbox',
|
|
101
|
+
headers: request_headers,
|
|
102
|
+
body: request_body
|
|
103
|
+
) { |key_id| fetch_public_key(key_id) }
|
|
104
|
+
# => true or false
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
The verifier handles interop quirks:
|
|
108
|
+
- Mastodon's query-string-stripping from (`request-target`)
|
|
109
|
+
- Configurable clock skew (default 12 hours, `clock_skew: nil` to disable)
|
|
110
|
+
- Multiple digest format parsing
|
|
111
|
+
|
|
112
|
+
### RFC 9421 (Final Standard)
|
|
113
|
+
|
|
114
|
+
The modern standard with broader algorithm support,
|
|
115
|
+
multiple signatures, and explicit component coverage.
|
|
116
|
+
|
|
117
|
+
#### Signing a request
|
|
118
|
+
|
|
119
|
+
```ruby
|
|
120
|
+
signer = HTTPMessageSignatures::RFC9421::Signer.new(
|
|
121
|
+
key: private_key,
|
|
122
|
+
key_id: 'my-key-id',
|
|
123
|
+
algorithm: 'ed25519',
|
|
124
|
+
covered_components: %w[@method @authority @path content-type]
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
headers = signer.sign(
|
|
128
|
+
method: 'POST',
|
|
129
|
+
url: 'https://remote.example/inbox',
|
|
130
|
+
headers: { 'Content-Type' => 'application/activity+json' }
|
|
131
|
+
)
|
|
132
|
+
# Returns a new hash with Signature-Input and Signature headers added
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Optional parameters:
|
|
136
|
+
|
|
137
|
+
```ruby
|
|
138
|
+
signer = HTTPMessageSignatures::RFC9421::Signer.new(
|
|
139
|
+
key: private_key,
|
|
140
|
+
key_id: 'my-key-id',
|
|
141
|
+
algorithm: 'rsa-pss-sha512',
|
|
142
|
+
covered_components: %w[@method @authority @path],
|
|
143
|
+
label: 'my-sig', # default: 'sig1'
|
|
144
|
+
tag: 'my-app', # application-specific tag
|
|
145
|
+
expires_in: 300, # seconds until expiration
|
|
146
|
+
nonce: SecureRandom.hex(16)
|
|
147
|
+
)
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
#### Signing a response
|
|
151
|
+
|
|
152
|
+
```ruby
|
|
153
|
+
headers = signer.sign_response(
|
|
154
|
+
status: 200,
|
|
155
|
+
headers: { 'Content-Type' => 'application/json' }
|
|
156
|
+
)
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
#### Verifying a request
|
|
160
|
+
|
|
161
|
+
```ruby
|
|
162
|
+
verifier = HTTPMessageSignatures::RFC9421::Verifier.new
|
|
163
|
+
|
|
164
|
+
result = verifier.verify(
|
|
165
|
+
method: 'POST',
|
|
166
|
+
url: 'https://example.com/inbox',
|
|
167
|
+
headers: request_headers
|
|
168
|
+
) { |key_id, algorithm| fetch_public_key key_id }
|
|
169
|
+
|
|
170
|
+
result.verified # => true or false
|
|
171
|
+
result.key_id # => 'my-key-id'
|
|
172
|
+
result.algorithm # => 'ed25519'
|
|
173
|
+
result.label # => 'sig1'
|
|
174
|
+
result.components # => ['@method', '@authority', ...]
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
#### Multiple signatures
|
|
178
|
+
|
|
179
|
+
A message can carry multiple independent signatures:
|
|
180
|
+
|
|
181
|
+
```ruby
|
|
182
|
+
# Sign with two different keys
|
|
183
|
+
headers = signer_one.sign method: 'POST', url: url, headers: headers
|
|
184
|
+
headers = signer_two.sign method: 'POST', url: url, headers: headers
|
|
185
|
+
|
|
186
|
+
# Verify all signatures at once
|
|
187
|
+
results = verifier.verify_all(
|
|
188
|
+
method: 'POST',
|
|
189
|
+
url: url,
|
|
190
|
+
headers: headers
|
|
191
|
+
) { |key_id, algorithm| fetch_key key_id }
|
|
192
|
+
|
|
193
|
+
results.each { puts "#{it.label}: #{it.verified}" }
|
|
194
|
+
|
|
195
|
+
# Or list available labels
|
|
196
|
+
verifier.labels headers # => ['sig1', 'sig2']
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
### Rack Middleware
|
|
200
|
+
|
|
201
|
+
Automatically verify signatures on incoming requests:
|
|
202
|
+
|
|
203
|
+
```ruby
|
|
204
|
+
# config.ru
|
|
205
|
+
use HTTPMessageSignatures::RackMiddleware do |key_id|
|
|
206
|
+
fetch_public_key key_id
|
|
207
|
+
end
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
With options:
|
|
211
|
+
|
|
212
|
+
```ruby
|
|
213
|
+
use HTTPMessageSignatures::RackMiddleware,
|
|
214
|
+
paths: ['/inbox'],
|
|
215
|
+
clock_skew: 300,
|
|
216
|
+
on_failure: ->(_env, _error) { [403, {}, ['Forbidden']] } do |key_id|
|
|
217
|
+
fetch_public_key key_id
|
|
218
|
+
end
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
The middleware auto-detects the signature format (draft-cavage or RFC 9421)
|
|
222
|
+
and sets `env['http_signature.verified']` on success. Unsigned requests pass
|
|
223
|
+
through. Returns 401 by default on verification failure.
|
|
224
|
+
|
|
225
|
+
### HTTP.rb Signing Client
|
|
226
|
+
|
|
227
|
+
Automatically sign outgoing requests using the `http` gem:
|
|
228
|
+
|
|
229
|
+
```ruby
|
|
230
|
+
client = HTTPMessageSignatures::HTTPSigner.new(
|
|
231
|
+
key_id: 'https://example.com/users/alice#main-key',
|
|
232
|
+
private_key: private_key
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
response = client.post 'https://remote.example/inbox',
|
|
236
|
+
headers: { 'Content-Type' => 'application/activity+json' },
|
|
237
|
+
body: '{"type":"Follow"}'
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
RFC 9421 mode:
|
|
241
|
+
|
|
242
|
+
```ruby
|
|
243
|
+
client = HTTPMessageSignatures::HTTPSigner.new(
|
|
244
|
+
key_id: 'my-key-id',
|
|
245
|
+
private_key: private_key,
|
|
246
|
+
mode: :rfc9421,
|
|
247
|
+
algorithm: 'ed25519'
|
|
248
|
+
)
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
Supports `get`, `post`, `put`, `patch`, `delete`, and `head`.
|
|
252
|
+
|
|
253
|
+
### Supported Algorithms
|
|
254
|
+
|
|
255
|
+
| Algorithm | Name | Key Type |
|
|
256
|
+
|----------------------|---------------------|---------------|
|
|
257
|
+
| RSA-PSS-SHA512 | `rsa-pss-sha512` | RSA |
|
|
258
|
+
| RSASSA-PKCS1-v1_5 | `rsa-v1_5-sha256` | RSA |
|
|
259
|
+
| HMAC-SHA256 | `hmac-sha256` | Shared secret |
|
|
260
|
+
| ECDSA P-256 | `ecdsa-p256-sha256` | EC P-256 |
|
|
261
|
+
| ECDSA P-384 | `ecdsa-p384-sha384` | EC P-384 |
|
|
262
|
+
| Ed25519 | `ed25519` | Ed25519 |
|
|
263
|
+
|
|
264
|
+
## Development
|
|
265
|
+
|
|
266
|
+
```sh
|
|
267
|
+
bundle install
|
|
268
|
+
bundle exec rspec
|
|
269
|
+
bundle exec rubocop
|
|
270
|
+
bundle exec rake
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
## Contributing
|
|
274
|
+
|
|
275
|
+
Bug reports and pull requests are welcome on GitHub at
|
|
276
|
+
https://github.com/xoengineering/http_message_signatures.
|
|
277
|
+
|
|
278
|
+
## License
|
|
279
|
+
|
|
280
|
+
The gem is available as open source under the terms of the
|
|
281
|
+
[GNU Affero General Public License v3.0](https://www.gnu.org/licenses/agpl-3.0.html).
|
|
282
|
+
|
|
283
|
+
## References
|
|
284
|
+
|
|
285
|
+
- [HTTP Signatures (draft-cavage)](https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures)
|
|
286
|
+
- [RFC 9421 — HTTP Message Signatures](https://www.rfc-editor.org/rfc/rfc9421.html)
|
|
287
|
+
- [RFC 8941 — Structured Field Values for HTTP](https://www.rfc-editor.org/rfc/rfc8941.html)
|
|
288
|
+
- [ActivityPub](https://www.w3.org/TR/activitypub)
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
module HTTPMessageSignatures
|
|
2
|
+
module Cavage
|
|
3
|
+
# Parses and serializes draft-cavage Signature headers.
|
|
4
|
+
#
|
|
5
|
+
# The Signature header format is:
|
|
6
|
+
# keyId="...",algorithm="...",headers="...",signature="..."
|
|
7
|
+
#
|
|
8
|
+
# @example Parsing
|
|
9
|
+
# sig = SignatureHeader.parse(request.headers['Signature'])
|
|
10
|
+
# sig.key_id # => "https://example.com/users/alice#main-key"
|
|
11
|
+
# sig.algorithm # => "rsa-sha256"
|
|
12
|
+
# sig.headers # => ["(request-target)", "host", "date"]
|
|
13
|
+
# sig.signature # => "base64..."
|
|
14
|
+
#
|
|
15
|
+
# @example Building
|
|
16
|
+
# sig = SignatureHeader.new(key_id: "...", algorithm: "rsa-sha256", headers: [...], signature: "...")
|
|
17
|
+
# sig.to_s # => 'keyId="...",algorithm="...",headers="...",signature="..."'
|
|
18
|
+
class SignatureHeader
|
|
19
|
+
attr_reader :algorithm, :headers, :key_id, :signature
|
|
20
|
+
|
|
21
|
+
def initialize algorithm:, headers:, key_id:, signature:
|
|
22
|
+
@algorithm = algorithm
|
|
23
|
+
@headers = headers
|
|
24
|
+
@key_id = key_id
|
|
25
|
+
@signature = signature
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Parse a Signature header string into a SignatureHeader object.
|
|
29
|
+
#
|
|
30
|
+
# @param header [String] the raw Signature header value
|
|
31
|
+
# @return [SignatureHeader]
|
|
32
|
+
# @raise [ParseError] if the header is missing required fields
|
|
33
|
+
def self.parse header
|
|
34
|
+
raise ParseError, 'Signature header is empty' if header.nil? || header.strip.empty?
|
|
35
|
+
|
|
36
|
+
params = parse_params header.delete_prefix('Signature ')
|
|
37
|
+
|
|
38
|
+
raise ParseError, 'Missing required field: keyId' unless params.key? 'keyId'
|
|
39
|
+
raise ParseError, 'Missing required field: signature' unless params.key? 'signature'
|
|
40
|
+
|
|
41
|
+
new(
|
|
42
|
+
key_id: params['keyId'],
|
|
43
|
+
algorithm: params.fetch('algorithm', 'hs2019'),
|
|
44
|
+
headers: params.key?('headers') ? params['headers'].split : ['date'],
|
|
45
|
+
signature: params['signature']
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Serialize to a Signature header string.
|
|
50
|
+
#
|
|
51
|
+
# @return [String]
|
|
52
|
+
def to_s
|
|
53
|
+
format(
|
|
54
|
+
'keyId="%<key_id>s",algorithm="%<algorithm>s",headers="%<headers>s",signature="%<signature>s"',
|
|
55
|
+
key_id: @key_id,
|
|
56
|
+
algorithm: @algorithm,
|
|
57
|
+
headers: @headers.join(' '),
|
|
58
|
+
signature: @signature
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
class << self
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
# Parse key="value" pairs, handling commas inside quoted values.
|
|
66
|
+
def parse_params header
|
|
67
|
+
params = {}
|
|
68
|
+
scanner = StringScanner.new header.strip
|
|
69
|
+
|
|
70
|
+
until scanner.eos?
|
|
71
|
+
scanner.skip(/\s*,?\s*/)
|
|
72
|
+
break if scanner.eos?
|
|
73
|
+
|
|
74
|
+
key = scan_key scanner
|
|
75
|
+
scanner.skip(/\s*=\s*/)
|
|
76
|
+
value = scan_quoted_value scanner, key
|
|
77
|
+
|
|
78
|
+
params[key] = value
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
params
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def scan_key scanner
|
|
85
|
+
key = scanner.scan(/[a-zA-Z]+/)
|
|
86
|
+
raise ParseError, "Expected parameter name at position #{scanner.pos}" unless key
|
|
87
|
+
|
|
88
|
+
key
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def scan_quoted_value scanner, key
|
|
92
|
+
raise ParseError, "Expected quoted value for #{key} at position #{scanner.pos}" unless scanner.skip(/"/)
|
|
93
|
+
|
|
94
|
+
value = +''
|
|
95
|
+
until scanner.eos?
|
|
96
|
+
chunk = scanner.scan(/[^"\\]+/)
|
|
97
|
+
value << chunk if chunk
|
|
98
|
+
|
|
99
|
+
if scanner.peek(1) == '\\'
|
|
100
|
+
scanner.getch
|
|
101
|
+
value << scanner.getch
|
|
102
|
+
elsif scanner.peek(1) == '"'
|
|
103
|
+
scanner.getch
|
|
104
|
+
break
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
value
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
require 'openssl'
|
|
2
|
+
require 'base64'
|
|
3
|
+
require 'time'
|
|
4
|
+
|
|
5
|
+
module HTTPMessageSignatures
|
|
6
|
+
module Cavage
|
|
7
|
+
# Signs outgoing HTTP requests using draft-cavage HTTP signatures.
|
|
8
|
+
#
|
|
9
|
+
# @example
|
|
10
|
+
# key = OpenSSL::PKey::RSA.generate(2048)
|
|
11
|
+
# signer = Signer.new(key_id: "https://example.com/users/alice#main-key", private_key: key)
|
|
12
|
+
# signed_headers = signer.sign(method: "POST", path: "/inbox", headers: headers, body: body)
|
|
13
|
+
class Signer
|
|
14
|
+
DEFAULT_POST_HEADERS = %w[(request-target) host date digest content-type].freeze
|
|
15
|
+
DEFAULT_GET_HEADERS = %w[(request-target) host date].freeze
|
|
16
|
+
|
|
17
|
+
attr_reader :key_id, :private_key
|
|
18
|
+
|
|
19
|
+
def initialize key_id:, private_key:
|
|
20
|
+
@key_id = key_id
|
|
21
|
+
@private_key = private_key
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Sign a request and return the headers with Signature added.
|
|
25
|
+
#
|
|
26
|
+
# @param headers [Hash] request headers
|
|
27
|
+
# @param method [String] HTTP method
|
|
28
|
+
# @param path [String] request path
|
|
29
|
+
# @param body [String, nil] request body
|
|
30
|
+
# @param signed_headers [Array<String>, nil] headers to sign (defaults based on method)
|
|
31
|
+
# @return [Hash] headers with Signature (and possibly Digest, Date) added
|
|
32
|
+
def sign headers:, method:, path:, body: nil, signed_headers: nil
|
|
33
|
+
result = headers.dup
|
|
34
|
+
result['Date'] ||= Time.now.httpdate
|
|
35
|
+
|
|
36
|
+
add_digest result, body
|
|
37
|
+
|
|
38
|
+
signed_headers ||= default_signed_headers method, body
|
|
39
|
+
signing_string = SigningString.build(method: method, path: path, signed_headers: signed_headers, headers: result)
|
|
40
|
+
signature = compute_signature signing_string
|
|
41
|
+
|
|
42
|
+
sig_header = SignatureHeader.new(
|
|
43
|
+
key_id: @key_id,
|
|
44
|
+
algorithm: 'rsa-sha256',
|
|
45
|
+
headers: signed_headers,
|
|
46
|
+
signature: signature
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
result['Signature'] = sig_header.to_s
|
|
50
|
+
result
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def add_digest headers, body
|
|
56
|
+
return if body.nil? || body.empty?
|
|
57
|
+
return if headers.key? 'Digest'
|
|
58
|
+
|
|
59
|
+
digest = OpenSSL::Digest::SHA256.digest body
|
|
60
|
+
headers['Digest'] = "SHA-256=#{Base64.strict_encode64(digest)}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def compute_signature signing_string
|
|
64
|
+
raw = @private_key.sign 'SHA256', signing_string
|
|
65
|
+
Base64.strict_encode64 raw
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def default_signed_headers method, body
|
|
69
|
+
if body && !body.empty? && method.upcase == 'POST'
|
|
70
|
+
DEFAULT_POST_HEADERS
|
|
71
|
+
else
|
|
72
|
+
DEFAULT_GET_HEADERS
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
module HTTPMessageSignatures
|
|
2
|
+
module Cavage
|
|
3
|
+
# Builds the signing string for draft-cavage HTTP signatures.
|
|
4
|
+
#
|
|
5
|
+
# The signing string is a newline-separated list of "header: value" lines,
|
|
6
|
+
# where the special pseudo-header `(request-target)` is the lowercase method
|
|
7
|
+
# followed by a space and the request path.
|
|
8
|
+
#
|
|
9
|
+
# @example
|
|
10
|
+
# SigningString.build(
|
|
11
|
+
# method: "post",
|
|
12
|
+
# path: "/inbox",
|
|
13
|
+
# signed_headers: ["(request-target)", "host", "date"],
|
|
14
|
+
# headers: { "Host" => "example.com", "Date" => "Sun, 09 Feb 2025 07:30:00 GMT" }
|
|
15
|
+
# )
|
|
16
|
+
# # => "(request-target): post /inbox\nhost: example.com\ndate: Sun, 09 Feb 2025 07:30:00 GMT"
|
|
17
|
+
module SigningString
|
|
18
|
+
class << self
|
|
19
|
+
# Build the signing string from the given request components.
|
|
20
|
+
#
|
|
21
|
+
# @param headers [Hash] the request headers
|
|
22
|
+
# @param method [String] HTTP method
|
|
23
|
+
# @param path [String] request path (e.g. "/inbox")
|
|
24
|
+
# @param signed_headers [Array<String>] list of headers to include
|
|
25
|
+
# @return [String] the signing string
|
|
26
|
+
# @raise [SigningError] if a required header is missing
|
|
27
|
+
def build headers:, method:, path:, signed_headers:
|
|
28
|
+
normalized = normalize_headers headers
|
|
29
|
+
|
|
30
|
+
signed_headers.map do |name|
|
|
31
|
+
resolve_header name, method, path, normalized
|
|
32
|
+
end.join("\n")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def normalize_headers headers
|
|
38
|
+
headers.transform_keys(&:downcase)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def resolve_header name, method, path, normalized_headers
|
|
42
|
+
lowered = name.downcase
|
|
43
|
+
|
|
44
|
+
if lowered == '(request-target)'
|
|
45
|
+
"(request-target): #{method.downcase} #{path}"
|
|
46
|
+
else
|
|
47
|
+
value = normalized_headers[lowered]
|
|
48
|
+
raise SigningError, "Missing required header: #{name}" unless value
|
|
49
|
+
|
|
50
|
+
"#{lowered}: #{value}"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
require 'openssl'
|
|
2
|
+
require 'base64'
|
|
3
|
+
require 'time'
|
|
4
|
+
|
|
5
|
+
module HTTPMessageSignatures
|
|
6
|
+
module Cavage
|
|
7
|
+
# Verifies incoming HTTP request signatures using draft-cavage.
|
|
8
|
+
#
|
|
9
|
+
# @example Basic usage
|
|
10
|
+
# verifier = Verifier.new
|
|
11
|
+
# verifier.verify(method: "POST", path: "/inbox", headers: headers, body: body) do |key_id|
|
|
12
|
+
# fetch_public_key(key_id) # return an OpenSSL::PKey
|
|
13
|
+
# end
|
|
14
|
+
# # => true or false
|
|
15
|
+
#
|
|
16
|
+
# @example Custom clock skew window (5 minutes)
|
|
17
|
+
# verifier = Verifier.new(clock_skew: 300)
|
|
18
|
+
#
|
|
19
|
+
# @example Disable Date freshness check
|
|
20
|
+
# verifier = Verifier.new(clock_skew: nil)
|
|
21
|
+
class Verifier
|
|
22
|
+
DEFAULT_CLOCK_SKEW = 43_200 # 12 hours in seconds
|
|
23
|
+
|
|
24
|
+
# @param clock_skew [Integer, nil] max allowed age of Date header in seconds (default 12h, nil to disable)
|
|
25
|
+
def initialize clock_skew: DEFAULT_CLOCK_SKEW
|
|
26
|
+
@clock_skew = clock_skew
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Verify a request signature. Returns true/false.
|
|
30
|
+
#
|
|
31
|
+
# @param headers [Hash] request headers (must include Signature)
|
|
32
|
+
# @param method [String] HTTP method
|
|
33
|
+
# @param path [String] request path
|
|
34
|
+
# @param body [String, nil] request body
|
|
35
|
+
# @yield [key_id] block that resolves a key_id to an OpenSSL public key
|
|
36
|
+
# @return [Boolean]
|
|
37
|
+
# @raise [VerificationError] if the Signature header is missing
|
|
38
|
+
# @raise [KeyError] if the key resolver returns nil
|
|
39
|
+
def verify headers:, method:, path:, body: nil
|
|
40
|
+
sig = parse_signature headers
|
|
41
|
+
return false unless digest_valid? headers, body
|
|
42
|
+
return false unless date_fresh? headers
|
|
43
|
+
|
|
44
|
+
public_key = resolve_key(sig.key_id) { yield sig.key_id }
|
|
45
|
+
raw_signature = Base64.strict_decode64 sig.signature
|
|
46
|
+
algorithm = algorithm_for sig.algorithm
|
|
47
|
+
|
|
48
|
+
# Try verification with the full path first, then fall back to path
|
|
49
|
+
# without query string. Mastodon and some other implementations omit
|
|
50
|
+
# the query string from (request-target) when signing.
|
|
51
|
+
paths_to_try(path).any? do |candidate_path|
|
|
52
|
+
signing_string = SigningString.build(
|
|
53
|
+
method: method,
|
|
54
|
+
path: candidate_path,
|
|
55
|
+
signed_headers: sig.headers,
|
|
56
|
+
headers: headers
|
|
57
|
+
)
|
|
58
|
+
public_key.verify algorithm, raw_signature, signing_string
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Verify a request signature. Raises on failure.
|
|
63
|
+
#
|
|
64
|
+
# @raise [VerificationError] if the signature is invalid
|
|
65
|
+
def verify!(**, &)
|
|
66
|
+
return true if verify(**, &)
|
|
67
|
+
|
|
68
|
+
raise VerificationError, 'Invalid signature: verification failed'
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def parse_signature headers
|
|
74
|
+
raw = headers['Signature'] || headers['signature']
|
|
75
|
+
raise VerificationError, 'Missing Signature header' unless raw
|
|
76
|
+
|
|
77
|
+
SignatureHeader.parse raw
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def resolve_key key_id
|
|
81
|
+
public_key = yield key_id
|
|
82
|
+
raise KeyError, "Could not resolve public key for key_id: #{key_id}" unless public_key
|
|
83
|
+
|
|
84
|
+
public_key
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def digest_valid? headers, body
|
|
88
|
+
digest_header = headers['Digest'] || headers['digest']
|
|
89
|
+
return true unless digest_header
|
|
90
|
+
return true if body.nil?
|
|
91
|
+
|
|
92
|
+
expected = Base64.strict_encode64(OpenSSL::Digest::SHA256.digest(body))
|
|
93
|
+
extract_sha256(digest_header) == expected
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def extract_sha256 digest_header
|
|
97
|
+
digest_header.split(',').each do |part|
|
|
98
|
+
algorithm, value = part.strip.split('=', 2)
|
|
99
|
+
return value if algorithm.casecmp('SHA-256').zero?
|
|
100
|
+
end
|
|
101
|
+
nil
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def date_fresh? headers
|
|
105
|
+
return true if @clock_skew.nil?
|
|
106
|
+
|
|
107
|
+
date_value = headers['Date'] || headers['date']
|
|
108
|
+
return true unless date_value
|
|
109
|
+
|
|
110
|
+
request_time = Time.httpdate date_value
|
|
111
|
+
(Time.now - request_time).abs <= @clock_skew
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def paths_to_try path
|
|
115
|
+
base = path.split('?', 2).first
|
|
116
|
+
base == path ? [path] : [path, base]
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def algorithm_for algorithm
|
|
120
|
+
case algorithm
|
|
121
|
+
when 'rsa-sha256', 'hs2019' then 'SHA256'
|
|
122
|
+
when 'rsa-sha512' then 'SHA512'
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
module HTTPMessageSignatures
|
|
2
|
+
# Base error class for all HTTP Message Signatures errors
|
|
3
|
+
class Error < StandardError; end
|
|
4
|
+
|
|
5
|
+
# Raised when signing a request fails
|
|
6
|
+
class SigningError < Error; end
|
|
7
|
+
|
|
8
|
+
# Raised when verification of a signature fails
|
|
9
|
+
class VerificationError < Error; end
|
|
10
|
+
|
|
11
|
+
# Raised when parsing a Signature header fails
|
|
12
|
+
class ParseError < Error; end
|
|
13
|
+
|
|
14
|
+
# Raised when a required key is missing or invalid
|
|
15
|
+
class KeyError < Error; end
|
|
16
|
+
end
|