http_signature 1.0.0 → 1.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0d4b1e73dff1838a018f5e0d5bef1c6a6a14c2b8ef552dbbfd0481ac85cb03b7
4
- data.tar.gz: 1a087ded86d8f84213984d9bd56fb55462f65feb9abc876514249f12fc5166ef
3
+ metadata.gz: 4d7340510585b31400802574581fc40aa006889f7f43e67e6ac4d5c926ddc3ce
4
+ data.tar.gz: 3364453874b93eb6b37a2ef13208dbb2792b4b50975c872459fea1a1d70a68d7
5
5
  SHA512:
6
- metadata.gz: 7796499163d70782ea0fd809316c39a4d2a933cf01cc88f6daaa88d673607e9a138643d666fc5080063ea0bdb1068d32c48642fb487c59d6edcb7626fba7ad8e
7
- data.tar.gz: 1603d71ad33fc087e456949de3e1534f3cf08c894a3fe10a3f456f6751409ddbbda247a53511d201866cf10f629e2c676fbe2eaa7cb0e0535dae81a2cad6e2a2
6
+ metadata.gz: ba102a57a504be38d46ff962442d273dcee1866e5e49127927f638774dc30a66b92c063edb62670ba09b42b14bb2257772992183b606ef2abc7c1d1340677717
7
+ data.tar.gz: b2ee1d08d85958e663760b999c45a8865e2db4f43920fbf9a77bae78479a5192e939d0cfdab26a6d47dd936c1bb17311b809ac6b8278f92d1c8ced43de957d9d
@@ -0,0 +1,25 @@
1
+ on:
2
+ push:
3
+ tags:
4
+ - 'v*.*.*'
5
+
6
+ jobs:
7
+ push:
8
+ name: Push gem to RubyGems.org
9
+ runs-on: ubuntu-latest
10
+
11
+ permissions:
12
+ id-token: write # IMPORTANT: this permission is mandatory for trusted publishing
13
+ contents: write # IMPORTANT: this permission is required for `rake release` to push the release tag
14
+
15
+ steps:
16
+ - uses: actions/checkout@v5
17
+ with:
18
+ persist-credentials: false
19
+ - name: Set up Ruby
20
+ uses: ruby/setup-ruby@v1
21
+ with:
22
+ bundler-cache: true
23
+ ruby-version: '3.4'
24
+
25
+ - uses: rubygems/release-gem@v1
data/AGENTS.md ADDED
@@ -0,0 +1,8 @@
1
+ # HTTP signature gem
2
+
3
+ This is a Ruby gem implementing the [HTTP Message Signatures RFC 9421 standard](https://www.rfc-editor.org/rfc/rfc9421.txt). Always adhere to the standard
4
+
5
+ ## Tests
6
+ - Run all tests: `bundle exec rake test`
7
+ - Run single test file: `bundle exec rake test TEST=test/http_signature_test.rb`
8
+ - Run single test: `bundle exec rake test TEST=test/http_signature_test.rb TESTOPTS="--name=/test_rsa_pss_sha512/"`
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- http_signature (1.0.0)
4
+ http_signature (1.1.0)
5
5
  base64
6
6
 
7
7
  GEM
data/README.md CHANGED
@@ -2,18 +2,19 @@
2
2
 
3
3
  Create and validate HTTP Message Signatures per [RFC 9421](https://www.rfc-editor.org/rfc/rfc9421) using the `Signature-Input` and `Signature` headers.
4
4
 
5
- Aims to only implement the creation and validation of signatures without any external dependencies. Adapters are provided for common HTTP libraries.
5
+ TL;DR: You specify what should be signed in `Signature-Input` with [components](https://www.rfc-editor.org/rfc/rfc9421#name-derived-components) and lowercase headers. And then the signature is in the `Signature` header
6
+
7
+ Example:
6
8
 
7
- __NOTE__: RFC 9421 signs components via two headers:
8
9
  ```
9
- Signature-Input: sig1=("@method" "@authority" "@target-uri" "date");created=...
10
- Signature: sig1=:BASE64_SIGNATURE_BYTES:
10
+ Signature-Input: sig1=("@method" "@target-uri" "date");created=1767816111;keyid="Test";alg="hmac-sha256"
11
+ Signature: sig1=:7a1ajkE2rOu+gnW3WLZ4ZEcgCm3TfExmypM/giIgdM0=:
11
12
  ```
12
13
 
13
14
  ## Installation
14
15
 
15
16
  ```shell
16
- gem install http_signature
17
+ bundle add http_signature
17
18
  ```
18
19
 
19
20
  ## Usage
@@ -22,21 +23,40 @@ gem install http_signature
22
23
 
23
24
  `HTTPSignature.create` returns both `Signature-Input` and `Signature` headers that you can include in your request.
24
25
 
25
-
26
26
  ```ruby
27
- headers = { 'date' => 'Tue, 20 Apr 2021 02:07:55 GMT' }
27
+ headers = { "date" => "Tue, 20 Apr 2021 02:07:55 GMT" }
28
28
 
29
29
  sig_headers = HTTPSignature.create(
30
- url: 'https://example.com/foo?pet=dog',
30
+ url: "https://example.com/foo?pet=dog",
31
31
  method: :get,
32
+ key_id: "Test",
33
+ key: "secret",
32
34
  headers: headers,
33
- key_id: 'Test',
34
- key: 'secret',
35
- covered_components: %w[@method @target-uri date],
35
+ components: %w[@method @target-uri date]
36
36
  )
37
37
 
38
- request['Signature-Input'] = sig_headers['Signature-Input']
39
- request['Signature'] = sig_headers['Signature']
38
+ request["Signature-Input"] = sig_headers["Signature-Input"]
39
+ request["Signature"] = sig_headers["Signature"]
40
+ ```
41
+ #### All options
42
+
43
+ ```ruby
44
+ HTTPSignature.create(
45
+ url: "https://example.com/foo?pet=dog",
46
+ method: :get,
47
+ key_id: "Test",
48
+ key: "secret",
49
+ # Optional arguments
50
+ headers: headers, # Default: {}
51
+ body: "Hello world", # Default: ""
52
+ components: %w[@method @target-uri date], # Default: %w[@method @target-uri content-digest content-type]
53
+ created: Time.now.to_i, # Default: Time.now.to_i
54
+ expires: Time.now.to_i + 600, # Default: nil
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"
59
+ )
40
60
  ```
41
61
 
42
62
 
@@ -51,6 +71,9 @@ HTTPSignature.valid?(
51
71
  headers: headers,
52
72
  key: "secret"
53
73
  )
74
+
75
+ # Returns true when all is good.
76
+ # Raises `SignatureError` for invalid signatures
54
77
  ```
55
78
 
56
79
  ## Outgoing request examples
@@ -58,10 +81,10 @@ HTTPSignature.valid?(
58
81
  ### NET::HTTP
59
82
 
60
83
  ```ruby
61
- require 'net/http'
62
- require 'http_signature'
84
+ require "net/http"
85
+ require "http_signature"
63
86
 
64
- uri = URI('http://example.com/hello')
87
+ uri = URI("http://example.com/hello")
65
88
 
66
89
  Net::HTTP.start(uri.host, uri.port) do |http|
67
90
  request = Net::HTTP::Get.new(uri)
@@ -70,14 +93,14 @@ Net::HTTP.start(uri.host, uri.port) do |http|
70
93
  url: request.uri,
71
94
  method: request.method,
72
95
  headers: request.each_header.map { |k, v| [k, v] }.to_h,
73
- key: 'MYSECRETKEY',
74
- key_id: 'KEY_1',
75
- algorithm: 'hmac-sha256',
76
- body: request.body ? request.body : ''
96
+ key: "MYSECRETKEY",
97
+ key_id: "KEY_1",
98
+ algorithm: "hmac-sha256",
99
+ body: request.body || ""
77
100
  )
78
101
 
79
- request['Signature-Input'] = sig_headers['Signature-Input']
80
- request['Signature'] = sig_headers['Signature']
102
+ request["Signature-Input"] = sig_headers["Signature-Input"]
103
+ request["Signature"] = sig_headers["Signature"]
81
104
 
82
105
  response = http.request(request) # Net::HTTPResponse
83
106
  end
@@ -88,18 +111,18 @@ end
88
111
  As a faraday middleware
89
112
 
90
113
  ```ruby
91
- require 'http_signature/faraday'
114
+ require "http_signature/faraday"
92
115
 
93
- HTTPSignature::Faraday.key = 'secret'
94
- HTTPSignature::Faraday.key_id = 'key-1'
116
+ HTTPSignature::Faraday.key = "secret"
117
+ HTTPSignature::Faraday.key_id = "key-1"
95
118
 
96
- Faraday.new('http://example.com') do |faraday|
119
+ Faraday.new("http://example.com") do |faraday|
97
120
  faraday.use(HTTPSignature::Faraday)
98
121
  faraday.adapter(Faraday.default_adapter)
99
122
  end
100
123
 
101
124
  # Now this request will contain the `Signature-Input` and `Signature` headers
102
- response = conn.get('/')
125
+ response = conn.get("/")
103
126
 
104
127
  # Request looking like:
105
128
  # Signature-Input: sig1=("@method" "@authority" "@target-uri" "date");created=...
@@ -114,14 +137,14 @@ Rack middlewares sits in between your app and the HTTP request and validate the
114
137
  Here is how it could be used with sinatra:
115
138
 
116
139
  ```ruby
117
- require 'http_signature/rack'
140
+ require "http_signature/rack"
118
141
 
119
142
  HTTPSignature.configure do |config|
120
143
  config.keys = [
121
- {id: 'key-1', value: 'MySecureKey'}
144
+ {id: "key-1", value: "MySecureKey"}
122
145
  ]
123
146
  end
124
- HTTPSignature::Rack.exclude_paths = ['/', '/hello/*']
147
+ HTTPSignature::Rack.exclude_paths = ["/", "/hello/*"]
125
148
 
126
149
  use HTTPSignature::Rack
127
150
  run MyApp
@@ -133,7 +156,7 @@ Opt-in per controller/action using a before_action. It responds with `401 Unauth
133
156
  ```ruby
134
157
  # app/controllers/api/base_controller.rb
135
158
 
136
- require 'http_signature/rails'
159
+ require "http_signature/rails"
137
160
 
138
161
  class Api::BaseController < ApplicationController
139
162
  include HTTPSignature::Rails::Controller
@@ -148,7 +171,7 @@ Set the keys in an initializer
148
171
 
149
172
  HTTPSignature.configure do |config|
150
173
  config.keys = [
151
- {id: 'key-1', value: 'MySecureKey'}
174
+ {id: "key-1", value: "MySecureKey"}
152
175
  ]
153
176
  end
154
177
  ```
@@ -18,7 +18,7 @@ Gem::Specification.new do |spec|
18
18
  spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
19
19
  spec.require_paths = ["lib"]
20
20
 
21
- spec.required_ruby_version = ">= 3.0.0"
21
+ spec.required_ruby_version = ">= 3.1.0"
22
22
 
23
23
  spec.add_development_dependency "bundler"
24
24
  spec.add_development_dependency "rake"
@@ -33,7 +33,7 @@ class HTTPSignature::Rack
33
33
  else
34
34
  ""
35
35
  end
36
- valid_signature = HTTPSignature.valid?(
36
+ HTTPSignature.valid?(
37
37
  url: request.url,
38
38
  method: request.request_method,
39
39
  headers: request_headers,
@@ -44,8 +44,6 @@ class HTTPSignature::Rack
44
44
  return [401, {}, ["Invalid signature"]]
45
45
  end
46
46
 
47
- return [401, {}, ["Invalid signature"]] unless valid_signature
48
-
49
47
  @app.call(env)
50
48
  end
51
49
 
@@ -20,7 +20,7 @@ module HTTPSignature
20
20
 
21
21
  request_body = read_request_body
22
22
 
23
- valid_signature = HTTPSignature.valid?(
23
+ HTTPSignature.valid?(
24
24
  url: request.url,
25
25
  method: request.request_method,
26
26
  headers: request_headers,
@@ -28,10 +28,8 @@ module HTTPSignature
28
28
  key_resolver: ->(key_id) { HTTPSignature.key(key_id) }
29
29
  )
30
30
 
31
- return if valid_signature
32
-
33
- render status: :unauthorized, plain: "Invalid signature"
34
- rescue HTTPSignature::SignatureError, ArgumentError
31
+ nil
32
+ rescue HTTPSignature::SignatureError
35
33
  render status: :unauthorized, plain: "Invalid signature"
36
34
  end
37
35
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module HTTPSignature
4
- VERSION = "1.0.0"
4
+ VERSION = "1.1.0"
5
5
  end
@@ -11,11 +11,13 @@ module HTTPSignature
11
11
  Config = Struct.new(:keys)
12
12
  DEFAULT_LABEL = "sig1"
13
13
  DEFAULT_ALGORITHM = "hmac-sha256"
14
- DEFAULT_COMPONENTS = %w[@method @authority @target-uri].freeze
14
+ DEFAULT_COMPONENTS = %w[@method @target-uri].freeze
15
+ DEFAULT_HEADERS = %w[content-digest content-type].freeze
15
16
 
16
17
  class SignatureError < StandardError; end
17
18
  class MissingComponent < SignatureError; end
18
19
  class UnsupportedAlgorithm < SignatureError; end
20
+ class ExpiredError < SignatureError; end
19
21
 
20
22
  Algorithm = Struct.new(:type, :digest_name, :curve)
21
23
  ALGORITHMS = {
@@ -55,36 +57,52 @@ module HTTPSignature
55
57
  headers: {},
56
58
  body: "",
57
59
  algorithm: DEFAULT_ALGORITHM,
58
- covered_components: nil,
60
+ components: nil,
59
61
  created: Time.now.to_i,
62
+ expires: nil,
60
63
  nonce: nil,
61
64
  label: DEFAULT_LABEL,
62
65
  query_string_params: {}
63
66
  )
67
+ unless created.is_a?(Integer)
68
+ raise ArgumentError, "created must be a Unix timestamp integer"
69
+ end
70
+ if expires && !expires.is_a?(Integer)
71
+ raise ArgumentError, "expires must be a Unix timestamp integer"
72
+ end
73
+ if expires && created > expires
74
+ raise ArgumentError, "expires (#{expires}) must be greater than created (#{created})"
75
+ end
64
76
  algorithm_entry = algorithm_entry_for(algorithm)
65
77
  normalized_headers = normalize_headers(headers)
66
78
  uri = apply_query_params(URI(url), query_string_params)
67
79
 
68
- normalized_headers = ensure_content_digest(normalized_headers, body)
80
+ components ||=
81
+ default_components(normalized_headers, body:)
69
82
 
70
- components =
71
- covered_components || default_components(normalized_headers)
83
+ normalized_headers =
84
+ if components.include?("content-digest")
85
+ ensure_content_digest(normalized_headers, body)
86
+ else
87
+ normalized_headers
88
+ end
72
89
 
73
90
  canonical_components = build_components(
74
- uri: uri,
75
- method: method,
91
+ uri:,
92
+ method:,
76
93
  headers: normalized_headers,
77
- covered_components: components
94
+ components:
78
95
  )
79
96
 
80
97
  signature_input_header, base_string = build_signature_input(
81
- label: label,
82
- components: components,
83
- created: created,
84
- key_id: key_id,
98
+ label:,
99
+ components:,
100
+ created:,
101
+ expires:,
102
+ key_id:,
85
103
  alg: algorithm,
86
- nonce: nonce,
87
- canonical_components: canonical_components
104
+ nonce:,
105
+ canonical_components:
88
106
  )
89
107
 
90
108
  signature_bytes = sign(base_string, key: key, algorithm: algorithm_entry)
@@ -98,7 +116,7 @@ module HTTPSignature
98
116
 
99
117
  # Verify RFC 9421 Signature headers
100
118
  #
101
- # @return [Boolean]
119
+ # @return [Boolean] true when signature verification succeeds
102
120
  def self.valid?(
103
121
  url:,
104
122
  method:,
@@ -120,30 +138,42 @@ module HTTPSignature
120
138
 
121
139
  algorithm_entry = algorithm_entry_for(parsed_input[:params][:alg] || DEFAULT_ALGORITHM)
122
140
  key_id = parsed_input[:params][:keyid]
141
+ created = parsed_input[:params][:created].to_i
142
+ expires = parsed_input[:params][:expires]&.to_i
143
+ now = Time.now.to_i
144
+ if expires && (created > expires || now > expires)
145
+ raise ExpiredError, "Signature expired at #{expires}"
146
+ end
123
147
  resolved_key = key || key_resolver&.call(key_id) || key_from_store(key_id)
124
148
  raise SignatureError, "Key is required for verification" unless resolved_key
125
149
 
126
150
  uri = apply_query_params(URI(url), query_string_params)
127
- normalized_headers = ensure_content_digest(normalized_headers, body)
151
+ if parsed_input[:components].include?("content-digest")
152
+ normalized_headers = ensure_content_digest(normalized_headers, body)
153
+ end
128
154
 
129
155
  canonical_components = build_components(
130
- uri: uri,
131
- method: method,
156
+ uri:,
157
+ method:,
132
158
  headers: normalized_headers,
133
- covered_components: parsed_input[:components]
159
+ components: parsed_input[:components]
134
160
  )
135
161
 
136
162
  _, base_string = build_signature_input(
137
- label: label,
163
+ label:,
138
164
  components: parsed_input[:components],
139
- created: parsed_input[:params][:created].to_i,
140
- key_id: key_id,
165
+ created:,
166
+ expires:,
167
+ key_id:,
141
168
  alg: parsed_input[:params][:alg],
142
169
  nonce: parsed_input[:params][:nonce],
143
- canonical_components: canonical_components
170
+ canonical_components:
144
171
  )
145
172
 
146
- verify_signature(base_string, parsed_signature, resolved_key, algorithm_entry)
173
+ verified = verify_signature(base_string, parsed_signature, resolved_key, algorithm_entry)
174
+ raise SignatureError, "Invalid signature" unless verified
175
+
176
+ true
147
177
  end
148
178
 
149
179
  # -- Private-ish helpers --
@@ -162,10 +192,19 @@ module HTTPSignature
162
192
  new_uri
163
193
  end
164
194
 
165
- def self.default_components(headers)
195
+ def self.default_components(headers, body: nil)
166
196
  components = DEFAULT_COMPONENTS.dup
167
- components << "date" if headers["date"]
168
- components << "content-digest" if headers["content-digest"]
197
+ DEFAULT_HEADERS.each do |header|
198
+ include_header =
199
+ if header == "content-digest"
200
+ !body.to_s.empty? || headers[header]
201
+ else
202
+ headers[header]
203
+ end
204
+
205
+ components << header if include_header
206
+ end
207
+
169
208
  components
170
209
  end
171
210
 
@@ -177,8 +216,8 @@ module HTTPSignature
177
216
  headers.merge("content-digest" => "sha-256=:#{Base64.strict_encode64(digest)}:")
178
217
  end
179
218
 
180
- def self.build_components(uri:, method:, headers:, covered_components:)
181
- covered_components.map do |component|
219
+ def self.build_components(uri:, method:, headers:, components:)
220
+ components.map do |component|
182
221
  if component.start_with?("@")
183
222
  [component, derived_component(component, uri, method)]
184
223
  else
@@ -219,13 +258,16 @@ module HTTPSignature
219
258
  label:,
220
259
  components:,
221
260
  created:,
261
+ expires:,
222
262
  key_id:,
223
263
  alg:,
224
264
  nonce:,
225
265
  canonical_components:
226
266
  )
227
267
  component_tokens = components.map { |c| %("#{escape_structured_string(c)}") }.join(" ")
228
- params = ["created=#{created}", %(keyid="#{escape_structured_string(key_id)}")]
268
+ params = ["created=#{created}"]
269
+ params << "expires=#{expires}" unless expires.nil?
270
+ params << %(keyid="#{escape_structured_string(key_id)}")
229
271
  params << %(alg="#{escape_structured_string(alg)}") if alg
230
272
  params << %(nonce="#{escape_structured_string(nonce)}") if nonce
231
273
 
@@ -332,6 +374,8 @@ module HTTPSignature
332
374
 
333
375
  encoded = match[1]
334
376
  Base64.strict_decode64(encoded)
377
+ rescue ArgumentError
378
+ raise SignatureError, "Invalid signature format"
335
379
  end
336
380
 
337
381
  def self.split_header(header)
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.0.0
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Joel Larsson
@@ -143,9 +143,11 @@ extensions: []
143
143
  extra_rdoc_files: []
144
144
  files:
145
145
  - ".github/workflows/ci.yml"
146
+ - ".github/workflows/push_gem.yml"
146
147
  - ".github/workflows/standardrb.yml"
147
148
  - ".gitignore"
148
149
  - ".ruby-version"
150
+ - AGENTS.md
149
151
  - Gemfile
150
152
  - Gemfile.lock
151
153
  - README.md
@@ -168,7 +170,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
168
170
  requirements:
169
171
  - - ">="
170
172
  - !ruby/object:Gem::Version
171
- version: 3.0.0
173
+ version: 3.1.0
172
174
  required_rubygems_version: !ruby/object:Gem::Requirement
173
175
  requirements:
174
176
  - - ">="