linzer 0.8.0.beta1 → 0.8.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: 0330e72cbbfb7cc56b9fa124a4f60a18bb7fbdab4eb98a072598c8fc3680cf76
4
- data.tar.gz: 321630c979fc736cc64284eb535b22752e50a42c8bbfae399cbf0db56efecdd3
3
+ metadata.gz: 47932906cedb5dd66d7d2bbc5e8eaac9f01b4e17dd7089bc87b4a06b9505b366
4
+ data.tar.gz: abe9bf21ac6d66a08f229de3fb10e77bc911c112f812cd0cf620d9f3947d580e
5
5
  SHA512:
6
- metadata.gz: e6e99bf6a409b960d0432f702b286f7b71b06a34b61ff242d2d75eebc9c125bc02c6daa573efc5af69dfeb7c2f788811fb91d2f6f6decd1a869db26e6a5462e6
7
- data.tar.gz: 9c76877de5ba5164f677aad7be9ffb3f905f2fd874523b9f2aaff287abd0ebdd8d8c7272da22eddfcacb56a7eb325392e52e1b2a3b551e8940627b146190a895
6
+ metadata.gz: b4352492dedad2bd9881b9bc6ac5888cdf7855a798f2b650040a9a64c4f7ea81c34178a4bfadb77f5953d82498f1eea69c90529d8b3888d9d182d3896e8415ed
7
+ data.tar.gz: b2540b62b29a07666c66969f5d6843f674994747fe80e47e71c0ba7f03ad554ccfbd1fcfefca796b7115f6fa2643f2b57b4d058de755c499a87ac87f883a4282
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,49 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.8.0] - 2026-08-08
4
+
5
+ (Only one change since the last beta: the Faraday bug fix below. Everything
6
+ else shipped in earlier 0.8.0 betas.)
7
+
8
+ - Optimize signature parsing, serialization, and validation performance
9
+ across signing and verifying flows, improving `sign!` throughput by
10
+ 473% and `verify!` by 136% by minimizing Starry usage in hot paths
11
+ where possible.
12
+
13
+ - Add comprehensive benchmarking and profiling infrastructure across all
14
+ supported algorithms.
15
+
16
+ - Make Rack an optional dependency.
17
+
18
+ - Add Web Bot Auth support, implementing the current IETF draft
19
+ (draft-meunier-web-bot-auth-architecture-05).
20
+ Includes recommended signature parameter defaults, nonce generation,
21
+ and optional Signature-Agent header handling.
22
+
23
+ - Fix Faraday adapter deriving URI components from a re-encoded query
24
+ string, which could differ from the URI faraday actually sent and fail
25
+ verification at the origin. Affected `@target-uri`, `@request-target`
26
+ and `@query`. The query is now encoded with the request's own
27
+ `params_encoder`, so it tracks the URL faraday puts on the wire:
28
+
29
+ - Spaces no longer always encode as `+`. Faraday honours the
30
+ process-wide `Faraday::Utils.default_space_encoding`, which other
31
+ gems may set (the `oauth2` gem sets it to `"%20"` on load), so a
32
+ query value containing a space signed a URI the verifier could not
33
+ reproduce.
34
+
35
+ - A request with no query parameters no longer appends a stray `?`.
36
+
37
+ - A `params_encoder` configured on the connection or request is now
38
+ preserved, instead of being replaced with the default encoder.
39
+
40
+ ## [0.8.0.beta2] - 2026-05-20
41
+
42
+ - Add Web Bot Auth support, implementing the current IETF draft
43
+ (draft-meunier-web-bot-auth-architecture-05).
44
+ Includes recommended signature parameter defaults, nonce generation,
45
+ and optional Signature-Agent header handling.
46
+
3
47
  ## [0.8.0.beta1] - 2026-05-07
4
48
 
5
49
  - Optimize signature parsing, serialization, and validation performance
data/README.md CHANGED
@@ -490,6 +490,61 @@ anything that to responds to `#to_i`, including an `ActiveSupport::Duration`.
490
490
  If the signature is older than the allowed window, verification
491
491
  fails with an error.
492
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
+
493
548
  ## Supported algorithms
494
549
 
495
550
  Linzer currently supports the following signature algorithms:
