linzer 0.7.9 → 0.8.0.beta2

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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/.standard.yml +1 -0
  3. data/CHANGELOG.md +19 -0
  4. data/README.md +63 -0
  5. data/Rakefile +6 -0
  6. data/benchmarks/profile_sign_ed25519.rb +102 -0
  7. data/benchmarks/profile_verify_ed25519.rb +107 -0
  8. data/benchmarks/sign.rb +55 -0
  9. data/benchmarks/verify.rb +68 -0
  10. data/lib/faraday/http_signature/middleware.rb +25 -4
  11. data/lib/linzer/common.rb +53 -23
  12. data/lib/linzer/ed25519.rb +4 -2
  13. data/lib/linzer/helper.rb +34 -15
  14. data/lib/linzer/hmac.rb +14 -12
  15. data/lib/linzer/http/signature_feature.rb +15 -4
  16. data/lib/linzer/http/structured_field.rb +145 -0
  17. data/lib/linzer/http.rb +13 -7
  18. data/lib/linzer/jws.rb +4 -4
  19. data/lib/linzer/key.rb +20 -2
  20. data/lib/linzer/message/adapter/abstract.rb +36 -22
  21. data/lib/linzer/message/adapter/generic/request.rb +1 -0
  22. data/lib/linzer/message/adapter.rb +0 -3
  23. data/lib/linzer/message/field/parser.rb +5 -5
  24. data/lib/linzer/message/field.rb +55 -3
  25. data/lib/linzer/message/overlay.rb +143 -0
  26. data/lib/linzer/message/wrapper.rb +0 -2
  27. data/lib/linzer/message.rb +18 -0
  28. data/lib/linzer/rack.rb +24 -0
  29. data/lib/linzer/rsa_pss.rb +4 -4
  30. data/lib/linzer/signature/context.rb +80 -0
  31. data/lib/linzer/signature/profile/base.rb +43 -0
  32. data/lib/linzer/signature/profile/example.rb +39 -0
  33. data/lib/linzer/signature/profile/web_bot_auth.rb +201 -0
  34. data/lib/linzer/signature/profile.rb +70 -0
  35. data/lib/linzer/signature.rb +147 -32
  36. data/lib/linzer/signer.rb +36 -15
  37. data/lib/linzer/verifier.rb +9 -3
  38. data/lib/linzer/version.rb +1 -1
  39. data/lib/linzer.rb +5 -4
  40. metadata +13 -41
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 91f1e3986fae03eb9bf01d4dc8e9c919cd7072ea3ae0f8168099261da0da8c11
4
- data.tar.gz: fb07600d19e8e2198e7cbcfb910516d9300e3aa89d94e81e3bc3ebd6a9d007e8
3
+ metadata.gz: 7a917fda2b4d4646e642ba3a52d11bd41a500b5bfed044142a6e0434e0ef1c94
4
+ data.tar.gz: 8d9296ba21595cba696532a41b20f123569d3b31de0730ba6e7e6f693e87e009
5
5
  SHA512:
6
- metadata.gz: aed32987b8ea9fc398ef1d263a94ce3df3ec86e8cd331d2e9b23383c0c6de1db3f0bdaa621990eb1809c210b0ae3f38b045abd2e8e48229a9ec8129cf4213c65
7
- data.tar.gz: 26a21c99d8e383af31395f5ebfa5581b0fedf126bb1bb806db3cb1eb0f0d89306dde3af7bf54100cd0251235f54e5cd4f852f8d61007b74fe079716b4447986a
6
+ metadata.gz: 2f6b61e4a340594891976d77b651527b8e0d49ff9ab11d73c56f01b5baecc1ad3fdf8ad23d987652496929ef30495cb116ba04a53f1b7ef2d881e8c1c1cfa1c9
7
+ data.tar.gz: fb4800f527d14823e9ed76b776056fe20a60f35fc3e08c30ce15c05758648476f8489c2d8bb89851fe2c77eeff56891b96ec955663bf08b7b6326cdf4425c881
data/.standard.yml CHANGED
@@ -9,3 +9,4 @@ ignore:
9
9
  - Layout/ArgumentAlignment
