linzer 0.4.0 → 0.5.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: 94cad96e720cc1235948c2a2ef20b1056f444b4a456364c8551cfda414c539fb
4
- data.tar.gz: fbd2f500e2c64e010fa380ac525e2afeef1a1fc7de055655263138a1a6afab27
3
+ metadata.gz: 026db131a5602b1f62b4f930086a0612b99f17a3aa6aa0eb649baa7bc8ba574b
4
+ data.tar.gz: 8f29e81e3083257c4731c9e408f4a66a95125fdb4d0678fd8d16cc410637d694
5
5
  SHA512:
6
- metadata.gz: f187e852f367e8d6428eb8cb61919693c67f0650a6b723e20211b1eabc47d0834c0b1facaaf89790e0d2e7335fe65752c09b0b136734e9d3214578b54fac794c
7
- data.tar.gz: a5fa3a4eb24c9362918197d0828347095a354ed2151f3f08e6582d42854a0fd19460163b16523148823f82ada8a1aa95a4d467553490b909b39cba52a0ae9166
6
+ metadata.gz: 6edb3a943f2bcf408ed865fe8bec013c3b13e609ea791981474a84bb21936b2e5fa61d86e64f9e9917c0a3e440e6246d443761c81cd94c094898c78423137789
7
+ data.tar.gz: 82074df18649f3f85af7c187762dbcf90b602c9748c9f6dba4695db42ee53ce24c5a546589f96011ab9d47564e1f9d2436a35616b54678e24f4222cd12f97837
data/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.5.0] - 2024-03-30
4
+
5
+ - Build Linzer::Message instances from Rack request and response objects
6
+ instead of unspecified/ad-hoc hashes with HTTP request and
7
+ response parameters.
8
+
9
+ - Update README examples.
10
+
11
+ ## [0.4.1] - 2024-03-25
12
+
13
+ - Fix one-off error on ECDSA P-256 and P-384 curve signature generation.
14
+ In some cases, an invalid signature of 63 or 95 bytes could be generated.
15
+
3
16
  ## [0.4.0] - 2024-03-16
4
17
 
5
18
  - Add support for capitalized HTTP header names.
data/README.md CHANGED
@@ -17,35 +17,94 @@ Or just `gem install linzer`.
17
17
  ### To sign a HTTP message:
18
18
 
