linzer 0.4.1 → 0.5.1

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: e05d05474d794c882d28a6fb83949147497218f141ae1e40e6c07e750407b421
4
- data.tar.gz: 58b26e247acd0ca13e9029ace69ebb7459f1639b7908733e07ac88566ac255d1
3
+ metadata.gz: 1955734eb1a8115753448b579b0cbe84f7d1ad247677ae20dd49495841b4aef3
4
+ data.tar.gz: abf9b2fbc465f10210c77f3d29e10c7e15af26a5badba90516f0b96050e3a548
5
5
  SHA512:
6
- metadata.gz: 3af97f2888d5c4bd40900c490945590077604b93c954819c9308d2ab8fe767c491a08d2cbe5054aaced580a4da568a1d1cf11f82048048532feb5150dd59dfea
7
- data.tar.gz: b4a47fd541623baef9582cc0b361975b504cc7824fd926f47f56f7f692f6a6d8dec0d8e4afdb5e64967d6e1d7a2107656165b95f383af5e902bddd26f0efe387
6
+ metadata.gz: 5263ec042dd91dc5bc434f4bbccc8f17d0ec52c751b7b9adec613c87efa180175cc7e5b4f47da007e6e61298a2b153f9ca05250ba3d55786c2d5609725233903
7
+ data.tar.gz: 45ad9720b910f441bf36f9766ec10eaaab64fb7a36f420b947a5170d9cae0d18d540f380996eba2265b6faa0df0b9537fb1c8b2b2e58c94bab45abcaccb19112
data/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.5.1] - 2024-04-01
4
+
5
+ - Add support for additional derived components:
6
+ @target-uri, @scheme, @request-target, @query and @query-param.
7
+
8
+ ## [0.5.0] - 2024-03-30
9
+
10
+ - Build Linzer::Message instances from Rack request and response objects
11
+ instead of unspecified/ad-hoc hashes with HTTP request and
12
+ response parameters.
13
+
14
+ - Update README examples.
15
+
3
16
  ## [0.4.1] - 2024-03-25
4
17
 
5
18
  - Fix one-off error on ECDSA P-256 and P-384 curve signature generation.
data/README.md CHANGED
@@ -1,4 +1,11 @@
1
- # Linzer
1
+ # Linzer [![Latest Version][gem-badge]][gem-link] [![License: MIT][license-image]][license-link] [![CI Status][ci-image]][ci-link]
2
+
3
+ [gem-badge]: https://badge.fury.io/rb/linzer.svg
4
+ [gem-link]: https://rubygems.org/gems/linzer
5
+ [license-image]: https://img.shields.io/badge/license-MIT-blue.svg
6
+ [license-link]: https://github.com/nomadium/linzer/blob/master/LICENSE.txt
7
+ [ci-image]: https://github.com/nomadium/linzer/actions/workflows/main.yml/badge.svg?branch=master
8
+ [ci-link]: https://github.com/nomadium/linzer/actions/workflows/main.yml
2
9
 