@@ -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
@@ -67,7 +67,7 @@ module Linzer
67
67
  # @param serialized_components [Array<String>] The covered components
68
68
  # @param parameters [Hash] Signature parameters
69
69
  # @return [String] The formatted @signature-params line
70
- SERIALIZED_SIGNATURE_PARAMS = Starry.serialize("@signature-params").freeze
70
+ SERIALIZED_SIGNATURE_PARAMS = HTTP::StructuredField.serialize("@signature-params").freeze
71
71
  private_constant :SERIALIZED_SIGNATURE_PARAMS
72
72
 
73
73
  def signature_params_line(serialized_components, parameters)
@@ -136,7 +136,7 @@ module Linzer
136
136
  .flat_map
137
137
  .with_index do |group, idx|
138
138
  group
139
- .map { |comp| Starry.parse_item(idx.zero? ? comp[1..] : comp) }
139
+ .map { |comp| HTTP::StructuredField.parse_item(idx.zero? ? comp[1..] : comp) }
140
140
  .uniq { |comp| [comp.value, comp.parameters] }
141
141
  end
142
142
 
@@ -14,13 +14,20 @@ module Linzer
14
14
  # {Linzer::Message::Adapter::Faraday::Request}, preserving the
15
15
  # original HTTP method, URL, and headers from the environment.
16
16
  #
17
+ # Preserves the environment's request options when present, so that
18
+ # a +params_encoder+ configured on the connection or request is
19
+ # carried over. The adapter re-encodes query parameters when
20
+ # deriving +@target-uri+ and friends, and must use the same encoder
21
+ # Faraday used to write the URL, or the signed value will not match
22
+ # the URI actually sent.
23
+ #
17
24
  # @param env [::Faraday::Env] the middleware environment
18
25
  # @return [::Faraday::Request] a new request object
19
26
  def self.create_request(env)
20
27
  ::Faraday::Request.create(env.method) do |req|
21
28
  req.params = ::Faraday::Utils::ParamsHash.new
22
29
  req.headers = ::Faraday::Utils::Headers.new(env.request_headers.dup)
23
- req.options = ::Faraday::ConnectionOptions.from(nil).request
30
+ req.options = env.request || ::Faraday::ConnectionOptions.from(nil).request
24
31
  req.url env.url
25
32
  end
26
33
  end
data/lib/linzer/helper.rb CHANGED
@@ -16,12 +16,22 @@ module Linzer
16
16
  #
17
17
  # @param request_or_response [Net::HTTPRequest, Net::HTTPResponse, Rack::Request,
18
18
  # Rack::Response, HTTP::Request] The HTTP message to sign
19
- # @param args [Hash] Keyword arguments
20
- # @option args [Linzer::Key] :key The private key to sign with (required)
21
- # @option args [Array<String>] :components The components to include in the
22
- # signature (required). Example: `%w[@method @path content-type]`
23
- # @option args [String] :label Optional signature label (defaults to "sig1")
24
- # @option args [Hash] :params Additional signature parameters (created, nonce, etc.)
19
+ #
20
+ # @param key [Linzer::Key]
21
+ # The private key to sign with (required)
22
+ #
23
+ # @param components [Array<String>]
24
+ # The components to include in the signature (required).
25
+ # Example: `%w[@method @path content-type]`
26
+ #
27
+ # @param label [String, nil]
28
+ # Optional signature label (defaults to "sig1")
29
+ #
30
+ # @param params [Hash]
31
+ # Additional signature parameters (created, nonce, etc.)
32
+ #
33
+ # @param profile [Symbol, Linzer::Signature::Profile::Base, nil]
34
+ # Optional signing profile
25
35
  #
26
36
  # @return [Object] The original HTTP message with signature headers attached
27
37
  #
@@ -46,17 +56,26 @@ module Linzer
46
56
  # label: "my-sig",
47
57
  # params: { nonce: SecureRandom.hex(16), tag: "my-app" }
48
58
  # )
49
- def sign!(request_or_response, **args)
50
- message = Message.new(request_or_response)
51
- options = {}
59
+ def sign!(request_or_response, key:, components: nil, label: nil, params: {}, profile: nil)
60
+ ctx = Signature::Context.new(
61
+ message: Message.new(request_or_response),
62
+ key: key,
63
+ label: label,
64
+ components: Array(components),
65
+ params: Hash(params)
66
+ )
67
+
68
+ resolved_profile = Signature::Profile.resolve(profile)
69
+ resolved_profile&.apply(ctx)
52
70
 