19
19
  ```ruby
20
- irb(main):001:0> key = Linzer.generate_ed25519_key
20
+ key = Linzer.generate_ed25519_key
21
21
  # => #<Linzer::Ed25519::Key:0x00000fe13e9bd208
22
22
 
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", ...
23
+ headers = {
24
+ "date" => "Fri, 23 Feb 2024 17:57:23 GMT",
25
+ "x-custom-header" => "foo"
26
+ }
27
+
28
+ request = Linzer.new_request(:post, "/some_uri", {}, headers)
29
+ # => #<Rack::Request:0x0000000104c1c8c0
30
+ # @env={"HTTP_DATE"=>"Fri, 23 Feb 2024 17:57:23 GMT", "HTTP_X_CUSTOM..."
31
+ # @params=nil>
32
+
33
+ message = Linzer::Message.new(request)
34
+ # => #<Linzer::Message:0x0000000104afa960
35
+ # @operation=#<Rack::Request:0x00000001049754a0
36
+ # @env={"HTTP_DATE"=>"Fri, 23 Feb 2024 17:57:23 GMT", "HTTP_X_CUSTOM..."
37
+ # @params=nil>>
38
+
39
+ fields = %w[date x-custom-header @method @path]
25
40
 
26
- fields = %w[date x-custom-header]
27
41
  signature = Linzer.sign(key, message, fields)
28
42
  # => #<Linzer::Signature:0x0000000111f77ad0 ...
29
43
 
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\""}
44
+ pp signature.to_h
45
+ # => {"signature"=>"sig1=:Cv1TUCxUpX+5SVa7pH0Xh...",
46
+ # "signature-input"=>"sig1=(\"date\" \"x-custom-header\" ..."}
47
+ ```
48
+
49
+ ### Use the message signature with any HTTP client:
50
+
51
+ ```ruby
52
+ require "net/http"
53
+
54
+ http = Net::HTTP.new("localhost", 9292)
55
+ http.set_debug_output($stderr)
56
+ response = http.post("/some_uri", "data", headers.merge(signature.to_h))
57
+ # opening connection to localhost:9292...
58
+ # opened
59
+ # <- "POST /some_uri HTTP/1.1\r\n
60
+ # <- Date: Fri, 23 Feb 2024 17:57:23 GMT\r\n
61
+ # <- X-Custom-Header: foo\r\n
62
+ # <- Signature: sig1=:Cv1TUCxUpX+5SVa7pH0X...
63
+ # <- Signature-Input: sig1=(\"date\" \"x-custom-header\" \"@method\"...
64
+ # <- Accept-Encoding: gzip;q=1.0,deflate;q=0.6,identity;q=0.3\r\n
65
+ # <- Accept: */*\r\n
66
+ # <- User-Agent: Ruby\r\n
67
+ # <- Connection: close\r\n
68
+ # <- Host: localhost:9292
69
+ # <- Content-Length: 4\r\n
70
+ # <- Content-Type: application/x-www-form-urlencoded\r\n\r\n"
71
+ # <- "data"
72
+ #
73
+ # -> "HTTP/1.1 200 OK\r\n"
74
+ # -> "Content-Type: text/html;charset=utf-8\r\n"
75
+ # -> "Content-Length: 0\r\n"
76
+ # -> "X-Xss-Protection: 1; mode=block\r\n"
77
+ # -> "X-Content-Type-Options: nosniff\r\n"
78
+ # -> "X-Frame-Options: SAMEORIGIN\r\n"
79
+ # -> "Server: WEBrick/1.8.1 (Ruby/3.2.0/2022-12-25)\r\n"
80
+ # -> "Date: Thu, 28 Mar 2024 17:19:21 GMT\r\n"
81
+ # -> "Connection: close\r\n"
82
+ # -> "\r\n"
83
+ # reading 0 bytes...
84
+ # -> ""
85
+ # read 0 bytes
86
+ # Conn close
87
+ # => #<Net::HTTPOK 200 OK readbody=true>
34
88
  ```
35
89
 
36
90
  ### To verify a valid signature:
37
91
 
38
92
  ```ruby
93
+ test_ed25519_key_pub = Base64.strict_encode64(key.material.verify_key.to_bytes)
94
+ # => "EUra7KsJ8B/lSZJVhDaopMycmZ6T7KtJqKVNJTHKIw0="
95
+
39
96
  pubkey = Linzer.new_ed25519_public_key(test_ed25519_key_pub, "some-key-ed25519")
40
97
  # => #<Linzer::Ed25519::Key:0x00000fe19b9384b0
41
98
 
42
- headers = {"signature-input" => "...", signature => "...", "date" => "Fri, 23 Feb 2024 13:18:15 GMT", "x-custom-header" => "bar"})
99
+ # if you have to, there is a helper method to build a request object on the server side
100
+ # although any standard Ruby web server or framework (Sinatra, Rails, etc) should expose
101
+ # a request object and this should not be required for most cases.
102
+ #
103
+ # request = Linzer.new_request(:post, "/some_uri", {}, headers)
43
104
 
44
- message = Linzer::Message.new(headers)
45
- # => #<Linzer::Message:0x0000000111b592a0 @headers={"date"=>"Fri, 23 Feb 2024 13:18:15 GMT", ...
105
+ message = Linzer::Message.new(request)
46
106
 
47
- signature = Linzer::Signature.build(headers)
48
- # => #<Linzer::Signature:0x0000000112396008 ...
107
+ signature = Linzer::Signature.build(message.headers)
49
108
 
50
109
  Linzer.verify(pubkey, message, signature)
51
110
  # => true
@@ -55,10 +114,35 @@ Linzer.verify(pubkey, message, signature)
55
114
 
56
115
  ```ruby
57
116
  result = Linzer.verify(pubkey, message, signature)
