linzer 0.7.0.beta2 → 0.7.0.beta3

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: fd7b5e233ec42d94c7040f774097c612850eef9c5795a2c8d3251bc618c8df5f
4
- data.tar.gz: 53cc818e2fc68fe1f6b3c89414a5b0d2393e1b8cfa8e4c9f3ba9076ce537f81c
3
+ metadata.gz: 65ccb6f89aab47730e202a8ad376b2ceb310213b2a140d194b2b6fd285aa710f
4
+ data.tar.gz: 485efd259e353048041528c38f0072945592cfe6f8d4c4e6ea20af5546e6cb94
5
5
  SHA512:
6
- metadata.gz: 93e5fb0d4fd0cd534691102a02efa8ae14589d12ee3636f86d10d19e57104b1bb122f950c9191c5fb40264fc453a31d33aa1369fac0fe34451e74108ce9c6a5c
7
- data.tar.gz: e5b12b53e46f7958c560cb579111c14eab85da6a047effcdb599d39f955fe38b89175faef1937a963890c8d8254763c54314ea95c4eff70815fc0c0b8c396551
6
+ metadata.gz: a01a27867e6dd628365268f1106880b7f61ad80b4d314ea336e21e4f1a4edbb17394d0c7da4f23256566e34a6acb2e6eec54680461141d917189a37219d3c84e
7
+ data.tar.gz: 2efef6b1fcad53964e1f54c8f1453b2ad3aa481ca98960ada2e735439d0736624118a99ab58d1f73fdef517f9802e1453215e17bfc660e56dae81cd5b044769d
data/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.7.0.beta3] - 2025-05-06 (aka the ["MiniDebConf Hamburg 2025"](https://wiki.debian.org/DebianEvents/de/2025/MiniDebConfHamburg) release)
4
+
5
+ - Refactor to improve Linzer APIs and streamline its usage along with different
6
+ HTTP libraries.
7
+
3
8
  ## [0.7.0.beta2] - 2025-04-13
4
9
 
5
10
  - Refactor and improve Rack::Auth::Signature code organization.
data/README.md CHANGED
@@ -23,7 +23,7 @@ Or just `gem install linzer`.
23
23
 
24
24
  ### TL;DR: I just want to protect my application!!
25
25
 
26
- Add the following middleware to you run Rack application and configure it
26
+ Add the following middleware to your Rack application and configure it
27
27
  as needed, e.g.:
28
28
 
29
29
  ```ruby
@@ -58,46 +58,40 @@ look at
58
58
 
59
59
  To learn about more specific scenarios or use cases, keep reading on below.
60
60
 
61
- ### To sign a HTTP message:
61
+ ### To sign a HTTP request:
62
62
 
63
63
  ```ruby
64
64
  key = Linzer.generate_ed25519_key
65
65
  # => #<Linzer::Ed25519::Key:0x00000fe13e9bd208
66
66
 
67
- headers = {
68
- "date" => "Fri, 23 Feb 2024 17:57:23 GMT",
69
- "x-custom-header" => "foo"
70
- }
71
-
72
- request = Linzer.new_request(:post, "/some_uri", {}, headers)
73
- # => #<Rack::Request:0x0000000104c1c8c0
74
- # @env={"HTTP_DATE"=>"Fri, 23 Feb 2024 17:57:23 GMT", "HTTP_X_CUSTOM..."
75
- # @params=nil>
76
-
77
- message = Linzer::Message.new(request)
78
- # => #<Linzer::Message:0x0000000104afa960
79
- # @operation=#<Rack::Request:0x00000001049754a0
80
- # @env={"HTTP_DATE"=>"Fri, 23 Feb 2024 17:57:23 GMT", "HTTP_X_CUSTOM..."
81
- # @params=nil>>
82
-
83
- fields = %w[date x-custom-header @method @path]
84
-
85
- signature = Linzer.sign(key, message, fields)
86
- # => #<Linzer::Signature:0x0000000111f77ad0 ...
87
-
88
- pp signature.to_h
89
- # => {"signature"=>"sig1=:Cv1TUCxUpX+5SVa7pH0Xh...",
90
- # "signature-input"=>"sig1=(\"date\" \"x-custom-header\" ..."}
67
+ uri = URI("https://example.org/api/task")
68
+ request = Net::HTTP::Get.new(uri)
69
+ request["date"] = Time.now.to_s
70
+
71
+ Linzer.sign!(
72
+ request,
73
+ key: key,
74
+ components: %w[@method @request-target date],
75
+ label: "sig1",
76
+ params: {
77
+ created: Time.now.to_i
78
+ }
79
+ )
80
+
81
+ request["signature"]
82
+ # => "sig1=:Cv1TUCxUpX+5SVa7pH0Xh..."
83
+ request["signature-input"]
84
+ # => "sig1=(\"@method\" \"@request-target\" \"date\" ..."}
91
85
  ```
92
86
 
93
- ### Use the message signature with any HTTP client:
87
+ ### Use the signed request with an HTTP client:
94
88
 
95
89
  ```ruby
96
90
  require "net/http"
97
91
 
98
- http = Net::HTTP.new("localhost", 9292)
92
+ http = Net::HTTP.new(uri.host, uri.port)
99
93
  http.set_debug_output($stderr)
100
- response = http.post("/some_uri", "data", headers.merge(signature.to_h))
94
+ response = http.request(request)
101
95
  # opening connection to localhost:9292...
102
96
  # opened
103
97
  # <- "POST /some_uri HTTP/1.1\r\n
@@ -131,7 +125,123 @@ response = http.post("/some_uri", "data", headers.merge(signature.to_h))
131
125
  # => #<Net::HTTPOK 200 OK readbody=true>
132
126
  ```
133
127
 
134
- ### To verify a valid signature:
128
+ ### To verify an incoming request on the server side:
129
+
130
+ The middleware `Rack::Auth::Signature` can be used for this scenario
131
+ [as shown above](#tldr-i-just-want-to-protect-my-application).
132
+
133
+ Or directly in the application controller (or routes), the incoming request can
134
+ be verified with the following approach:
135
+
136
+ ```ruby
137
+ post "/foo" do
138
+ request
139
+ # =>
140
+ # #<Sinatra::Request:0x000000011e5a5d60
141
+ # @env=
142
+ # {"GATEWAY_INTERFACE" => "CGI/1.1",
143
+ # "PATH_INFO" => "/api",
144
+ # ...
145
+
146
+ result = Linzer.verify!(request, key: some_client_key)
147
+ # => true
148
+ ...
149
+ end
150
+ ```
151
+
152
+ If the signature is missing or invalid, the verification method will raise an
153
+ exception with a message clarifying why the request signature failed verification.
154
+
155
+ Also, for additional flexibility on the server side, the method above can take
156
+ a block with the `keyid` parameter extracted from the signature (if any) as argument.
157
+ This can be useful to retrieve key data from databases/caches on the server side, e.g.:
158
+
159
+ ```ruby
160
+ get "/bar" do
161
+ ...
162
+ result = Linzer.verify!(request) do |keyid|
163
+ retrieve_pubkey_from_db(db_client, keyid)
164
+ end
165
+ # => true
166
+ ...
167
+ end
168
+ ```
169
+
170
+ ### To verify a received response on the client side:
171
+
172
+ It's similar to verifying requests, the same method is used, see example below:
173
+
174
+ ```ruby
175
+ response
176
+ # => #<Net::HTTPOK 200 OK readbody=true>
177
+ response.body
178
+ # => "protected"
179
+ pubkey = Linzer.new_ed25519_key(IO.read("pubkey.pem"))
180
+ result = Linzer.verify!(response, key: pubkey, no_older_than: 600)
181
+ # => true
182
+ ```
183
+
184
+ ### To sign an outgoing response on the server side:
185
+
186
+ Again, the same principle used to sign outgoing requests, the same method is used,
187
+ see example below:
188
+
189
+ ```ruby
190
+ put "/baz" do
191
+ ...
192
+ response
193
+ # => #<Sinatra::Response:0x0000000109ac40b8 ...
194
+ response.headers["x-custom-app-header"] = "..."
195
+ Linzer.sign!(response,
196
+ key: my_key,
197
+ components: %w[@status content-type content-digest x-custom-app-header],
198
+ label: "sig1",
199
+ params: {
200
+ created: Time.now.to_i
201
+ }
202
+ )
203
+ response["signature"]
204
+ # => "sig1=:2TPCzD4l48bg6LMcVXdV9u..."
205
+ response["signature-input"]
206
+ # => "sig1=(\"@status\" \"content-type\" \"content-digest\"..."
207
+ ...
208
+ end
209
+ ```
210
+
211
+ ### What do you do if you want to sign/verify requests and responses with your preferred HTTP ruby library/framework (not using Rack or `Net::HTTP`, for example)?
212
+
213
+ You can provide an adapter class and then register it with this library.
214
+ For guidance on how to implement such adapters, you can consult an
215
+ [example adapter for http gem response](https://github.com/nomadium/linzer/blob/master/lib/linzer/message/adapter/http_gem/response.rb)
216
+ included with this gem or the ones
217
+ [provided out of the box](https://github.com/nomadium/linzer/blob/master/lib/linzer/message/adapter).
218
+
219
+ For how to register a custom adapter and how to verify signatures in a response,
220
+ see this example:
221
+
222
+ ```ruby
223
+ Linzer::Message.register_adapter(HTTP::Response, MyOwnResponseAdapter)
224
+ response = HTTP.get("http://www.example.com/api/service/task")
225
+ # => #<HTTP::Response/1.1 200 OK ...
226
+ response["signature"]
227
+ => "sig1=:oqzDlQmfejfT..."
228
+ response["signature-input"]
229
+ => "sig1=(\"@status\" \"foo\");created=1746480237"
230
+ result = Linzer.verify!(response, key: my_key)
231
+ # => true
232
+ ```
233
+ ---
234
+
235
+ Furthermore, on some low-level scenarios where a user wants or needs additional
236
+ control on how the signing and verification routines are performed, Linzer allows
237
+ to manipulate instances of internal HTTP messages (requests & responses, see
238
+ `Linzer::Message` class and available adapters), signature objects
239
+ (`Linzer::Signature`) and how to register additional message adapters for any
240
+ HTTP ruby library not supported out of the box by this gem.
241
+
242
+ See below for a few examples of these scenarios.
243
+
244
+ #### To verify a valid signature:
135
245
 
136
246
  ```ruby
137
247
  test_ed25519_key_pub = key.material.public_to_pem
@@ -140,12 +250,6 @@ test_ed25519_key_pub = key.material.public_to_pem
140
250
  pubkey = Linzer.new_ed25519_public_key(test_ed25519_key_pub, "some-key-ed25519")
141
251
  # => #<Linzer::Ed25519::Key:0x00000fe19b9384b0
142
252
 
143
- # if you have to, there is a helper method to build a request object on the server side
144
- # although any standard Ruby web server or framework (Sinatra, Rails, etc) should expose
145
- # a request object and this should not be required for most cases.
146
- #
147
- # request = Linzer.new_request(:post, "/some_uri", {}, headers)
148
-
149
253
  message = Linzer::Message.new(request)
150
254
 
151
255
  signature = Linzer::Signature.build(message.headers)
@@ -163,14 +267,14 @@ Linzer.verify(pubkey, message, signature, no_older_than: 500)
163
267
  `no_older_than` expects a number of seconds, but you can pass anything that to responds to `#to_i`, including an `ActiveSupport::Duration`.
164
268
  `::verify` will raise if the `created` parameter of the signature is older than the given number of seconds.
165
269
 
166
- ### What if an invalid signature if verified?
270
+ #### What if an invalid signature if verified?
167
271
 
168
272
  ```ruby
169
273
  result = Linzer.verify(pubkey, message, signature)
170
274
  lib/linzer/verifier.rb:38:in `verify_or_fail': Failed to verify message: Invalid signature. (Linzer::Error)
171
275
  ```
172
276
 
173
- ### HTTP responses are also supported
277
+ #### HTTP responses are also supported
174
278
 
175
279
  HTTP responses can also be signed and verified in the same way as requests.
176
280
 
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Linzer
4
+ class Message
5
+ module Adapter
6
+ class Abstract
7
+ def initialize(operation, **options)
8
+ raise Linzer::Error, "Cannot instantiate an abstract class!"
9
+ end
10
+
11
+ def request?
12
+ self.class.to_s.include?("Request")
13
+ end
14
+
15
+ def response?
16
+ self.class.to_s.include?("Response")
17
+ end
18
+
19
+ # XXX: attached request as specified in RFC has to be tested for Net::HTTP classes
20
+ # and custom HTTP message classes
21
+ def attached_request?
22
+ response? && !!@attached_request
23
+ end
24
+
25
+ def field?(f)
26
+ !!self[f]
27
+ end
28
+
29
+ def [](field_name)
30
+ name = parse_field_name(field_name)
31
+ return nil if name.nil?
32
+
33
+ if field_name.start_with?("@")
34
+ retrieve(name, :derived)
35
+ else
36
+ retrieve(name, :field)
37
+ end
38
+ end
39
+
40
+ def headers
41
+ raise Linzer::Error, "Sub-classes are required to implement this method!"
42
+ end
43
+
44
+ def attach!(signature)
45
+ raise Linzer::Error, "Sub-classes are required to implement this method!"
46
+ end
47
+
48
+ private
49
+
50
+ def parse_field_name(field_name)
51
+ if field_name&.start_with?("@")
52
+ Starry.parse_item(field_name[1..])
53
+ else
54
+ Starry.parse_item(field_name)
55
+ end
56
+ rescue => _
57
+ nil
58
+ end
59
+
60
+ def validate_attached_request(message)
61
+ msg = "The attached message is not a valid HTTP request!"
62
+ raise Linzer::Error, msg unless message.request?
63
+ end
64
+
65
+ def validate_parameters(name, method)
66
+ has_unknown = name.parameters.any? { |p, _| !KNOWN_PARAMETERS.include?(p) }
67
+ return nil if has_unknown
68
+
69
+ has_name = name.parameters["name"]
70
+ has_req = name.parameters["req"]
71
+ has_sf = name.parameters["sf"] || name.parameters.key?("key")
72
+ has_bs = name.parameters["bs"]
73
+ value = name.value
74
+
75
+ # Section 2.2.8 of RFC 9421
76
+ return nil if has_name && value != :"query-param"
77
+
78
+ # No derived values come from trailers section
79
+ return nil if method == :derived && name.parameters["tr"]
80
+
81
+ # From: 2.1. HTTP Fields:
82
+ # The bs parameter, which requires the raw bytes of the field values
83
+ # from the message, is not compatible with the use of the sf or key
84
+ # parameters, which require the parsed data structures of the field
85
+ # values after combination
86
+ return nil if has_sf && has_bs
87
+
88
+ # req param only makes sense on responses with an associated request
89
+ # return nil if has_req && (!response? || !attached_request?)
90
+ return nil if has_req && !response?
91
+
92
+ name
93
+ end
94
+
95
+ KNOWN_PARAMETERS = %w[sf key bs req tr name]
96
+ private_constant :KNOWN_PARAMETERS
97
+
98
+ def retrieve(name, method)
99
+ if !name.parameters.empty?
100
+ valid_params = validate_parameters(name, method)
101
+ return nil if !valid_params
102
+ end
103
+
104
+ has_req = name.parameters["req"]
105
+ has_sf = name.parameters["sf"] || name.parameters.key?("key")
106
+ has_bs = name.parameters["bs"]
107
+
108
+ if has_req
109
+ name.parameters.delete("req")
110
+ return req(name, method)
111
+ end
112
+
113
+ value = send(method, name)
114
+
115
+ case
116
+ when has_sf
117
+ key = name.parameters["key"]
118
+ sf(value, key)
119
+ when has_bs then bs(value)
120
+ else value
121
+ end
122
+ end
123
+
124
+ def sf(value, key = nil)
125
+ dict = Starry.parse_dictionary(value)
126
+
127
+ if key
128
+ obj = dict[key]
129
+ Starry.serialize(obj.is_a?(Starry::InnerList) ? [obj] : obj)
130
+ else
131
+ Starry.serialize(dict)
132
+ end
133
+ end
134
+
135
+ def bs(value)
136
+ Starry.serialize(value.encode(Encoding::ASCII_8BIT))
137
+ end
138
+
139
+ def tr(trailer)
140
+ @operation.body.trailers[trailer.value.to_s]
141
+ end
142
+
143
+ def req(field, method)
144
+ case method
145
+ when :derived then @attached_request["@#{field}"]
146
+ when :field then @attached_request[field.to_s]
147
+ end
148
+ end
149
+ end
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Example HTTP message adapter for HTTP::Response class from http ruby gem.
4
+ # https://github.com/httprb/http
5
+ # It's not required automatically to avoid making http gem a dependency.
6
+ #
7
+ module Linzer
8
+ class Message
9
+ module Adapter
10
+ module HTTPGem
11
+ class Response < Abstract
12
+ def initialize(operation, **options)
13
+ @operation = operation
14
+ freeze
15
+ end
16
+
17
+ def headers
18
+ @operation.headers
19
+ end
20
+
21
+ # XXX: this implementation is incomplete, e.g.: ;tr parameter is not supported yet
22
+ def [](field_name)
23
+ return @operation.code if field_name == "@status"
24
+ @operation[field_name]
25
+ end
26
+
27
+ def attach!(signature)
28
+ signature.to_h.each { |h, v| @operation[h] = v }
29
+ @operation
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+
5
+ module Linzer
6
+ class Message
7
+ module Adapter
8
+ module NetHTTP
9
+ class Request < Abstract
10
+ def initialize(operation, **options)
11
+ @operation = operation
12
+ freeze
13
+ end
14
+
15
+ def headers
16
+ @operation.each_header.to_h
17
+ end
18
+
19
+ def attach!(signature)
20
+ signature.to_h.each { |h, v| @operation[h] = v }
21
+ @operation
22
+ end
23
+
24
+ private
25
+
26
+ def derived(name)
27
+ case name.value
28
+ when :method then @operation.method
29
+ when :"target-uri" then @operation.uri.to_s
30
+ when :authority then @operation.uri.authority.downcase
31
+ when :scheme then @operation.uri.scheme.downcase
32
+ when :"request-target" then @operation.uri.request_uri
33
+ when :path then @operation.uri.path
34
+ when :query then "?%s" % String(@operation.uri.query)
35
+ when :"query-param" then query_param(name)
36
+ end
37
+ end
38
+
39
+ def query_param(name)
40
+ param_name = name.parameters["name"]
41
+ return nil if !param_name
42
+ decoded_param_name = URI.decode_uri_component(param_name)
43
+ params = CGI.parse(@operation.uri.query)
44
+ URI.encode_uri_component(params[decoded_param_name]&.first)
45
+ end
46
+
47
+ def field(name)
48
+ has_tr = name.parameters["tr"]
49
+ return nil if has_tr # HTTP requests don't have trailer fields
50
+ value = @operation[name.value.to_s]
51
+ value.dup&.strip
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Linzer
4
+ class Message
5
+ module Adapter
6
+ module NetHTTP
7
+ class Response < Abstract
8
+ def initialize(operation, **options)
9
+ @operation = operation
10
+ attached_request = options[:attached_request]
11
+ @attached_request = attached_request ? Message.new(attached_request) : nil
12
+ validate_attached_request @attached_request if @attached_request
13
+ freeze
14
+ end
15
+
16
+ def headers
17
+ @operation.each_header.to_h
18
+ end
19
+
20
+ # XXX: this implementation is incomplete, e.g.: ;tr parameter is not supported yet
21
+ def [](field_name)
22
+ return @operation.code.to_i if field_name == "@status"
23
+ @operation[field_name]
24
+ end
25
+
26
+ def attach!(signature)
27
+ signature.to_h.each { |h, v| @operation[h] = v }
28
+ @operation
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Linzer
4
+ class Message
5
+ module Adapter
6
+ module Rack
7
+ module Common
8
+ DERIVED_COMPONENT = {
9
+ method: :request_method,
10
+ authority: :authority,
11
+ path: :path_info,
12
+ status: :status,
13
+ "target-uri": :url,
14
+ scheme: :scheme,
15
+ "request-target": :fullpath,
16
+ query: :query_string
17
+ }.freeze
18
+ private_constant :DERIVED_COMPONENT
19
+
20
+ private
21
+
22
+ def validate
23
+ msg = "Message instance must be an HTTP request or response"
24
+ raise Error.new msg if response? == request?
25
+ end
26
+
27
+ def validate_header_name(name)
28
+ raise ArgumentError.new, "Blank header name." if name.empty?
29
+ name.to_str
30
+ rescue => ex
31
+ err_msg = "Invalid header name: '#{name}'"
32
+ raise Linzer::Error.new, err_msg, cause: ex
33
+ end
34
+
35
+ def rack_header_name(field_name)
36
+ validate_header_name field_name
37
+
38
+ rack_name = field_name.upcase.tr("-", "_")
39
+ case field_name.downcase
40
+ when "content-type", "content-length"
41
+ rack_name
42
+ else
43
+ "HTTP_#{rack_name}"
44
+ end
45
+ end
46
+
47
+ def rack_request_headers(rack_request)
48
+ rack_request
49
+ .each_header
50
+ .to_h
51
+ .select do |k, _|
52
+ k.start_with?("HTTP_") || %w[CONTENT_TYPE CONTENT_LENGTH].include?(k)
53
+ end
54
+ .transform_keys { |k| k.downcase.tr("_", "-") }
55
+ .transform_keys do |k|
56
+ %w[content-type content-length].include?(k) ? k : k.gsub(/^http-/, "")
57
+ end
58
+ end
59
+
60
+ def derived(name)
61
+ method = DERIVED_COMPONENT[name.value]
62
+
63
+ value = case name.value
64
+ when :query then derive(@operation, method)
65
+ when :"query-param" then query_param(name)
66
+ end
67
+
68
+ return nil if !method && !value
69
+ value || derive(@operation, method)
70
+ end
71
+
72
+ def field(name)
73
+ has_tr = name.parameters["tr"]
74
+ if has_tr
75
+ value = tr(name)
76
+ else
77
+ if request?
78
+ rack_header_name = rack_header_name(name.value.to_s)
79
+ value = @operation.env[rack_header_name]
80
+ end
81
+ value = @operation.headers[name.value.to_s] if response?
82
+ end
83
+ value.dup&.strip
84
+ end
85
+
86
+ def derive(operation, method)
87
+ return nil unless operation.respond_to?(method)
88
+ value = operation.public_send(method)
89
+ return "?" + value if method == :query_string
90
+ return value.downcase if %i[authority scheme].include?(method)
91
+ value
92
+ end
93
+
94
+ def query_param(name)
95
+ param_name = name.parameters["name"]
96
+ return nil if !param_name
97
+ decoded_param_name = URI.decode_uri_component(param_name)
98
+ URI.encode_uri_component(@operation.params.fetch(decoded_param_name))
99
+ rescue => _
100
+ nil
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Linzer
4
+ class Message
5
+ module Adapter
6
+ module Rack
7
+ class Request < Abstract
8
+ include Common
9
+
10
+ def initialize(operation, **options)
11
+ @operation = operation
12
+ validate
13
+ freeze
14
+ end
15
+
16
+ def headers
17
+ rack_request_headers(@operation)
18
+ end
19
+
20
+ def attach!(signature)
21
+ signature.to_h.each do |h, v|
22
+ @operation.set_header(rack_header_name(h), v)
23
+ end
24
+ @operation
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Linzer
4
+ class Message
5
+ module Adapter
6
+ module Rack
7
+ class Response < Abstract
8
+ include Common
9
+
10
+ def initialize(operation, **options)
11
+ @operation = operation
12
+ validate
13
+ attached_request = options[:attached_request]
14
+ @attached_request = attached_request ? Message.new(attached_request) : nil
15
+ validate_attached_request @attached_request if @attached_request
16
+ freeze
17
+ end
18
+
19
+ def headers
20
+ @operation.headers
21
+ end
22
+
23
+ def attach!(signature)
24
+ signature.to_h.each { |h, v| @operation[h] = v }
25
+ @operation
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "adapter/abstract"
4
+ require_relative "adapter/rack/common"
5
+ require_relative "adapter/rack/request"
6
+ require_relative "adapter/rack/response"
7
+ require_relative "adapter/net_http/request"
8
+ require_relative "adapter/net_http/response"
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Linzer
4
+ class Message
5
+ module Wrapper
6
+ @adapters = {
7
+ Rack::Request => Linzer::Message::Adapter::Rack::Request,
8
+ Rack::Response => Linzer::Message::Adapter::Rack::Response,
9
+ Net::HTTPRequest => Linzer::Message::Adapter::NetHTTP::Request,
10
+ Net::HTTPResponse => Linzer::Message::Adapter::NetHTTP::Response
11
+ }
12
+
13
+ class << self
14
+ def wrap(operation, **options)
15
+ adapter_class = adapters[operation.class]
16
+
17
+ if !adapter_class
18
+ ancestor = find_ancestor(operation)
19
+ fail_with_unsupported(operation) unless ancestor
20
+ end
21
+
22
+ (adapter_class || ancestor).new(operation, **options)
23
+ end
24
+
25
+ def register_adapter(operation_class, adapter_class)
26
+ adapters[operation_class] = adapter_class
27
+ end
28
+
29
+ private
30
+
31
+ attr_reader :adapters
32
+
33
+ def find_ancestor(operation)
34
+ adapters
35
+ .select { |klz, adpt| operation.is_a? klz }
36
+ .values
37
+ .first
38
+ end
39
+
40
+ def fail_with_unsupported(operation)
41
+ err_msg = <<~EOM
42
+ Unknown/unsupported HTTP message class: '#{operation.class}'!
43
+
44
+ Linzer supports custom HTTP messages implementation by register them first
45
+ with `Linzer::Message.register_adapter` method.
46
+ EOM
47
+ raise Linzer::Error, err_msg
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -1,207 +1,28 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "forwardable"
4
+
3
5
  module Linzer
4
6
  class Message
7
+ extend Forwardable
8
+
5
9
  def initialize(operation, attached_request: nil)
6
- @operation = operation
7
- validate
8
- @attached_request = attached_request ? Message.new(attached_request) : nil
10
+ @adapter = Wrapper.wrap(operation, attached_request: attached_request)
9
11
  freeze
10
12
  end
11
13
 
12
- def request?
13
- @operation.is_a?(Rack::Request) || @operation.respond_to?(:request_method)
14
- end
15
-
16
- def response?
17
- @operation.is_a?(Rack::Response) || @operation.respond_to?(:status)
18
- end
19
-
20
- def attached_request?
21
- !!@attached_request
22
- end
23
-
24
- def headers
25
- return @operation.headers if response? || @operation.respond_to?(:headers)
14
+ # common predicates
15
+ def_delegators :@adapter, :request?, :response?, :attached_request?
26
16
 
27
- Request.headers(@operation)
28
- end
29
-
30
- def field?(f)
31
- !!self[f]
32
- end
33
-
34
- DERIVED_COMPONENT = {
35
- method: :request_method,
36
- authority: :authority,
37
- path: :path_info,
38
- status: :status,
39
- "target-uri": :url,
40
- scheme: :scheme,
41
- "request-target": :fullpath,
42
- query: :query_string
43
- }.freeze
17
+ # fields look up
18
+ def_delegators :@adapter, :headers, :field?, :[]
44
19
 
45
- def [](field_name)
46
- name = parse_field_name(field_name)
47
- return nil if name.nil?
48
-
49
- if field_name.start_with?("@")
50
- retrieve(name, :derived)
51
- else
52
- retrieve(name, :field)
53
- end
54
- end
20
+ # to attach a signature to the underlying HTTP message
21
+ def_delegators :@adapter, :attach!
55
22
 
56
23
  class << self
57
- def parse_structured_dictionary(str, field_name = nil)
58
- Starry.parse_dictionary(str)
59
- rescue Starry::ParseError => _
60
- raise Error.new "Cannot parse \"#{field_name}\" field. Bailing out!"
61
- end
62
- end
63
-
64
- private
65
-
66
- def validate
67
- msg = "Message instance must be an HTTP request or response"
68
- raise Error.new msg if response? == request?
69
- end
70
-
71
- def parse_field_name(field_name)
72
- if field_name&.start_with?("@")
73
- Starry.parse_item(field_name[1..])
74
- else
75
- Starry.parse_item(field_name)
76
- end
77
- rescue => _
78
- nil
79
- end
80
-
81
- def validate_parameters(name, method)
82
- has_unknown = name.parameters.any? { |p, _| !KNOWN_PARAMETERS.include?(p) }
83
- return nil if has_unknown
84
-
85
- has_name = name.parameters["name"]
86
- has_req = name.parameters["req"]
87
- has_sf = name.parameters["sf"] || name.parameters.key?("key")
88
- has_bs = name.parameters["bs"]
89
- value = name.value
90
-
91
- # Section 2.2.8 of RFC 9421
92
- return nil if has_name && value != :"query-param"
93
-
94
- # No derived values come from trailers section
95
- return nil if method == :derived && name.parameters["tr"]
96
-
97
- # From: 2.1. HTTP Fields:
98
- # The bs parameter, which requires the raw bytes of the field values
99
- # from the message, is not compatible with the use of the sf or key
100
- # parameters, which require the parsed data structures of the field
101
- # values after combination
102
- return nil if has_sf && has_bs
103
-
104
- # req param only makes sense on responses with an associated request
105
- return nil if has_req && (!response? || !attached_request?)
106
-
107
- name
108
- end
109
-
110
- KNOWN_PARAMETERS = %w[sf key bs req tr name]
111
- private_constant :KNOWN_PARAMETERS
112
-
113
- def retrieve(name, method)
114
- if !name.parameters.empty?
115
- valid_params = validate_parameters(name, method)
116
- return nil if !valid_params
117
- end
118
-
119
- has_req = name.parameters["req"]
120
- has_sf = name.parameters["sf"] || name.parameters.key?("key")
121
- has_bs = name.parameters["bs"]
122
-
123
- if has_req
124
- name.parameters.delete("req")
125
- return req(name, method)
126
- end
127
-
128
- value = send(method, name)
129
-
130
- case
131
- when has_sf
132
- key = name.parameters["key"]
133
- sf(value, key)
134
- when has_bs then bs(value)
135
- else value
136
- end
137
- end
138
-
139
- def derived(name)
140
- method = DERIVED_COMPONENT[name.value]
141
-
142
- value = case name.value
143
- when :query then derive(@operation, method)
144
- when :"query-param" then query_param(name)
145
- end
146
-
147
- return nil if !method && !value
148
- value || derive(@operation, method)
149
- end
150
-
151
- def field(name)
152
- has_tr = name.parameters["tr"]
153
- if has_tr
154
- value = tr(name)
155
- else
156
- if request?
157
- rack_header_name = Request.rack_header_name(name.value.to_s)
158
- value = @operation.env[rack_header_name]
159
- end
160
- value = @operation.headers[name.value.to_s] if response?
161
- end
162
- value.dup&.strip
163
- end
164
-
165
- def derive(operation, method)
166
- return nil unless operation.respond_to?(method)
167
- value = operation.public_send(method)
168
- return "?" + value if method == :query_string
169
- return value.downcase if %i[authority scheme].include?(method)
170
- value
171
- end
172
-
173
- def query_param(name)
174
- param_name = name.parameters["name"]
175
- return nil if !param_name
176
- decoded_param_name = URI.decode_uri_component(param_name)
177
- URI.encode_uri_component(@operation.params.fetch(decoded_param_name))
178
- rescue => _
179
- nil
180
- end
181
-
182
- def sf(value, key = nil)
183
- dict = Starry.parse_dictionary(value)
184
-
185
- if key
186
- obj = dict[key]
187
- Starry.serialize(obj.is_a?(Starry::InnerList) ? [obj] : obj)
188
- else
189
- Starry.serialize(dict)
190
- end
191
- end
192
-
193
- def bs(value)
194
- Starry.serialize(value.encode(Encoding::ASCII_8BIT))
195
- end
196
-
197
- def tr(trailer)
198
- @operation.body.trailers[trailer.value.to_s]
199
- end
200
-
201
- def req(field, method)
202
- case method
203
- when :derived then @attached_request["@#{field}"]
204
- when :field then @attached_request[field.to_s]
24
+ def register_adapter(operation_class, adapter_class)
25
+ Wrapper.register_adapter(operation_class, adapter_class)
205
26
  end
206
27
  end
207
28
  end
@@ -86,11 +86,17 @@ module Linzer
86
86
  raise Error.new "Unexpected value for covered components."
87
87
  end
88
88
 
89
+ def parse_structured_dictionary(str, field_name = nil)
90
+ Starry.parse_dictionary(str)
91
+ rescue Starry::ParseError => _
92
+ raise Error.new "Cannot parse \"#{field_name}\" field. Bailing out!"
93
+ end
94
+
89
95
  def parse_structured_field(hsh, field_name)
90
96
  # Serialized Structured Field values for HTTP are ASCII strings.
91
97
  # See: RFC 8941 (https://datatracker.ietf.org/doc/html/rfc8941)
92
98
  value = hsh[field_name].encode(Encoding::US_ASCII)
93
- Message.parse_structured_dictionary(value, field_name)
99
+ parse_structured_dictionary(value, field_name)
94
100
  end
95
101
  end
96
102
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Linzer
4
- VERSION = "0.7.0.beta2"
4
+ VERSION = "0.7.0.beta3"
5
5
  end
data/lib/linzer.rb CHANGED
@@ -5,12 +5,13 @@ require "openssl"
5
5
  require "rack"
6
6
  require "uri"
7
7
  require "stringio"
8
+ require "net/http"
8
9
 
9
10
  require_relative "linzer/version"
10
11
  require_relative "linzer/common"
11
- require_relative "linzer/request"
12
- require_relative "linzer/response"
13
12
  require_relative "linzer/message"
13
+ require_relative "linzer/message/adapter"
14
+ require_relative "linzer/message/wrapper"
14
15
  require_relative "linzer/signature"
15
16
  require_relative "linzer/key"
16
17
  require_relative "linzer/rsa"
@@ -28,11 +29,6 @@ module Linzer
28
29
 
29
30
  class << self
30
31
  include Key::Helper
31
- include Response
32
-
33
- def new_request(verb, uri = "/", params = {}, headers = {})
34
- Linzer::Request.build(verb, uri, params, headers)
35
- end
36
32
 
37
33
  def verify(pubkey, message, signature, no_older_than: nil)
38
34
  Linzer::Verifier.verify(pubkey, message, signature, no_older_than: no_older_than)
@@ -41,5 +37,27 @@ module Linzer
41
37
  def sign(key, message, components, options = {})
42
38
  Linzer::Signer.sign(key, message, components, options)
43
39
  end
40
+
41
+ def sign!(request_or_response, **args)
42
+ message = Message.new(request_or_response)
43
+ options = {}
44
+
45
+ label = args[:label]
46
+ options[:label] = label if label
47
+ options.merge!(args.fetch(:params, {}))
48
+
49
+ key = args.fetch(:key)
50
+ signature = Linzer::Signer.sign(key, message, args.fetch(:components), options)
51
+ message.attach!(signature)
52
+ end
53
+
54
+ def verify!(request_or_response, key: nil, no_older_than: 900)
55
+ message = Message.new(request_or_response)
56
+ signature = Signature.build(message.headers.slice("signature", "signature-input"))
57
+ keyid = signature.parameters["keyid"]
58
+ raise Linzer::Error, "key not found" if !key && !keyid
59
+ verify_key = block_given? ? (yield keyid) : key
60
+ Linzer.verify(verify_key, message, signature, no_older_than: no_older_than)
61
+ end
44
62
  end
45
63
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: linzer
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0.beta2
4
+ version: 0.7.0.beta3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Miguel Landaeta
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
10
+ date: 2025-05-06 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: openssl
@@ -123,6 +123,40 @@ dependencies:
123
123
  - - ">="
124
124
  - !ruby/object:Gem::Version
125
125
  version: 1.7.0
126
+ - !ruby/object:Gem::Dependency
127
+ name: forwardable
128
+ requirement: !ruby/object:Gem::Requirement
129
+ requirements:
130
+ - - "~>"
131
+ - !ruby/object:Gem::Version
132
+ version: '1.3'
133
+ - - ">="
134
+ - !ruby/object:Gem::Version
135
+ version: 1.3.3
136
+ type: :runtime
137
+ prerelease: false
138
+ version_requirements: !ruby/object:Gem::Requirement
139
+ requirements:
140
+ - - "~>"
141
+ - !ruby/object:Gem::Version
142
+ version: '1.3'
143
+ - - ">="
144
+ - !ruby/object:Gem::Version
145
+ version: 1.3.3
146
+ - !ruby/object:Gem::Dependency
147
+ name: net-http
148
+ requirement: !ruby/object:Gem::Requirement
149
+ requirements:
150
+ - - "~>"
151
+ - !ruby/object:Gem::Version
152
+ version: 0.6.0
153
+ type: :runtime
154
+ prerelease: false
155
+ version_requirements: !ruby/object:Gem::Requirement
156
+ requirements:
157
+ - - "~>"
158
+ - !ruby/object:Gem::Version
159
+ version: 0.6.0
126
160
  email:
127
161
  - miguel@miguel.cc
128
162
  executables: []
@@ -148,8 +182,15 @@ files:
148
182
  - lib/linzer/key.rb
149
183
  - lib/linzer/key/helper.rb
150
184
  - lib/linzer/message.rb
151
- - lib/linzer/request.rb
152
- - lib/linzer/response.rb
185
+ - lib/linzer/message/adapter.rb
186
+ - lib/linzer/message/adapter/abstract.rb
187
+ - lib/linzer/message/adapter/http_gem/response.rb
188
+ - lib/linzer/message/adapter/net_http/request.rb
189
+ - lib/linzer/message/adapter/net_http/response.rb
190
+ - lib/linzer/message/adapter/rack/common.rb
191
+ - lib/linzer/message/adapter/rack/request.rb
192
+ - lib/linzer/message/adapter/rack/response.rb
193
+ - lib/linzer/message/wrapper.rb
153
194
  - lib/linzer/rsa.rb
154
195
  - lib/linzer/rsa_pss.rb
155
196
  - lib/linzer/signature.rb
@@ -179,7 +220,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
179
220
  - !ruby/object:Gem::Version
180
221
  version: '0'
181
222
  requirements: []
182
- rubygems_version: 3.6.7
223
+ rubygems_version: 3.6.2
183
224
  specification_version: 4
184
225
  summary: An implementation of HTTP Messages Signatures (RFC9421)
185
226
  test_files: []
@@ -1,95 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Linzer
4
- module Request
5
- extend self
6
-
7
- def build(verb, uri = "/", params = {}, headers = {})
8
- validate verb, uri, params, headers
9
-
10
- # XXX: to-do: handle rack request params?
11
- request_method = Rack.const_get(verb.upcase)
12
- args = {
13
- "REQUEST_METHOD" => request_method,
14
- "PATH_INFO" => uri.to_str,
15
- "rack.input" => StringIO.new
16
- }
17
-
18
- Rack::Request.new(build_rack_env(headers).merge(args))
19
- end
20
-
21
- def rack_header_name(field_name)
22
- validate_header_name field_name
23
-
24
- rack_name = field_name.upcase.tr("-", "_")
25
- case field_name.downcase
26
- when "content-type", "content-length"
27
- rack_name
28
- else
29
- "HTTP_#{rack_name}"
30
- end
31
- end
32
-
33
- def headers(rack_request)
34
- rack_request
35
- .each_header
36
- .to_h
37
- .select do |k, _|
38
- k.start_with?("HTTP_") || %w[CONTENT_TYPE CONTENT_LENGTH].include?(k)
39
- end
40
- .transform_keys { |k| k.downcase.tr("_", "-") }
41
- .transform_keys do |k|
42
- %w[content-type content-length].include?(k) ? k : k.gsub(/^http-/, "")
43
- end
44
- end
45
-
46
- private
47
-
48
- def validate(verb, uri, params, headers)
49
- validate_verb verb
50
- validate_uri uri
51
- validate_arg_hash headers: headers
52
- validate_arg_hash params: params
53
- end
54
-
55
- def validate_verb(verb)
56
- Rack.const_get(verb.upcase)
57
- rescue => ex
58
- unknown_method = "Unknown/invalid HTTP request method"
59
- raise Error.new, unknown_method, cause: ex
60
- end
61
-
62
- def validate_uri(uri)
63
- uri.to_str
64
- rescue => ex
65
- invalid_uri = "Invalid URI"
66
- raise Error.new, invalid_uri, cause: ex
67
- end
68
-
69
- def validate_arg_hash(hsh)
70
- arg_name = hsh.keys.first
71
- hsh[arg_name].to_hash
72
- rescue => ex
73
- err_msg = "invalid \"#{arg_name}\" parameter, cannot be converted to hash."
74
- raise Error.new, "Cannot build request: #{err_msg}", cause: ex
75
- end
76
-
77
- def validate_header_name(name)
78
- raise ArgumentError.new, "Blank header name." if name.empty?
79
- name.to_str
80
- rescue => ex
81
- err_msg = "Invalid header name: '#{name}'"
82
- raise Error.new, err_msg, cause: ex
83
- end
84
-
85
- def build_rack_env(headers)
86
- headers
87
- .to_hash
88
- .transform_values(&:to_s)
89
- .transform_keys { |k| k.upcase.tr("-", "_") }
90
- .transform_keys do |k|
91
- %w[CONTENT_TYPE CONTENT_LENGTH].include?(k) ? k : "HTTP_#{k}"
92
- end
93
- end
94
- end
95
- end
@@ -1,9 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Linzer
4
- module Response
5
- def new_response(body = nil, status = 200, headers = {})
6
- Rack::Response.new(body, status, headers.transform_values(&:to_s))
7
- end
8
- end
9
- end