53
- label = args[:label]
54
- options[:label] = label if label
55
- options.merge!(args.fetch(:params, {}))
71
+ signature = Linzer::Signer.sign(
72
+ ctx.key,
73
+ ctx.message,
74
+ ctx.components,
75
+ ctx.params
76
+ )
56
77
 
57
- key = args.fetch(:key)
58
- signature = Linzer::Signer.sign(key, message, args.fetch(:components), options)
59
- message.attach!(signature)
78
+ ctx.message.attach!(signature)
60
79
  end
61
80
 
62
81
  # Verifies a signed HTTP request or response.
@@ -53,12 +53,17 @@ module Linzer
53
53
  # @param covered_components [Array<String>] Components to include
54
54
  # in the signature. Defaults to `@method`, `@request-target`,
55
55
  # `@authority`, and `date`.
56
+ # @param profile [Symbol, Linzer::Signature::Profile::Base, nil]
57
+ # Optional signing profile used when generating signatures.
58
+ # When provided, the profile may supply default covered components
59
+ # and signature parameters.
56
60
  #
57
61
  # @raise [HTTP::Error] If key is nil or invalid
58
- def initialize(key:, params: {}, covered_components: default_components)
62
+ def initialize(key:, params: {}, covered_components: default_components, profile: nil)
59
63
  @fields = Array(covered_components)
60
64
  @key = validate_key(key)
61
65
  @params = Hash(params)
66
+ @profile = profile
62
67
  end
63
68
 
64
69
  # @return [Array<String>] The components to include in signatures
@@ -67,6 +72,10 @@ module Linzer
67
72
  # @return [Hash] Additional signature parameters
68
73
  attr_reader :params
69
74
 
75
+ # @return [Linzer::Signature::Profile::Base, Symbol, nil]
76
+ # Optional signing profile used during signature generation
77
+ attr_reader :profile
78
+
70
79
  # Wraps an outgoing request to add signature headers.
71
80
  #
72
81
  # Called automatically by http.rb for each request.
@@ -74,9 +83,11 @@ module Linzer
74
83
  # @param request [HTTP::Request] The outgoing request
75
84
  # @return [HTTP::Request] The request with signature headers added
76
85
  def wrap_request(request)
77
- message = Linzer::Message.new(request)
78
- signature = Linzer.sign(key, message, fields, **params)
79
- request.headers.merge!(signature.to_h)
86
+ Linzer.sign! request,
87
+ key: key,
88
+ components: fields,
89
+ params: params,
90
+ profile: profile
80
91
  request
81
92
  end
82
93
 
@@ -10,38 +10,135 @@ module Linzer
10
10
  # @see https://www.rfc-editor.org/rfc/rfc8941 RFC 8941
11
11
  # @see https://www.rfc-editor.org/rfc/rfc9421 RFC 9421
12
12
  module StructuredField