10
10
  - Style/EmptyCaseCondition
11
11
  - Style/RescueModifier
12
+ - Layout/MultilineMethodCallBraceLayout
data/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.8.0.beta2] - 2026-05-20
4
+
5
+ - Add Web Bot Auth support, implementing the current IETF draft
6
+ (draft-meunier-web-bot-auth-architecture-05).
7
+ Includes recommended signature parameter defaults, nonce generation,
8
+ and optional Signature-Agent header handling.
9
+
10
+ ## [0.8.0.beta1] - 2026-05-07
11
+
12
+ - Optimize signature parsing, serialization, and validation performance
13
+ across signing and verifying flows, improving `sign!` throughput by
14
+ 473% and `verify!` by 136% by minimizing Starry usage in hot paths
15
+ where possible.
16
+
17
+ - Add comprehensive benchmarking and profiling infrastructure across all
18
+ supported algorithms.
19
+
20
+ - Make Rack an optional dependency.
21
+
3
22
  ## [0.7.9] - 2026-04-30
4
23
 
5
24
  (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
@@ -482,6 +490,61 @@ anything that to responds to `#to_i`, including an `ActiveSupport::Duration`.
482
490
  If the signature is older than the allowed window, verification
483
491
  fails with an error.
484
492
 
493
+ ## Web Bot Auth
494
+
495
+ Linzer supports the Web Bot Auth authentication mechanism, which allows
496
+ automated clients to identify themselves using HTTP Message Signatures
497
+ (as defined in RFC 9421).
498
+
499
+ This is useful for distinguishing legitimate automated traffic from
500
+ anonymous or potentially abusive requests.
501
+
502
+ For more details on Web Bot Auth, refer to the
503
+ [relevant IETF drafts](https://datatracker.ietf.org/wg/webbotauth/documents/)
504
+ or to additional resources such as
505
+ [this Cloudflare article](https://blog.cloudflare.com/web-bot-auth/).
506
+
507
+ When enabled, as shown in the examples below, Linzer adds the required
508
+ signature headers to identify the client as an automated agent:
509
+
510
+ - Plain Linzer:
511
+
512
+ ```ruby
513
+
514
+ Linzer.sign!(
515
+ request,
516
+ key: key,
517
+ label: "sig1",
518
+ profile: :web_bot_auth
519
+ # or override/set specific parameters like the following:
520
+ # profile: Linzer::Signing::Profile.web_bot_auth(agent: "https://...")
521
+ )
522
+ ```
523
+
524
+ - http.rb gem:
525
+
526
+ ```ruby
527
+ require "linzer/http/signature_feature"
528
+
529
+ response = HTTP.headers(date: Time.now.utc.httpdate, foo: "bar")
530
+ .use(http_signature: {key: key, profile: :web_bot_auth}
531
+ .get(url)
532
+ ```
533
+
534
+ - Faraday:
535
+
536
+ ```ruby
537
+ require "linzer/faraday"
538
+
539
+ conn = Faraday.new(url: api_url) do |builder|
540
+ builder.request :http_signature, key: signing_key,
541
+ components: components,
542
+ profile: :web_bot_auth,
543
+ params: signature_params
544
+ end
545
+ response = conn.post("/task")
546
+ ```
547
+
485
548
  ## Supported algorithms
486
549
 
487
550
  Linzer currently supports the following signature algorithms:
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}"
@@ -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
@@ -107,7 +107,16 @@ module Faraday
107
107
  # @return [Boolean] when +true+ (default), raises
108
108
  # {VerifyError} on verification failure; when +false+,
109
109
  # sets +env[:http_signature_verified]+ to +false+ and continues
110
- class Options < Faraday::Options.new(:key, :sign_request, :sign_key, :components, :verify_response, :verify_key, :params, :strict)
110
+ # @!attribute [rw] profile
111
+ # Optional HTTP Message Signatures signing profile.
112
+ #
113
+ # When set, the profile is passed to {Linzer.sign!} and may provide
114
+ # default covered components and signature parameters.
115
+ #
116
+ # @return [Symbol, Linzer::Signature::Profile::Base, nil]
117
+ # a registered profile name, a profile instance, or +nil+ to use
118
+ # the default signing behavior
119
+ class Options < Faraday::Options.new(:key, :sign_request, :sign_key, :components, :verify_response, :verify_key, :params, :strict, :profile)
111
120
  # Returns the generic key.
112
121
  # @return [Linzer::Key, nil]
113
122
  def key
@@ -146,6 +155,13 @@ module Faraday
146
155
  def params
147
156
  Hash(self[:params])
148
157
  end
158
+
159
+ # Returns the signing profile configuration.
160
+ #
161
+ # @return [Symbol, Linzer::Signature::Profile::Base, nil]
162
+ def profile
163
+ self[:profile]
164
+ end
149
165
  end
150
166
 
151
167
  # Creates a new middleware instance.
@@ -186,10 +202,15 @@ module Faraday
186
202
 
187
203
  key = resolve_signing_key
188
204
  request = Linzer::Faraday::Utils.create_request(env)
189
- message = Linzer::Message.new(request)
190
205
 
191
- signature = Linzer.sign(key, message, options.components, options.params)
192
- env.request_headers.merge!(signature.to_h)
206
+ Linzer.sign! request,
207
+ key: key,
208
+ components: options.components,
209
+ params: options.params,
210
+ profile: options.profile
211
+
212
+ signature_headers = request.headers.slice("signature", "signature-input")
213
+ env.request_headers.merge!(signature_headers)
193
214
  env
194
215
  rescue Linzer::Error => e
195
216
  raise SigningError, e if options.strict?
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
- signature_base =
31
- serialized_components.each_with_object(+"") do |component, base|
32
- base << "%s\n" % signature_base_line(component, message)
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
- signature_base << signature_params_line(serialized_components, parameters)
45
+ buf << signature_params_line(serialized_components, parameters)
36
46
 
37
- signature_base
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
- def signature_params_line(serialized_components, parameters)
61
- identifiers = serialized_components.map { |c| Starry.parse_item(c) }
70
+ SERIALIZED_SIGNATURE_PARAMS = HTTP::StructuredField.serialize("@signature-params").freeze
71
+ private_constant :SERIALIZED_SIGNATURE_PARAMS
62
72
 
63
- signature_params =
64
- Starry.serialize([Starry::InnerList.new(identifiers, parameters)])
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
- "%s: %s" % [Starry.serialize("@signature-params"), signature_params]
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
- if components.include?('"@signature-params"') ||
81
- components.any? { |c| c.start_with?('"@signature-params"') }
82
- raise Error.new "Invalid component in signature input"
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
- msg = "Cannot verify signature. Missing component in message: %s"
86
- components.each do |c|
87
- raise Error.new msg % "\"#{c}\"" unless message.field?(c)
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
- msg = "Invalid signature. Duplicated component in signature input."
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
@@ -106,11 +136,11 @@ module Linzer
106
136
  .flat_map
107
137
  .with_index do |group, idx|
108
138
  group
109
- .map { |comp| Starry.parse_item(idx.zero? ? comp[1..] : comp) }
139
+ .map { |comp| HTTP::StructuredField.parse_item(idx.zero? ? comp[1..] : comp) }
110
140
  .uniq { |comp| [comp.value, comp.parameters] }
111
141
  end
112
142
 
113
- raise Error.new msg if components.count != uniq_components.count
143
+ raise Error, duplicated if components.count != uniq_components.count
114
144
  end
115
145
  end
116
146
  end
@@ -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 public?
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 private?
57
+ def compute_private?
56
58
  has_pem_private?
57
59
  end
58
60
  end