3
10
  Linzer is a Ruby library for [HTTP Message Signatures (RFC 9421)](https://www.rfc-editor.org/rfc/rfc9421.html).
4
11
 
@@ -17,35 +24,97 @@ Or just `gem install linzer`.
17
24
  ### To sign a HTTP message:
18
25
 
19
26
  ```ruby
20
- irb(main):001:0> key = Linzer.generate_ed25519_key
27
+ key = Linzer.generate_ed25519_key
21
28
  # => #<Linzer::Ed25519::Key:0x00000fe13e9bd208
22
29
 
23
- message = Linzer::Message.new(headers: {"date" => "Fri, 23 Feb 2024 17:57:23 GMT", "x-custom-header" => "foo"})
24
- # => #<Linzer::Message:0x0000000111b592a0 @headers={"date"=>"Fri, 23 Feb 2024 17:57:23 GMT", ...
30
+ headers = {
31
+ "date" => "Fri, 23 Feb 2024 17:57:23 GMT",
32
+ "x-custom-header" => "foo"
33
+ }
34
+
35
+ request = Linzer.new_request(:post, "/some_uri", {}, headers)
36
+ # => #<Rack::Request:0x0000000104c1c8c0
37
+ # @env={"HTTP_DATE"=>"Fri, 23 Feb 2024 17:57:23 GMT", "HTTP_X_CUSTOM..."
38
+ # @params=nil>
39
+
40
+ message = Linzer::Message.new(request)
41
+ # => #<Linzer::Message:0x0000000104afa960
42
+ # @operation=#<Rack::Request:0x00000001049754a0
43
+ # @env={"HTTP_DATE"=>"Fri, 23 Feb 2024 17:57:23 GMT", "HTTP_X_CUSTOM..."
44
+ # @params=nil>>
45
+
46
+ fields = %w[date x-custom-header @method @path]
25
47
 
26
- fields = %w[date x-custom-header]
27
48
  signature = Linzer.sign(key, message, fields)
28
49
  # => #<Linzer::Signature:0x0000000111f77ad0 ...
29
50
 
30
- puts signature.to_h
31
- {"signature"=>
32
- "sig1=:8rLY3nFtezwwsK+sqZEMe7wzbNHojZJGEnvp3suKichgwH...",
33
- "signature-input"=>"sig1=(\"date\" \"x-custom-header\");created=1709075013;keyid=\"test-key-ed25519\""}
51
+ pp signature.to_h
52
+ # => {"signature"=>"sig1=:Cv1TUCxUpX+5SVa7pH0Xh...",
53
+ # "signature-input"=>"sig1=(\"date\" \"x-custom-header\" ..."}
54
+ ```
55
+
56
+ ### Use the message signature with any HTTP client:
57
+
58
+ ```ruby
59
+ require "net/http"
60
+
61
+ http = Net::HTTP.new("localhost", 9292)
62
+ http.set_debug_output($stderr)
63
+ response = http.post("/some_uri", "data", headers.merge(signature.to_h))
64
+ # opening connection to localhost:9292...
65
+ # opened
66
+ # <- "POST /some_uri HTTP/1.1\r\n
67
+ # <- Date: Fri, 23 Feb 2024 17:57:23 GMT\r\n
68
+ # <- X-Custom-Header: foo\r\n
69
+ # <- Signature: sig1=:Cv1TUCxUpX+5SVa7pH0X...
70
+ # <- Signature-Input: sig1=(\"date\" \"x-custom-header\" \"@method\"...
71
+ # <- Accept-Encoding: gzip;q=1.0,deflate;q=0.6,identity;q=0.3\r\n
72
+ # <- Accept: */*\r\n
73
+ # <- User-Agent: Ruby\r\n
74
+ # <- Connection: close\r\n
75
+ # <- Host: localhost:9292
76
+ # <- Content-Length: 4\r\n
77
+ # <- Content-Type: application/x-www-form-urlencoded\r\n\r\n"
78
+ # <- "data"
79
+ #
80
+ # -> "HTTP/1.1 200 OK\r\n"
81
+ # -> "Content-Type: text/html;charset=utf-8\r\n"
82
+ # -> "Content-Length: 0\r\n"
83
+ # -> "X-Xss-Protection: 1; mode=block\r\n"
84
+ # -> "X-Content-Type-Options: nosniff\r\n"
85
+ # -> "X-Frame-Options: SAMEORIGIN\r\n"
86
+ # -> "Server: WEBrick/1.8.1 (Ruby/3.2.0/2022-12-25)\r\n"
87
+ # -> "Date: Thu, 28 Mar 2024 17:19:21 GMT\r\n"
88
+ # -> "Connection: close\r\n"
89
+ # -> "\r\n"
90
+ # reading 0 bytes...
91
+ # -> ""
92
+ # read 0 bytes
93
+ # Conn close
94
+ # => #<Net::HTTPOK 200 OK readbody=true>
34
95
  ```
35
96
 
36
97
  ### To verify a valid signature:
37
98
 
38
99
  ```ruby
39
- pubkey = Linzer.new_ed25519_public_key(test_ed25519_key_pub, "some-key-ed25519")
100
+ test_ed25519_key_pub = Base64.strict_encode64(key.material.verify_key.to_bytes)
101
+ # => "EUra7KsJ8B/lSZJVhDaopMycmZ6T7KtJqKVNJTHKIw0="
102
+
103
+ raw_pubkey = Base64.strict_decode64(test_ed25519_key_pub)
104
+ # => "\xB1rM\xFFR\x1F\xDDw\x00\x89\..."
105
+
106
+ pubkey = Linzer.new_ed25519_public_key(raw_pubkey, "some-key-ed25519")
40
107
  # => #<Linzer::Ed25519::Key:0x00000fe19b9384b0
41
108
 
42
- headers = {"signature-input" => "...", signature => "...", "date" => "Fri, 23 Feb 2024 13:18:15 GMT", "x-custom-header" => "bar"})
109
+ # if you have to, there is a helper method to build a request object on the server side
110
+ # although any standard Ruby web server or framework (Sinatra, Rails, etc) should expose
111
+ # a request object and this should not be required for most cases.
112
+ #
113
+ # request = Linzer.new_request(:post, "/some_uri", {}, headers)
43
114
 
44
- message = Linzer::Message.new(headers)
45
- # => #<Linzer::Message:0x0000000111b592a0 @headers={"date"=>"Fri, 23 Feb 2024 13:18:15 GMT", ...
115
+ message = Linzer::Message.new(request)
46
116
 
47
- signature = Linzer::Signature.build(headers)
48
- # => #<Linzer::Signature:0x0000000112396008 ...
117
+ signature = Linzer::Signature.build(message.headers)
49
118
 
50
119
  Linzer.verify(pubkey, message, signature)
51
120
  # => true
@@ -55,10 +124,35 @@ Linzer.verify(pubkey, message, signature)
55
124
 
56
125
  ```ruby
57
126
  result = Linzer.verify(pubkey, message, signature)
58
- lib/linzer/verifier.rb:34:in `verify_or_fail': Failed to verify message: Invalid signature. (Linzer::Error)
127
+ lib/linzer/verifier.rb:38:in `verify_or_fail': Failed to verify message: Invalid signature. (Linzer::Error)
128
+ ```
129
+
130
+ ### HTTP responses are also supported
131
+
132
+ HTTP responses can also be signed and verified in the same way as requests.
133
+
134
+ ```ruby
135
+ headers = {
136
+ "date" => "Sat, 30 Mar 2024 21:40:13 GMT",
137
+ "x-response-custom" => "bar"
138
+ }
139
+
140
+ response = Linzer.new_response("request body", 200, headers)
141
+ # or just use the response object exposed by your HTTP framework
142
+
143
+ message = Linzer::Message.new(response)
144
+ fields = %w[@status date x-response-custom]
145
+
146
+ signature = Linzer.sign(key, message, fields)
147
+
148
+ pp signature.to_h
149
+ # => {"signature"=>
150
+ # "sig1=:tCldwXqbISktyABrmbhszo...",
151
+ # "signature-input"=>"sig1=(\"@status\" \"date\" ..."}
152
+
59
153
  ```
60
154
 
61
- For now, to consult additional details, just take a look at source code and/or the unit tests.
155
+ For now, to consult additional details just take a look at source code and/or the unit tests.
62
156
 
63
157
  Please note that is still early days and extensive testing is still ongoing. For now only the following algorithms are supported: RSASSA-PSS using SHA-512, HMAC-SHA256, Ed25519 and ECDSA (P-256 and P-384 curves).
64
158
 
@@ -2,37 +2,56 @@
2
2
 
3
3
  module Linzer
4
4
  class Message
5
- def initialize(request_data)
6
- @http = Hash(request_data[:http].clone).freeze
7
- @headers = Hash(request_data.fetch(:headers, {})
8
- .transform_keys(&:downcase)
9
- .clone).freeze
5
+ def initialize(operation)
6
+ @operation = operation
10
7
  freeze
11
8
  end
12
9
 
13
- def empty?
14
- @headers.empty?
10
+ def request?
11
+ @operation.is_a?(Rack::Request) || @operation.respond_to?(:request_method)
15
12
  end
16
13
 
17
- def header?(header)
18
- @headers.key?(header)
14
+ def response?
15
+ @operation.is_a?(Rack::Response) || @operation.respond_to?(:status)
16
+ end
17
+
18
+ def headers
19
+ return @operation.headers if response? || @operation.respond_to?(:headers)
20
+
21
+ Request.headers(@operation)
19
22
  end
20
23
 
21
24
  def field?(f)
22
25
  !!self[f]
23
26
  end
24
27
 
28
+ DERIVED_COMPONENT = {
29
+ "@method" => :request_method,
30
+ "@authority" => :authority,
31
+ "@path" => :path_info,
32
+ "@status" => :status,
33
+ "@target-uri" => :url,
34
+ "@scheme" => :scheme,
35
+ "@request-target" => :fullpath,
36
+ "@query" => :query_string
37
+ }.freeze
38
+
25
39
  def [](field_name)
26
- return @headers[field_name] if !field_name.start_with?("@")
40
+ if !field_name.start_with?("@")
41
+ return @operation.env[Request.rack_header_name(field_name)] if request?
42
+ return @operation.headers[field_name] # if response?
43
+ end
44
+
45
+ method = DERIVED_COMPONENT[field_name]
27
46
 
28
47
  case field_name
29
- when "@method" then @http["method"]
30
- when "@authority" then @http["host"]
31
- when "@path" then @http["path"]
32
- when "@status" then @http["status"]
33
- else # XXX: improve this and add support for all fields in the RFC
34
- raise Error.new "Unknown/unsupported field: \"#{field_name}\""
48
+ when "@query"
49
+ return "?#{@operation.public_send(method)}"
50
+ when /\A(?<field>(?<prefix>@query-param)(?<rest>;name=.+)\Z)/
51
+ return parse_query_param Regexp.last_match
35
52
  end
53
+
54
+ method ? @operation.public_send(method) : nil
36
55
  end
37
56
 
38
57
  class << self
@@ -42,5 +61,17 @@ module Linzer
42
61
  raise Error.new "Cannot parse \"#{field_name}\" field. Bailing out!"
43
62
  end
44
63
  end
64
+
65
+ private
66
+
67
+ def parse_query_param(match_data)
68
+ raw_item = '"%s"%s' % [match_data[:prefix], match_data[:rest]]
69
+ parsed_item = Starry.parse_item(raw_item)
70
+ fail unless parsed_item.value == "@query-param"
71
+ param_name = URI.decode_uri_component(parsed_item.parameters["name"])
72
+ URI.encode_uri_component(@operation.params.fetch(param_name))
73
+ rescue => _
74
+ nil
75
+ end
45
76
  end
46
77
  end
@@ -0,0 +1,93 @@
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
+ }
16
+
17
+ Rack::Request.new(build_rack_env(headers).merge(args))
18
+ end
19
+
20
+ def rack_header_name(field_name)
21
+ validate_header_name field_name
22
+
23
+ rack_name = field_name.upcase.tr("-", "_")
24
+ case field_name.downcase
25
+ when "content-type", "content-length"
26
+ rack_name
27
+ else
28
+ "HTTP_#{rack_name}"
29
+ end
30
+ end
31
+
32
+ def headers(rack_request)
33
+ rack_request
34
+ .each_header
35
+ .to_h
36
+ .select do |k, _|
37
+ k.start_with?("HTTP_") || %w[CONTENT_TYPE CONTENT_LENGTH].include?(k)
38
+ end
39
+ .transform_keys { |k| k.downcase.tr("_", "-") }
40
+ .transform_keys do |k|
41
+ %w[content-type content-length].include?(k) ? k : k.gsub(/^http-/, "")
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ def validate(verb, uri, params, headers)
48
+ validate_verb verb
49
+ validate_uri uri
50
+ validate_arg_hash headers: headers
51
+ validate_arg_hash params: params
52
+ end
53
+
54
+ def validate_verb(verb)
55
+ Rack.const_get(verb.upcase)
56
+ rescue => ex
57
+ unknown_method = "Unknown/invalid HTTP request method"
58
+ raise Error.new, unknown_method, cause: ex
59
+ end
60
+
61
+ def validate_uri(uri)
62
+ uri.to_str
63
+ rescue => ex
64
+ invalid_uri = "Invalid URI"
65
+ raise Error.new, invalid_uri, cause: ex
66
+ end
67
+
68
+ def validate_arg_hash(hsh)
69
+ arg_name = hsh.keys.first
70
+ hsh[arg_name].to_hash
71
+ rescue => ex
72
+ err_msg = "invalid \"#{arg_name}\" parameter, cannot be converted to hash."
73
+ raise Error.new, "Cannot build request: #{err_msg}", cause: ex
74
+ end
75
+
76
+ def validate_header_name(name)
77
+ raise ArgumentError.new, "Blank header name." if name.empty?
78
+ name.to_str
79
+ rescue => ex
80
+ err_msg = "Invalid header name: '#{name}'"
81
+ raise Error.new, err_msg, cause: ex
82
+ end
83
+
84
+ def build_rack_env(headers)
85
+ headers
86
+ .to_hash
87
+ .transform_keys { |k| k.upcase.tr("-", "_") }
88
+ .transform_keys do |k|
89
+ %w[CONTENT_TYPE CONTENT_LENGTH].include?(k) ? k : "HTTP_#{k}"
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,9 @@
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)
7
+ end
8
+ end
9
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Linzer
4
- VERSION = "0.4.1"
4
+ VERSION = "0.5.1"
5
5
  end
data/lib/linzer.rb CHANGED
@@ -2,9 +2,12 @@
2
2
 
3
3
  require "starry"
4
4
  require "openssl"
5
+ require "rack"
5
6
 
6
7
  require_relative "linzer/version"
7
8
  require_relative "linzer/common"
9
+ require_relative "linzer/request"
10
+ require_relative "linzer/response"
8
11
  require_relative "linzer/message"
9
12
  require_relative "linzer/signature"
10
13
  require_relative "linzer/key"
@@ -21,6 +24,11 @@ module Linzer
21
24
 
22
25
  class << self
23
26
  include Key::Helper
27
+ include Response
28
+
29
+ def new_request(verb, uri = "/", params = {}, headers = {})
30
+ Linzer::Request.build(verb, uri, params, headers)
31
+ end
24
32
 
25
33
  def verify(pubkey, message, signature)
26
34
  Linzer::Verifier.verify(pubkey, message, signature)
data/linzer.gemspec CHANGED
@@ -31,4 +31,5 @@ Gem::Specification.new do |spec|
31
31
 
32
32
  spec.add_runtime_dependency "ed25519", "~> 1.3", ">= 1.3.0"
33
33
  spec.add_runtime_dependency "starry", "~> 0.1"
34
+ spec.add_runtime_dependency "rack", "~> 3.0"
34
35
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: linzer
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.1
4
+ version: 0.5.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Miguel Landaeta
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2024-03-25 00:00:00.000000000 Z
11
+ date: 2024-04-01 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ed25519
@@ -44,6 +44,20 @@ dependencies:
44
44
  - - "~>"
45
45
  - !ruby/object:Gem::Version
46
46
  version: '0.1'
47
+ - !ruby/object:Gem::Dependency
48
+ name: rack
49
+ requirement: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '3.0'
54
+ type: :runtime
55
+ prerelease: false
56
+ version_requirements: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '3.0'
47
61
  description:
48
62
  email:
49
63
  - miguel@miguel.cc
@@ -66,6 +80,8 @@ files:
66
80
  - lib/linzer/key.rb
67
81
  - lib/linzer/key/helper.rb
68
82
  - lib/linzer/message.rb
83
+ - lib/linzer/request.rb
84
+ - lib/linzer/response.rb
69
85
  - lib/linzer/rsa.rb
70
86
  - lib/linzer/signature.rb
71
87
  - lib/linzer/signer.rb