13
- # Serializes signature parameters to the RFC 8941 string format.
13
+ InnerList = Starry::InnerList
14
+ Item = Starry::Item
15
+
16
+ # Parses an RFC 8941 Structured Field Dictionary.
17
+ #
18
+ # @param str [String] the serialized dictionary value
19
+ # @param field_name [String, nil] optional field name for contextual errors
20
+ # @return [Hash<String, Object>] parsed structured field dictionary
21
+ # @raise [Linzer::Error] if the field cannot be parsed
22
+ #
23
+ # @see https://www.rfc-editor.org/rfc/rfc8941 RFC 8941
24
+ def self.parse_dictionary(str, field_name: nil)
25
+ Starry.parse_dictionary(str)
26
+ # rescue Starry::ParseError => ex
27
+ # https://github.com/takemar/starry/pull/4
28
+ rescue => ex
29
+ cannot_parse = "Cannot parse %sfield!"
30
+ raise Error,
31
+ cannot_parse % [field_name ? "\"#{field_name}\" " : nil],
32
+ cause: ex
33
+ end
34
+
35
+ # Parses an RFC 8941 Structured Field Item.
36
+ #
37
+ # @param item [String] serialized structured field item
38
+ # @return [Starry::Item] parsed structured field item
39
+ # @raise [Linzer::Error] if the item is invalid or unparseable
40
+ #
41
+ # @see https://www.rfc-editor.org/rfc/rfc8941 RFC 8941
42
+ def self.parse_item(item)
43
+ Starry.parse_item(item)
44
+ # rescue Starry::ParseError => ex
45
+ # https://github.com/takemar/starry/pull/4
46
+ rescue => ex
47
+ raise Error, "Invalid/unparseable HTTP field item", cause: ex
48
+ end
49
+
50
+ # Parses an RFC 8941 Structured Field List.
51
+ #
52
+ # @param list [String] serialized structured field list
53
+ # @return [Array<Object>] parsed structured field list members
54
+ # @raise [Linzer::Error] if the list is invalid or unparseable
55
+ #
56
+ # @see https://www.rfc-editor.org/rfc/rfc8941 RFC 8941
57
+ def self.parse_list(list)
58
+ Starry.parse_list(list)
59
+ # rescue Starry::ParseError => ex
60
+ # https://github.com/takemar/starry/pull/4
61
+ rescue => ex
62
+ raise Error, "Invalid/unparseable HTTP field list", cause: ex
63
+ end
64
+
65
+ # Serializes a dictionary into RFC 8941 Structured Field format.
66
+ #
67
+ # @param hsh [Hash] structured field dictionary
68
+ # @return [String] serialized structured field dictionary
69
+ # @raise [Linzer::Error] if the dictionary cannot be serialized
14
70
  #
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).
71
+ # @see https://www.rfc-editor.org/rfc/rfc8941 RFC 8941
72
+ def self.serialize_dictionary(hsh)
73
+ Starry.serialize_dictionary(hsh)
74
+ # rescue Starry::SerializeError => ex
75
+ # https://github.com/takemar/starry/pull/4
76
+ rescue => ex
77
+ raise Error, ex.message, cause: ex
78
+ end
79
+
80
+ # Serializes a list into RFC 8941 Structured Field format.
81
+ #
82
+ # @param arr [Array] structured field list members
83
+ # @return [String] serialized structured field list
84
+ # @raise [Linzer::Error] if the list cannot be serialized
85
+ #
86
+ # @see https://www.rfc-editor.org/rfc/rfc8941 RFC 8941
87
+ def self.serialize_list(arr)
88
+ Starry.serialize_list(arr)
89
+ # rescue Starry::SerializeError => ex
90
+ # https://github.com/takemar/starry/pull/4
91
+ rescue => ex
92
+ raise Error, ex.message, cause: ex
93
+ end
94
+
95
+ # Serializes an object into RFC 8941 Structured Field format.
96
+ #
97
+ # @param obj [Object] structured field value
98
+ # @return [String] serialized structured field value
99
+ # @raise [Linzer::Error] if the object cannot be serialized
18
100
  #
19
- # @example Serialize signature parameters
20
- # StructuredField.serialize_parameters(
21
- # created: 1700000000,
22
- # keyid: "my-key"
23
- # )
24
- # # => ';created=1700000000;keyid="my-key"'
101
+ # @see https://www.rfc-editor.org/rfc/rfc8941 RFC 8941
102
+ def self.serialize(obj)
103
+ Starry.serialize(obj)
104
+ rescue Starry::SerializeError => ex
105
+ raise Error, ex.message, cause: ex
106
+ end
107
+
108
+ # Serializes a Structured Field Item into RFC 8941 format.
109
+ #
110
+ # This helper serializes a single Structured Field Item, such as
111
+ # a string, integer, token, boolean, date, byte sequence, or
112
+ # an already constructed {Starry::Item}.
113
+ #
114
+ # @param item [Object] structured field item value
115
+ # @return [String] serialized structured field item
116
+ # @raise [Linzer::Error] if the item cannot be serialized
117
+ #
118
+ # @see https://www.rfc-editor.org/rfc/rfc8941 RFC 8941
119
+ def self.serialize_item(item)
120
+ Starry.serialize_item(item)
121
+ rescue Starry::SerializeError => ex
122
+ raise Error, ex.message, cause: ex
123
+ end
124
+
125
+ # Serializes Structured Field Parameters into RFC 8941 format.
25
126
  #