58
- lib/linzer/verifier.rb:34:in `verify_or_fail': Failed to verify message: Invalid signature. (Linzer::Error)
117
+ lib/linzer/verifier.rb:38:in `verify_or_fail': Failed to verify message: Invalid signature. (Linzer::Error)
118
+ ```
119
+
120
+ ### HTTP responses are also supported
121
+
122
+ HTTP responses can also be signed and verified in the same way as requests.
123
+
124
+ ```ruby
125
+ headers = {
126
+ "date" => "Sat, 30 Mar 2024 21:40:13 GMT",
127
+ "x-response-custom" => "bar"
128
+ }
129
+
130
+ response = Linzer.new_response("request body", 200, headers)
131
+ # or just use the response object exposed by your HTTP framework
132
+
133
+ message = Linzer::Message.new(response)
134
+ fields = %w[@status date x-response-custom]
135
+
136
+ signature = Linzer.sign(key, message, fields)
137
+
138
+ pp signature.to_h
139
+ # => {"signature"=>
140
+ # "sig1=:tCldwXqbISktyABrmbhszo...",
141
+ # "signature-input"=>"sig1=(\"@status\" \"date\" ..."}
142
+
59
143
  ```
60
144
 
61
- For now, to consult additional details, just take a look at source code and/or the unit tests.
145
+ For now, to consult additional details just take a look at source code and/or the unit tests.
62
146
 
63
147
  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
148
 
data/lib/linzer/ecdsa.rb CHANGED
@@ -25,12 +25,12 @@ module Linzer
25
25
  case digest
26
26
  when "SHA256"
27
27
  raise Linzer::Error.new(msg) if sig.length != 64
28
- r_bn = OpenSSL::BN.new(sig[0..31].unpack1("H*").to_i(16))
29
- s_bn = OpenSSL::BN.new(sig[32..63].unpack1("H*").to_i(16))
28
+ r_bn = OpenSSL::BN.new(sig[0..31].unpack1("H64").to_i(16))
29
+ s_bn = OpenSSL::BN.new(sig[32..63].unpack1("H64").to_i(16))
30
30
  when "SHA384"
31
31
  raise Linzer::Error.new(msg) if sig.length != 96
32
- r_bn = OpenSSL::BN.new(sig[0..47].unpack1("H*").to_i(16))
33
- s_bn = OpenSSL::BN.new(sig[48..95].unpack1("H*").to_i(16))
32
+ r_bn = OpenSSL::BN.new(sig[0..47].unpack1("H96").to_i(16))
33
+ s_bn = OpenSSL::BN.new(sig[48..95].unpack1("H96").to_i(16))
34
34
  else
35
35
  msg = "Cannot verify signature, unsupported digest algorithm: '%s'" % digest
36
36
  raise Linzer::Error.new(msg)
@@ -44,13 +44,21 @@ module Linzer
44
44
  end
45
45
 
46
46
  def decode_der_signature(der_sig)
47
+ digest = @params[:digest]
48
+ msg = "Unsupported digest algorithm: '%s'" % digest
47
49
  OpenSSL::ASN1
48
50
  .decode(der_sig)
49
51
  .value
50
- .map { |n| n.value.to_s(16) }
51
- .map { |s| [s].pack("H*") }
52
+ .map do |n|
53
+ case digest
54
+ when "SHA256" then "%.64x" % n.value
55
+ when "SHA384" then "%.96x" % n.value
56
+ else raise Linzer::Error.new(msg)
57
+ end
58
+ end
59
+ .map { |s| [s].pack("H#{s.length}") }
52
60
  .reduce(:<<)
53
- .force_encoding(Encoding::ASCII_8BIT)
61
+ .encode(Encoding::ASCII_8BIT)
54
62
  end
55
63
  end
56
64
  end
@@ -2,20 +2,23 @@
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)
@@ -23,13 +26,16 @@ module Linzer
23
26
  end
24
27
 
25
28
  def [](field_name)
26
- return @headers[field_name] if !field_name.start_with?("@")
29
+ if !field_name.start_with?("@")
30
+ return @operation.env[Request.rack_header_name(field_name)] if request?
31
+ return @operation.headers[field_name] # if response?
32
+ end
27
33
 
28
34
  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"]
35
+ when "@method" then @operation.request_method
36
+ when "@authority" then @operation.authority
37
+ when "@path" then @operation.path_info
38
+ when "@status" then @operation.status
33
39
  else # XXX: improve this and add support for all fields in the RFC
34
40
  raise Error.new "Unknown/unsupported field: \"#{field_name}\""
35
41
  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.0"
4
+ VERSION = "0.5.0"
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.0
4
+ version: 0.5.0
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-16 00:00:00.000000000 Z
11
+ date: 2024-03-30 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