linzer 0.7.9 → 0.8.0.beta1
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 +4 -4
- data/CHANGELOG.md +12 -0
- data/README.md +8 -0
- data/Rakefile +6 -0
- data/benchmarks/profile_sign_ed25519.rb +102 -0
- data/benchmarks/profile_verify_ed25519.rb +107 -0
- data/benchmarks/sign.rb +55 -0
- data/benchmarks/verify.rb +68 -0
- data/lib/linzer/common.rb +52 -22
- data/lib/linzer/ed25519.rb +4 -2
- data/lib/linzer/hmac.rb +14 -12
- data/lib/linzer/http/structured_field.rb +48 -0
- data/lib/linzer/jws.rb +4 -4
- data/lib/linzer/key.rb +20 -2
- data/lib/linzer/message/adapter/abstract.rb +6 -5
- data/lib/linzer/message/adapter.rb +0 -3
- data/lib/linzer/message/field.rb +53 -1
- data/lib/linzer/message/wrapper.rb +0 -2
- data/lib/linzer/rack.rb +24 -0
- data/lib/linzer/rsa_pss.rb +4 -4
- data/lib/linzer/signature.rb +143 -18
- data/lib/linzer/signer.rb +36 -15
- data/lib/linzer/verifier.rb +9 -3
- data/lib/linzer/version.rb +1 -1
- data/lib/linzer.rb +1 -3
- metadata +7 -41
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0330e72cbbfb7cc56b9fa124a4f60a18bb7fbdab4eb98a072598c8fc3680cf76
|
|
4
|
+
data.tar.gz: 321630c979fc736cc64284eb535b22752e50a42c8bbfae399cbf0db56efecdd3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e6e99bf6a409b960d0432f702b286f7b71b06a34b61ff242d2d75eebc9c125bc02c6daa573efc5af69dfeb7c2f788811fb91d2f6f6decd1a869db26e6a5462e6
|
|
7
|
+
data.tar.gz: 9c76877de5ba5164f677aad7be9ffb3f905f2fd874523b9f2aaff287abd0ebdd8d8c7272da22eddfcacb56a7eb325392e52e1b2a3b551e8940627b146190a895
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.8.0.beta1] - 2026-05-07
|
|
4
|
+
|
|
5
|
+
- Optimize signature parsing, serialization, and validation performance
|
|
6
|
+
across signing and verifying flows, improving `sign!` throughput by
|
|
7
|
+
473% and `verify!` by 136% by minimizing Starry usage in hot paths
|
|
8
|
+
where possible.
|
|
9
|
+
|
|
10
|
+
- Add comprehensive benchmarking and profiling infrastructure across all
|
|
11
|
+
supported algorithms.
|
|
12
|
+
|
|
13
|
+
- Make Rack an optional dependency.
|
|
14
|
+
|
|
3
15
|
## [0.7.9] - 2026-04-30
|
|
4
16
|
|
|
5
17
|
(No changes since the last beta release, this new stable release just
|
data/README.md
CHANGED
|
@@ -45,6 +45,8 @@ sections.
|
|
|
45
45
|
Add the middleware to your Rack application:
|
|
46
46
|
|
|
47
47
|
```ruby
|
|
48
|
+
require "linzer/rack"
|
|
49
|
+
|
|
48
50
|
# config.ru
|
|
49
51
|
use Rack::Auth::Signature,
|
|
50
52
|
except: "/login",
|
|
@@ -64,6 +66,8 @@ for all endpoints except /login.
|
|
|
64
66
|
For more complex setups, you can load configuration from a file, e.g.:
|
|
65
67
|
|
|
66
68
|
```ruby
|
|
69
|
+
require "linzer/rack"
|
|
70
|
+
|
|
67
71
|
# config.ru
|
|
68
72
|
use Rack::Auth::Signature,
|
|
69
73
|
except: "/login",
|
|
@@ -75,6 +79,8 @@ use Rack::Auth::Signature,
|
|
|
75
79
|
In a Rails application, add the middleware in your configuration:
|
|
76
80
|
|
|
77
81
|
```ruby
|
|
82
|
+
require "linzer/rack"
|
|
83
|
+
|
|
78
84
|
# config/application.rb
|
|
79
85
|
config.middleware.use Rack::Auth::Signature,
|
|
80
86
|
except: "/login",
|
|
@@ -255,6 +261,8 @@ For production use, prefer a full-featured HTTP client).
|
|
|
255
261
|
You can sign responses using the same API as for requests, e.g.:
|
|
256
262
|
|
|
257
263
|
```ruby
|
|
264
|
+
require "linzer/rack"
|
|
265
|
+
|
|
258
266
|
put "/baz" do
|
|
259
267
|
...
|
|
260
268
|
response
|
data/Rakefile
CHANGED
|
@@ -12,5 +12,11 @@ task :integration do
|
|
|
12
12
|
sh "bundle exec rspec -t integration spec/integration/**"
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
+
desc "Run benchmarks"
|
|
16
|
+
task :benchmarks do
|
|
17
|
+
sh "bundle exec ruby benchmarks/sign.rb"
|
|
18
|
+
sh "bundle exec ruby benchmarks/verify.rb"
|
|
19
|
+
end
|
|
20
|
+
|
|
15
21
|
task default: %i[spec standard]
|
|
16
22
|
task all: %i[integration default]
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Profile: Linzer.sign! with Ed25519 keys
|
|
5
|
+
#
|
|
6
|
+
# Uses StackProf (sampling CPU profiler) to identify bottlenecks in the
|
|
7
|
+
# signing hot path. Produces both a summary report and a flamegraph-ready
|
|
8
|
+
# dump file.
|
|
9
|
+
#
|
|
10
|
+
# Run with:
|
|
11
|
+
# ruby benchmarks/profile_sign_ed25519.rb
|
|
12
|
+
#
|
|
13
|
+
# For flamegraph (requires stackprof gem):
|
|
14
|
+
# stackprof --flamegraph tmp/stackprof-sign-ed25519.dump > tmp/flamegraph.json
|
|
15
|
+
# stackprof --flamegraph-viewer tmp/flamegraph.json
|
|
16
|
+
|
|
17
|
+
require "bundler/setup"
|
|
18
|
+
require "linzer"
|
|
19
|
+
require "net/http"
|
|
20
|
+
require "stackprof"
|
|
21
|
+
require "fileutils"
|
|
22
|
+
|
|
23
|
+
ITERATIONS = Integer(ENV.fetch("ITERATIONS", 5_000))
|
|
24
|
+
OUTPUT_DIR = "tmp"
|
|
25
|
+
DUMP_FILE = File.join(OUTPUT_DIR, "stackprof-sign-ed25519.dump")
|
|
26
|
+
|
|
27
|
+
FileUtils.mkdir_p(OUTPUT_DIR)
|
|
28
|
+
|
|
29
|
+
key = Linzer.generate_ed25519_key("bench-ed25519")
|
|
30
|
+
uri = URI("https://example.com/api/resource")
|
|
31
|
+
components = %w[@method @path @authority content-type]
|
|
32
|
+
|
|
33
|
+
puts "StackProf profile \u2014 Linzer.sign! (Ed25519)"
|
|
34
|
+
puts "Ruby #{RUBY_VERSION} / #{RUBY_PLATFORM}"
|
|
35
|
+
puts "Iterations: #{ITERATIONS}"
|
|
36
|
+
puts
|
|
37
|
+
|
|
38
|
+
# Warm up (JIT, autoloads, caches)
|
|
39
|
+
20.times do
|
|
40
|
+
req = Net::HTTP::Post.new(uri)
|
|
41
|
+
req["content-type"] = "application/json"
|
|
42
|
+
req.body = '{"hello":"world"}'
|
|
43
|
+
Linzer.sign!(req, key: key, components: components)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
StackProf.run(
|
|
47
|
+
mode: :cpu,
|
|
48
|
+
interval: 100, # sample every 100 \u00b5s
|
|
49
|
+
out: DUMP_FILE,
|
|
50
|
+
raw: true # needed for flamegraph output
|
|
51
|
+
) do
|
|
52
|
+
ITERATIONS.times do
|
|
53
|
+
request = Net::HTTP::Post.new(uri)
|
|
54
|
+
request["content-type"] = "application/json"
|
|
55
|
+
request.body = '{"hello":"world"}'
|
|
56
|
+
|
|
57
|
+
Linzer.sign!(request, key: key, components: components)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
puts "=" * 78
|
|
62
|
+
puts "METHOD-LEVEL REPORT (top 30 by self time)"
|
|
63
|
+
puts "=" * 78
|
|
64
|
+
puts
|
|
65
|
+
|
|
66
|
+
report = StackProf::Report.new(Marshal.load(File.binread(DUMP_FILE)))
|
|
67
|
+
report.print_text(false, 30)
|
|
68
|
+
|
|
69
|
+
puts
|
|
70
|
+
puts "=" * 78
|
|
71
|
+
puts "METHOD-LEVEL REPORT (top 30 by total time)"
|
|
72
|
+
puts "=" * 78
|
|
73
|
+
puts
|
|
74
|
+
|
|
75
|
+
report.print_text(true, 30)
|
|
76
|
+
|
|
77
|
+
# Also print source-annotated hotspots for the top methods
|
|
78
|
+
puts
|
|
79
|
+
puts "=" * 78
|
|
80
|
+
puts "SOURCE ANNOTATIONS FOR TOP METHODS"
|
|
81
|
+
puts "=" * 78
|
|
82
|
+
|
|
83
|
+
top_frames = report.data[:frames]
|
|
84
|
+
.sort_by { |_id, f| -(f[:samples] || 0) }
|
|
85
|
+
.first(10)
|
|
86
|
+
|
|
87
|
+
top_frames.each do |_frame_id, frame|
|
|
88
|
+
name = frame[:name]
|
|
89
|
+
file = frame[:file]
|
|
90
|
+
next unless file && File.exist?(file)
|
|
91
|
+
puts
|
|
92
|
+
puts "-" * 78
|
|
93
|
+
puts "#{name} (#{file}:#{frame[:line]})"
|
|
94
|
+
puts " self: #{frame[:samples]} total: #{frame[:total_samples]}"
|
|
95
|
+
puts "-" * 78
|
|
96
|
+
report.print_method(name)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
puts
|
|
100
|
+
puts "Dump written to: #{DUMP_FILE}"
|
|
101
|
+
puts "To generate a flamegraph:"
|
|
102
|
+
puts " stackprof --flamegraph #{DUMP_FILE} > tmp/flamegraph.json"
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Profile: Linzer.verify! with Ed25519 keys
|
|
5
|
+
#
|
|
6
|
+
# Uses StackProf (sampling CPU profiler) to identify bottlenecks in the
|
|
7
|
+
# verification hot path.
|
|
8
|
+
#
|
|
9
|
+
# Run with:
|
|
10
|
+
# ruby benchmarks/profile_verify_ed25519.rb
|
|
11
|
+
|
|
12
|
+
require "bundler/setup"
|
|
13
|
+
require "linzer"
|
|
14
|
+
require "net/http"
|
|
15
|
+
require "stackprof"
|
|
16
|
+
require "fileutils"
|
|
17
|
+
|
|
18
|
+
ITERATIONS = Integer(ENV.fetch("ITERATIONS", 5_000))
|
|
19
|
+
OUTPUT_DIR = "tmp"
|
|
20
|
+
DUMP_FILE = File.join(OUTPUT_DIR, "stackprof-verify-ed25519.dump")
|
|
21
|
+
|
|
22
|
+
FileUtils.mkdir_p(OUTPUT_DIR)
|
|
23
|
+
|
|
24
|
+
key = Linzer.generate_ed25519_key("bench-ed25519")
|
|
25
|
+
uri = URI("https://example.com/api/resource")
|
|
26
|
+
components = %w[@method @path @authority content-type]
|
|
27
|
+
|
|
28
|
+
# Sign a request once
|
|
29
|
+
request = Net::HTTP::Post.new(uri)
|
|
30
|
+
request["content-type"] = "application/json"
|
|
31
|
+
request.body = '{"hello":"world"}'
|
|
32
|
+
Linzer.sign!(request, key: key, components: components)
|
|
33
|
+
|
|
34
|
+
sig_header = request["signature"]
|
|
35
|
+
input_header = request["signature-input"]
|
|
36
|
+
|
|
37
|
+
puts "StackProf profile \u2014 Linzer.verify! (Ed25519)"
|
|
38
|
+
puts "Ruby #{RUBY_VERSION} / #{RUBY_PLATFORM}"
|
|
39
|
+
puts "Iterations: #{ITERATIONS}"
|
|
40
|
+
puts
|
|
41
|
+
|
|
42
|
+
# Warm up
|
|
43
|
+
20.times do
|
|
44
|
+
req = Net::HTTP::Post.new(uri)
|
|
45
|
+
req["content-type"] = "application/json"
|
|
46
|
+
req.body = '{"hello":"world"}'
|
|
47
|
+
req["signature"] = sig_header
|
|
48
|
+
req["signature-input"] = input_header
|
|
49
|
+
Linzer.verify!(req, key: key)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
StackProf.run(
|
|
53
|
+
mode: :cpu,
|
|
54
|
+
interval: 100,
|
|
55
|
+
out: DUMP_FILE,
|
|
56
|
+
raw: true
|
|
57
|
+
) do
|
|
58
|
+
ITERATIONS.times do
|
|
59
|
+
req = Net::HTTP::Post.new(uri)
|
|
60
|
+
req["content-type"] = "application/json"
|
|
61
|
+
req.body = '{"hello":"world"}'
|
|
62
|
+
req["signature"] = sig_header
|
|
63
|
+
req["signature-input"] = input_header
|
|
64
|
+
Linzer.verify!(req, key: key)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
puts "=" * 78
|
|
69
|
+
puts "METHOD-LEVEL REPORT (top 30 by self time)"
|
|
70
|
+
puts "=" * 78
|
|
71
|
+
puts
|
|
72
|
+
|
|
73
|
+
report = StackProf::Report.new(Marshal.load(File.binread(DUMP_FILE)))
|
|
74
|
+
report.print_text(false, 30)
|
|
75
|
+
|
|
76
|
+
puts
|
|
77
|
+
puts "=" * 78
|
|
78
|
+
puts "METHOD-LEVEL REPORT (top 30 by total time)"
|
|
79
|
+
puts "=" * 78
|
|
80
|
+
puts
|
|
81
|
+
|
|
82
|
+
report.print_text(true, 30)
|
|
83
|
+
|
|
84
|
+
# Source-annotated hotspots for the top methods
|
|
85
|
+
puts
|
|
86
|
+
puts "=" * 78
|
|
87
|
+
puts "SOURCE ANNOTATIONS FOR TOP METHODS"
|
|
88
|
+
puts "=" * 78
|
|
89
|
+
|
|
90
|
+
top_frames = report.data[:frames]
|
|
91
|
+
.sort_by { |_id, f| -(f[:samples] || 0) }
|
|
92
|
+
.first(10)
|
|
93
|
+
|
|
94
|
+
top_frames.each do |_frame_id, frame|
|
|
95
|
+
name = frame[:name]
|
|
96
|
+
file = frame[:file]
|
|
97
|
+
next unless file && File.exist?(file)
|
|
98
|
+
puts
|
|
99
|
+
puts "-" * 78
|
|
100
|
+
puts "#{name} (#{file}:#{frame[:line]})"
|
|
101
|
+
puts " self: #{frame[:samples]} total: #{frame[:total_samples]}"
|
|
102
|
+
puts "-" * 78
|
|
103
|
+
report.print_method(name)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
puts
|
|
107
|
+
puts "Dump written to: #{DUMP_FILE}"
|
data/benchmarks/sign.rb
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
require "benchmark_driver"
|
|
2
|
+
require "openssl"
|
|
3
|
+
|
|
4
|
+
BENCH_ALGS = %i[
|
|
5
|
+
ed25519
|
|
6
|
+
ecdsa_p256_sha256
|
|
7
|
+
ecdsa_p384_sha384
|
|
8
|
+
rsa_v1_5_sha256
|
|
9
|
+
rsa_pss_sha512
|
|
10
|
+
hmac_sha256
|
|
11
|
+
jws_ed25519
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
puts "Linzer.sign! benchmark — supported algorithms"
|
|
15
|
+
puts `git show HEAD | head -1 | cut -b 1-15`
|
|
16
|
+
puts "Ruby #{RUBY_VERSION} / #{RUBY_PLATFORM}"
|
|
17
|
+
puts "OpenSSL #{OpenSSL::VERSION}"
|
|
18
|
+
puts Time.now.utc
|
|
19
|
+
|
|
20
|
+
BENCH_ALGS.each do |alg|
|
|
21
|
+
next if alg == :rsa_pss_sha512 && RUBY_VERSION < "3.1.0"
|
|
22
|
+
Benchmark.driver do |x|
|
|
23
|
+
x.prelude <<~RUBY
|
|
24
|
+
require "bundler/setup"
|
|
25
|
+
require "linzer"
|
|
26
|
+
require "linzer/jws"
|
|
27
|
+
|
|
28
|
+
def generate_linzer_key(alg)
|
|
29
|
+
case alg
|
|
30
|
+
when :ed25519, :ecdsa_p256_sha256, :ecdsa_p384_sha384, :hmac_sha256
|
|
31
|
+
Linzer.public_send(:"generate_#{alg}_key", "bench-#{alg}")
|
|
32
|
+
when :rsa_v1_5_sha256, :rsa_pss_sha512
|
|
33
|
+
Linzer.public_send(:"generate_#{alg}_key", 2048, "bench-#{alg}")
|
|
34
|
+
when :jws_ed25519
|
|
35
|
+
Linzer.generate_jws_key(algorithm: "EdDSA")
|
|
36
|
+
else
|
|
37
|
+
raise ArgumentError, "Unknown algorithm!"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
key = generate_linzer_key(:"#{alg}")
|
|
42
|
+
uri = URI("https://example.com/api/resource")
|
|
43
|
+
components = %w[@method @path @authority content-type]
|
|
44
|
+
RUBY
|
|
45
|
+
|
|
46
|
+
puts "\n#{alg}:\n#{"#" * 70}\n\n"
|
|
47
|
+
|
|
48
|
+
x.report "[#{alg}] sign!", %{
|
|
49
|
+
request = Net::HTTP::Post.new(uri)
|
|
50
|
+
request["content-type"] = "application/json"
|
|
51
|
+
request.body = '{"hello":"world"}'
|
|
52
|
+
Linzer.sign!(request, key: key, components: components)
|
|
53
|
+
}
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
require "benchmark_driver"
|
|
2
|
+
require "openssl"
|
|
3
|
+
|
|
4
|
+
BENCH_ALGS = %i[
|
|
5
|
+
ed25519
|
|
6
|
+
ecdsa_p256_sha256
|
|
7
|
+
ecdsa_p384_sha384
|
|
8
|
+
rsa_v1_5_sha256
|
|
9
|
+
rsa_pss_sha512
|
|
10
|
+
hmac_sha256
|
|
11
|
+
jws_ed25519
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
puts "Linzer.verify! benchmark — supported algorithms"
|
|
15
|
+
puts `git show HEAD | head -1 | cut -b 1-15`
|
|
16
|
+
puts "Ruby #{RUBY_VERSION} / #{RUBY_PLATFORM}"
|
|
17
|
+
puts "OpenSSL #{OpenSSL::VERSION}"
|
|
18
|
+
puts Time.now.utc
|
|
19
|
+
|
|
20
|
+
BENCH_ALGS.each do |alg|
|
|
21
|
+
next if alg == :rsa_pss_sha512 && RUBY_VERSION < "3.1.0"
|
|
22
|
+
Benchmark.driver do |x|
|
|
23
|
+
x.prelude <<~RUBY
|
|
24
|
+
require "bundler/setup"
|
|
25
|
+
require "linzer"
|
|
26
|
+
require "linzer/jws"
|
|
27
|
+
|
|
28
|
+
def generate_linzer_key(alg)
|
|
29
|
+
case alg
|
|
30
|
+
when :ed25519, :ecdsa_p256_sha256, :ecdsa_p384_sha384, :hmac_sha256
|
|
31
|
+
Linzer.public_send(:"generate_#{alg}_key", "bench-#{alg}")
|
|
32
|
+
when :rsa_v1_5_sha256, :rsa_pss_sha512
|
|
33
|
+
Linzer.public_send(:"generate_#{alg}_key", 2048, "bench-#{alg}")
|
|
34
|
+
when :jws_ed25519
|
|
35
|
+
Linzer.generate_jws_key(algorithm: "EdDSA")
|
|
36
|
+
else
|
|
37
|
+
raise ArgumentError, "Unknown algorithm!"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
key = generate_linzer_key(:"#{alg}")
|
|
42
|
+
uri = URI("https://example.com/api/resource")
|
|
43
|
+
components = %w[@method @path @authority content-type]
|
|
44
|
+
|
|
45
|
+
request = Net::HTTP::Post.new(uri)
|
|
46
|
+
request["content-type"] = "application/json"
|
|
47
|
+
request.body = '{"hello":"world"}'
|
|
48
|
+
|
|
49
|
+
Linzer.sign!(request, key: key, components: components)
|
|
50
|
+
|
|
51
|
+
# Capture the signed headers so we can rebuild identical requests
|
|
52
|
+
sig_header = request["signature"]
|
|
53
|
+
input_header = request["signature-input"]
|
|
54
|
+
RUBY
|
|
55
|
+
|
|
56
|
+
puts "\n#{alg}:\n#{"#" * 70}\n\n"
|
|
57
|
+
|
|
58
|
+
x.report "[#{alg}] verify!", %{
|
|
59
|
+
req = Net::HTTP::Post.new(uri)
|
|
60
|
+
req["content-type"] = "application/json"
|
|
61
|
+
req.body = '{"hello":"world"}'
|
|
62
|
+
req["signature"] = sig_header
|
|
63
|
+
req["signature-input"] = input_header
|
|
64
|
+
|
|
65
|
+
Linzer.verify!(req, key: key)
|
|
66
|
+
}
|
|
67
|
+
end
|
|
68
|
+
end
|
data/lib/linzer/common.rb
CHANGED
|
@@ -26,15 +26,25 @@ module Linzer
|
|
|
26
26
|
# # "@path": /foo
|
|
27
27
|
# # "content-type": application/json
|
|
28
28
|
# # "@signature-params": ("@method" "@path" "content-type");created=1618884473
|
|
29
|
-
def signature_base(message, serialized_components, parameters)
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
29
|
+
def signature_base(message, serialized_components, parameters, field_ids: nil)
|
|
30
|
+
buf = +""
|
|
31
|
+
|
|
32
|
+
if field_ids
|
|
33
|
+
i = 0
|
|
34
|
+
len = serialized_components.size
|
|
35
|
+
while i < len
|
|
36
|
+
buf << serialized_components[i] << ": " << String(message[field_ids[i]]) << "\n"
|
|
37
|
+
i += 1
|
|
38
|
+
end
|
|
39
|
+
else
|
|
40
|
+
serialized_components.each do |component|
|
|
41
|
+
buf << signature_base_line(component, message) << "\n"
|
|
33
42
|
end
|
|
43
|
+
end
|
|
34
44
|
|
|
35
|
-
|
|
45
|
+
buf << signature_params_line(serialized_components, parameters)
|
|
36
46
|
|
|
37
|
-
|
|
47
|
+
buf
|
|
38
48
|
end
|
|
39
49
|
module_function :signature_base
|
|
40
50
|
|
|
@@ -57,13 +67,14 @@ module Linzer
|
|
|
57
67
|
# @param serialized_components [Array<String>] The covered components
|
|
58
68
|
# @param parameters [Hash] Signature parameters
|
|
59
69
|
# @return [String] The formatted @signature-params line
|
|
60
|
-
|
|
61
|
-
|
|
70
|
+
SERIALIZED_SIGNATURE_PARAMS = Starry.serialize("@signature-params").freeze
|
|
71
|
+
private_constant :SERIALIZED_SIGNATURE_PARAMS
|
|
62
72
|
|
|
63
|
-
|
|
64
|
-
|
|
73
|
+
def signature_params_line(serialized_components, parameters)
|
|
74
|
+
params_str = HTTP::StructuredField.serialize_parameters(parameters)
|
|
75
|
+
components_str = serialized_components.join(" ")
|
|
65
76
|
|
|
66
|
-
"
|
|
77
|
+
"#{SERIALIZED_SIGNATURE_PARAMS}: (#{components_str})#{params_str}"
|
|
67
78
|
end
|
|
68
79
|
module_function :signature_params_line
|
|
69
80
|
|
|
@@ -76,18 +87,30 @@ module Linzer
|
|
|
76
87
|
# @raise [Error] If @signature-params is in the components
|
|
77
88
|
# @raise [Error] If any component is missing from the message
|
|
78
89
|
# @raise [Error] If any component is duplicated
|
|
79
|
-
def validate_components(message, components)
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
end
|
|
90
|
+
def validate_components(message, components, field_ids: nil)
|
|
91
|
+
has_params = false
|
|
92
|
+
missing = "Cannot verify signature. Missing component in message"
|
|
93
|
+
invalid = "Invalid component in signature input"
|
|
84
94
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
95
|
+
if field_ids
|
|
96
|
+
i = 0
|
|
97
|
+
len = components.size
|
|
98
|
+
while i < len
|
|
99
|
+
c = components[i]
|
|
100
|
+
raise Error, invalid if c.include?("@signature-params")
|
|
101
|
+
has_params = true if !has_params && c.include?(";")
|
|
102
|
+
raise Error, "#{missing}: \"#{c}\"" unless message.field?(field_ids[i])
|
|
103
|
+
i += 1
|
|
104
|
+
end
|
|
105
|
+
else
|
|
106
|
+
components.each do |c|
|
|
107
|
+
raise Error, invalid if c.include?("@signature-params")
|
|
108
|
+
has_params = true if !has_params && c.include?(";")
|
|
109
|
+
raise Error, "#{missing}: \"#{c}\"" unless message.field?(c)
|
|
110
|
+
end
|
|
88
111
|
end
|
|
89
112
|
|
|
90
|
-
validate_uniqueness components
|
|
113
|
+
validate_uniqueness(components) if has_params || components.size != components.uniq.size
|
|
91
114
|
end
|
|
92
115
|
|
|
93
116
|
# Validates that there are no duplicate components.
|
|
@@ -98,7 +121,14 @@ module Linzer
|
|
|
98
121
|
# @param components [Array<String>] Component identifiers to check
|
|
99
122
|
# @raise [Error] If any component appears more than once
|
|
100
123
|
def validate_uniqueness(components)
|
|
101
|
-
|
|
124
|
+
duplicated = "Invalid signature. Duplicated component in signature input."
|
|
125
|
+
|
|
126
|
+
# String-level duplicates are always invalid
|
|
127
|
+
raise Error, duplicated if components.size != components.uniq.size
|
|
128
|
+
|
|
129
|
+
# If any component has parameters, also check for semantic duplicates
|
|
130
|
+
# (e.g. ;bs;req vs ;req;bs are semantically equal but different strings)
|
|
131
|
+
return unless components.any? { |c| c.include?(";") }
|
|
102
132
|
|
|
103
133
|
uniq_components =
|
|
104
134
|
components
|
|
@@ -110,7 +140,7 @@ module Linzer
|
|
|
110
140
|
.uniq { |comp| [comp.value, comp.parameters] }
|
|
111
141
|
end
|
|
112
142
|
|
|
113
|
-
raise Error
|
|
143
|
+
raise Error, duplicated if components.count != uniq_components.count
|
|
114
144
|
end
|
|
115
145
|
end
|
|
116
146
|
end
|
data/lib/linzer/ed25519.rb
CHANGED
|
@@ -46,13 +46,15 @@ module Linzer
|
|
|
46
46
|
material.verify(nil, signature, data)
|
|
47
47
|
end
|
|
48
48
|
|
|
49
|
+
private
|
|
50
|
+
|
|
49
51
|
# @return [Boolean] true if this key contains public key material
|
|
50
|
-
def
|
|
52
|
+
def compute_public?
|
|
51
53
|
has_pem_public?
|
|
52
54
|
end
|
|
53
55
|
|
|
54
56
|
# @return [Boolean] true if this key contains private key material
|
|
55
|
-
def
|
|
57
|
+
def compute_private?
|
|
56
58
|
has_pem_private?
|
|
57
59
|
end
|
|
58
60
|
end
|
data/lib/linzer/hmac.rb
CHANGED
|
@@ -54,18 +54,6 @@ module Linzer
|
|
|
54
54
|
OpenSSL.secure_compare(signature, sign(data))
|
|
55
55
|
end
|
|
56
56
|
|
|
57
|
-
# HMAC keys can always sign (they contain the secret).
|
|
58
|
-
# @return [Boolean] true if key material is present
|
|
59
|
-
def private?
|
|
60
|
-
!material.nil?
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
# HMAC keys are symmetric, not public/private.
|
|
64
|
-
# @return [Boolean] always false for HMAC keys
|
|
65
|
-
def public?
|
|
66
|
-
false
|
|
67
|
-
end
|
|
68
|
-
|
|
69
57
|
# Returns a safe string representation that doesn't leak the secret.
|
|
70
58
|
#
|
|
71
59
|
# The key material is intentionally excluded from the output to prevent
|
|
@@ -82,6 +70,20 @@ module Linzer
|
|
|
82
70
|
oid = Digest::SHA2.hexdigest(object_id.to_s)[48..63]
|
|
83
71
|
"#<%s:0x%s %s>" % [self.class, oid, vars.join(", ")]
|
|
84
72
|
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
# HMAC keys can always sign (they contain the secret).
|
|
77
|
+
# @return [Boolean] true if key material is present
|
|
78
|
+
def compute_private?
|
|
79
|
+
!material.nil?
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# HMAC keys are symmetric, not public/private.
|
|
83
|
+
# @return [Boolean] always false for HMAC keys
|
|
84
|
+
def compute_public?
|
|
85
|
+
false
|
|
86
|
+
end
|
|
85
87
|
end
|
|
86
88
|
end
|
|
87
89
|
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Linzer
|
|
4
|
+
module HTTP
|
|
5
|
+
# Utilities for serializing HTTP Structured Fields as defined in RFC 8941.
|
|
6
|
+
#
|
|
7
|
+
# This module currently provides helpers for serializing HTTP Message
|
|
8
|
+
# Signature parameters as used by RFC 9421.
|
|
9
|
+
#
|
|
10
|
+
# @see https://www.rfc-editor.org/rfc/rfc8941 RFC 8941
|
|
11
|
+
# @see https://www.rfc-editor.org/rfc/rfc9421 RFC 9421
|
|
12
|
+
module StructuredField
|
|
13
|
+
# Serializes signature parameters to the RFC 8941 string format.
|
|
14
|
+
#
|
|
15
|
+
# Integers are bare, strings are double-quoted. This covers all
|
|
16
|
+
# parameter types used in RFC 9421 signatures (created, expires,
|
|
17
|
+
# keyid, nonce, alg, tag).
|
|
18
|
+
#
|
|
19
|
+
# @example Serialize signature parameters
|
|
20
|
+
# StructuredField.serialize_parameters(
|
|
21
|
+
# created: 1700000000,
|
|
22
|
+
# keyid: "my-key"
|
|
23
|
+
# )
|
|
24
|
+
# # => ';created=1700000000;keyid="my-key"'
|
|
25
|
+
#
|
|
26
|
+
# @param parameters [Hash{Symbol,String => Object}]
|
|
27
|
+
# The parameters to serialize.
|
|
28
|
+
#
|
|
29
|
+
# @return [String]
|
|
30
|
+
# The serialized structured field parameter string.
|
|
31
|
+
#
|
|
32
|
+
def self.serialize_parameters(parameters)
|
|
33
|
+
params_str = +""
|
|
34
|
+
parameters.each do |key, value|
|
|
35
|
+
params_str << case value
|
|
36
|
+
when Integer
|
|
37
|
+
";#{key}=#{value}"
|
|
38
|
+
when String
|
|
39
|
+
";#{key}=\"#{value}\""
|
|
40
|
+
else
|
|
41
|
+
";#{key}=#{value}"
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
params_str
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
data/lib/linzer/jws.rb
CHANGED
|
@@ -101,18 +101,18 @@ module Linzer
|
|
|
101
101
|
algo.verify(data: data, signature: signature, verification_key: verify_key)
|
|
102
102
|
end
|
|
103
103
|
|
|
104
|
+
private
|
|
105
|
+
|
|
104
106
|
# @return [Boolean] true if this key can verify signatures
|
|
105
|
-
def
|
|
107
|
+
def compute_public?
|
|
106
108
|
!!verify_key
|
|
107
109
|
end
|
|
108
110
|
|
|
109
111
|
# @return [Boolean] true if this key can create signatures
|
|
110
|
-
def
|
|
112
|
+
def compute_private?
|
|
111
113
|
!!signing_key
|
|
112
114
|
end
|
|
113
115
|
|
|
114
|
-
private
|
|
115
|
-
|
|
116
116
|
# Resolves the appropriate JWT algorithm implementation.
|
|
117
117
|
# @return [JWT::JWA::SigningAlgorithm] The algorithm implementation
|
|
118
118
|
# @raise [Error] If the algorithm cannot be determined
|