26
- # @param parameters [Hash{Symbol,String => Object}]
27
- # The parameters to serialize.
127
+ # Parameters are serialized according to the Structured Fields
128
+ # parameter syntax defined in RFC 8941 Section 3.1.2.
28
129
  #
29
- # @return [String]
30
- # The serialized structured field parameter string.
130
+ # @param parameters [Hash{String,Symbol => Object}] parameter names
131
+ # and values
132
+ # @return [String] serialized structured field parameters
133
+ # @raise [Linzer::Error] if the parameters cannot be serialized
31
134
  #
135
+ # @see https://www.rfc-editor.org/rfc/rfc8941 RFC 8941
32
136
  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
137
+ Starry.serialize_parameters(parameters)
138
+ # rescue Starry::SerializeError => ex
139
+ # https://github.com/takemar/starry/pull/4
140
+ rescue => ex
141
+ raise Error, ex.message, cause: ex
45
142
  end
46
143
  end
47
144
  end
data/lib/linzer/http.rb CHANGED
@@ -100,12 +100,18 @@ module Linzer
100
100
 
101
101
  headers = build_headers(options[:headers] || {})
102
102
  request = build_request(verb, uri, headers)
103
- message = Linzer::Message.new(request)
104
103
  components = options[:covered_components] || default_components
105
104
  params = options[:params] || {}
106
- signature = Linzer.sign(key, message, components, **params)
107
105
 
108
- do_request(http, uri, verb, options[:data], signature, headers)
106
+ Linzer.sign! request,
107
+ key: key,
108
+ components: components,
109
+ params: params,
110
+ profile: options[:profile]
111
+
112
+ signature_headers = request.each_header.to_h.slice("signature-input", "signature")
113
+
114
+ do_request(http, uri, verb, options[:data], signature_headers, headers)
109
115
  end
110
116
 
111
117
  # Returns the default covered components for signing.
@@ -180,19 +186,19 @@ module Linzer
180
186
  # @param uri [String] the request URI
181
187
  # @param verb [Symbol] the HTTP method
182
188
  # @param data [String, nil] the request body
183
- # @param signature [Linzer::Signature] the generated signature
189
+ # @param signature_headers [Hash] the generated signature headers
184
190
  # @param headers [Hash] request headers
185
191
  # @return [Net::HTTPResponse] the response
186
192
  # @raise [Linzer::Error] if a body is required but not provided
187
- def do_request(http, uri, verb, data, signature, headers)
193
+ def do_request(http, uri, verb, data, signature_headers, headers)
188
194
  if with_body?(verb)
189
195
  if !data
190
196
  missed_body = "Missing request body on HTTP request: '#{verb.upcase}'"
191
197
  raise Linzer::Error, missed_body
192
198
  end
193
- http.public_send(verb, uri, data, headers.merge(signature.to_h))
199
+ http.public_send(verb, uri, data, headers.merge(signature_headers))
194
200
  else
195
- http.public_send(verb, uri, headers.merge(signature.to_h))
201
+ http.public_send(verb, uri, headers.merge(signature_headers))
196
202
  end
197
203
  end
198
204
  end
@@ -97,26 +97,35 @@ module Linzer
97
97
  # Attaches a signature to the underlying HTTP message.
98
98
  #
99
99
  # @param signature [Signature] The signature to attach
100
+ # @param additional_headers [#each]
101
+ # Additional headers to attach after signature processing.
102
+ # Header values overwrite existing values with the same field name.
103
+ #
100
104
  # @return [Object] The underlying HTTP message
101
- def attach!(signature)
105
+ def attach!(signature, additional_headers: {})
102
106
  signature_headers = signature.to_h
103
107
 
104
- unless has_signature?
108
+ if !has_signature?
105
109
  signature_headers.each { |h, v| set_header!(h, v) }
106
- return @operation
110
+ else
111
+ begin
112
+ signature_headers.each do |hdr, value|
113
+ merged = HTTP::StructuredField.parse_dictionary(String(header(hdr)))
114
+ merged.merge!(HTTP::StructuredField.parse_dictionary(value))
115
+ set_header!(hdr, HTTP::StructuredField.serialize_dictionary(merged))
116
+ end
117
+ rescue Error => ex
118
+ raise Error,
119
+ "Cannot attach signature, invalid signature header(s)!",
120
+ cause: ex
121
+ end
107
122
  end
