linzer 0.4.1 → 0.5.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/CHANGELOG.md +13 -0
- data/README.md +111 -17
- data/lib/linzer/message.rb +47 -16
- data/lib/linzer/request.rb +93 -0
- data/lib/linzer/response.rb +9 -0
- data/lib/linzer/version.rb +1 -1
- data/lib/linzer.rb +8 -0
- data/linzer.gemspec +1 -0
- metadata +18 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 1955734eb1a8115753448b579b0cbe84f7d1ad247677ae20dd49495841b4aef3
|
4
|
+
data.tar.gz: abf9b2fbc465f10210c77f3d29e10c7e15af26a5badba90516f0b96050e3a548
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
-
|
27
|
+
key = Linzer.generate_ed25519_key
|
21
28
|
# => #<Linzer::Ed25519::Key:0x00000fe13e9bd208
|
22
29
|
|
23
|
-
|
24
|
-
|
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
|
-
|
31
|
-
{"signature"=>
|
32
|
-
"sig1
|
33
|
-
|
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
|
-
|
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
|
-
|
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(
|
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:
|
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
|
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
|
|
data/lib/linzer/message.rb
CHANGED
@@ -2,37 +2,56 @@
|
|
2
2
|
|
3
3
|
module Linzer
|
4
4
|
class Message
|
5
|
-
def initialize(
|
6
|
-
@
|
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
|
14
|
-
@
|
10
|
+
def request?
|
11
|
+
@operation.is_a?(Rack::Request) || @operation.respond_to?(:request_method)
|
15
12
|
end
|
16
13
|
|
17
|
-
def
|
18
|
-
@
|
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
|
-
|
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 "@
|
30
|
-
|
31
|
-
when
|
32
|
-
|
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
|
data/lib/linzer/version.rb
CHANGED
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
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
|
+
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-
|
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
|