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.
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