108
123
 
109
- signature_headers.each do |hdr, value|
110
- merged = Starry.parse_dictionary(String(header(hdr)))
111
- merged.merge!(Starry.parse_dictionary(value))
112
- set_header!(hdr, Starry.serialize_dictionary(merged))
124
+ if !additional_headers.empty?
125
+ additional_headers.each { |h, v| set_header!(h, v) }
113
126
  end
114
127
 
115
128
  @operation
116
- rescue Starry::ParseError => e
117
- raise Error,
118
- "Cannot attach signature, invalid signature header(s)!",
119
- cause: e
120
129
  end
121
130
 
122
131
  private
@@ -201,8 +210,10 @@ module Linzer
201
210
  has_bs = name.parameters["bs"]
202
211
 
203
212
  if has_req
204
- name.parameters.delete("req")
205
- return req(name, method)
213
+ request_field =
214
+ HTTP::StructuredField::Item.new(name.value,
215
+ name.parameters.except("req"))
216
+ return req(request_field, method)
206
217
  end
207
218
 
208
219
  value = send(method, name)
@@ -223,14 +234,16 @@ module Linzer
223
234
  # @return [String] the serialized structured field value
224
235
  # @see https://www.rfc-editor.org/rfc/rfc9421.html#section-2.1.1
225
236
  def sf(value, key = nil)
226
- dict = Starry.parse_dictionary(value)
237
+ dict = HTTP::StructuredField.parse_dictionary(value)
227
238
 
228
239
  if key
229
240
  obj = dict[key]
230
- Starry.serialize(obj.is_a?(Starry::InnerList) ? [obj] : obj)
241
+ HTTP::StructuredField.serialize(obj.is_a?(HTTP::StructuredField::InnerList) ? [obj] : obj)
231
242
  else
232
- Starry.serialize(dict)
243
+ HTTP::StructuredField.serialize(dict)
233
244
  end
245
+ rescue Error => _ex
246
+ nil
234
247
  end
235
248
 
236
249
  # Binary-wraps a field value as a byte sequence.
@@ -239,7 +252,7 @@ module Linzer
239
252
  # @return [String] the serialized byte sequence
240
253
  # @see https://www.rfc-editor.org/rfc/rfc9421.html#section-2.1.3
241
254
  def bs(value)
242
- Starry.serialize(value.encode(Encoding::ASCII_8BIT))
255
+ HTTP::StructuredField.serialize(value.encode(Encoding::ASCII_8BIT))
243
256
  end
244
257
 
245
258
  # Retrieves a trailer field value.
@@ -37,10 +37,24 @@ module Linzer
37
37
 
38
38
  # Builds the full URI including query parameters.
39
39
  #
40
+ # Encodes the query with the request's own +params_encoder+,
41
+ # mirroring how faraday builds the URL it puts on the wire
42
+ # (+Faraday::Connection#build_env+). Encoding it independently
43
+ # here would diverge from the URI actually sent: notably
44
+ # +URI.encode_www_form+ always encodes a space as +++, while
45
+ # faraday honours the process-wide
46
+ # +Faraday::Utils.default_space_encoding+, which other gems may
47
+ # set to +%20+. The signer would then cover a +@target-uri+ the
48
+ # verifier cannot reproduce from the request it received.
49
+ #
50
+ # An empty query is left as +nil+ rather than +""+, as assigning
51
+ # +""+ would append a stray +?+ that faraday does not send.
52
+ #
40
53
  # @return [URI] the complete request URI with encoded query string
41
54
  def uri
42
55
  uri = @operation.path.dup
43
- uri.query = URI.encode_www_form(@operation.params)
56
+ query = @operation.params.to_query(@operation.options.params_encoder)
57
+ uri.query = query unless query.empty?
44
58
  uri
45
59
  end
46
60
 
@@ -72,6 +72,7 @@ module Linzer
72
72
  end
73
73
 
74
74
  def query_param(uri_query, name)
75
+ return nil if !uri_query
75
76
  param_name = name.parameters["name"]
76
77
  return nil if !param_name
77
78
  decoded_param_name = URI.decode_uri_component(param_name)