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
@@ -26,12 +26,14 @@ module Linzer
26
26
  # @see https://www.rfc-editor.org/rfc/rfc9421.html#section-4 RFC 9421 Section 4
27
27
  class Signature
28
28
  # @api private
29
- # Use {.build} to create Signature instances.
30
- def initialize(metadata, value, label, parameters = {})
31
- @metadata = metadata.clone.freeze
32
- @value = value.clone.freeze
33
- @parameters = parameters.clone.freeze
34
- @label = label.clone.freeze
29
+ # Use {.build} or {.from_components} to create Signature instances.
30
+ def initialize(metadata, value, label, parameters = {}, parsed_items: nil, headers: nil)
31
+ @metadata = metadata.clone.freeze
32
+ @value = value.clone.freeze
33
+ @parameters = parameters.clone.freeze
34
+ @label = label.clone.freeze
35
+ @parsed_items = parsed_items&.freeze
36
+ @headers = headers&.freeze
35
37
  freeze
36
38
  end
37
39
 
@@ -74,6 +76,20 @@ module Linzer
74
76
  FieldId.deserialize_components(serialized_components)
75
77
  end
76
78
 
79
+ # Builds FieldId objects for each covered component.
80
+ #
81
+ # Uses {parsed_items} when available to create {FastIdentifier} objects
82
+ # that bypass Starry re-parsing. Falls back to constructing full
83
+ # {FieldId} objects from the serialized strings.
84
+ #
85
+ # Returns a fresh array each time because some adapter methods may
86
+ # mutate item parameters during field lookup (e.g., deleting "req").
87
+ #
88
+ # @return [Array<FastIdentifier, FieldId>] FieldId objects for each component
89
+ def field_ids
90
+ build_field_ids
91
+ end
92
+
77
93
  # Returns the signature creation timestamp.
78
94
  #
79
95
  # @return [Integer, nil] Unix timestamp when the signature was created,
@@ -132,20 +148,60 @@ module Linzer
132
148
  # @example Attaching to a Net::HTTP request
133
149
  # signature.to_h.each { |name, value| request[name] = value }
134
150
  def to_h
151
+ return @headers if @headers
152
+
153
+ items = @parsed_items || serialized_components.map { |c| HTTP::StructuredField.parse_item(c) }
135
154
  {
136
- "signature" => Starry.serialize({label => value}),
137
- "signature-input" => Starry.serialize({
138
- label => Starry::InnerList.new(
139
- serialized_components.map { |c| Starry.parse_item(c) },
140
- parameters
141
- )
155
+ "signature" => HTTP::StructuredField.serialize({label => value}),
156
+ "signature-input" => HTTP::StructuredField.serialize({
157
+ label => HTTP::StructuredField::InnerList.new(items, parameters)
142
158
  })
143
159
  }
144
160
  end
145
161
 
162
+ private
163
+
164
+ def build_field_ids
165
+ if @parsed_items && @parsed_items.size == @metadata.size
166
+ @metadata.each_with_index.map do |serialized, i|
167
+ item = @parsed_items[i]
168
+ # Clone items that have parameters since the adapter's retrieve
169
+ # method may mutate parameters (e.g., deleting "req").
170
+ unless item.parameters.empty?
171
+ item = HTTP::StructuredField::Item.new(item.value, item.parameters.dup)
172
+ end
173
+ Message::Field::FastIdentifier.new(serialized, item)
174
+ end
175
+ else
176
+ @metadata.map { |c| FieldId.new(field_name: c) }
177
+ end
178
+ end
179
+
146
180
  class << self
147
181
  private :new
148
182
 
183
+ # Creates a Signature directly from its constituent parts.
184
+ #
185
+ # This avoids the serialize-then-parse round-trip when the caller
186
+ # (e.g. {Signer.sign}) already has all the data.
187
+ #
188
+ # @api private
189
+ # @param components [Array<String>] Serialized component identifiers
190
+ # @param raw_signature [String] The raw signature bytes
191
+ # @param label [String] The signature label
192
+ # @param parameters [Hash] Signature parameters (symbol keys)
193
+ # @param parsed_items [Array<Starry::Item>] Pre-parsed component items
194
+ # @param headers [Hash] Pre-serialized header strings
195
+ # @return [Signature] The constructed signature
196
+ def from_components(components:, raw_signature:, label:, parameters:, parsed_items:, headers:)
197
+ # Signature stores parameters with string keys (as produced by Starry
198
+ # parsing). Convert symbol keys from Signer to match.
199
+ string_params = {}
200
+ parameters.each { |k, v| string_params[k.to_s] = v }
201
+ new(components, raw_signature, label, string_params,
202
+ parsed_items: parsed_items, headers: headers)
203
+ end
204
+
149
205
  # Builds a Signature from HTTP headers.
150
206
  #
151
207
  # Parses the `signature` and `signature-input` headers according to
@@ -178,25 +234,26 @@ module Linzer
178
234
  def build(headers, options = {})
179
235
  basic_validate headers
180
236
  headers.transform_keys!(&:downcase)
237
+ headers.transform_values! { |v| v.encode(Encoding::ASCII) }
181
238
  validate headers
182
239
 
183
- input = parse_structured_field(headers, "signature-input")
240
+ input = HTTP::StructuredField.parse_dictionary(
241
+ headers["signature-input"],
242
+ field_name: "signature-input"
243
+ )
244
+
184
245
  reject_multiple_signatures if input.size > 1 && options[:label].nil?
185
246
  label = options[:label] || input.keys.first
186
247
 
187
- signature = parse_structured_field(headers, "signature")
188
- fail_with_signature_not_found label unless signature.key?(label)
189
-
190
- raw_signature =
191
- signature[label].value
192
- .force_encoding(Encoding::ASCII_8BIT)
248
+ raw_signature = extract_raw_signature(headers["signature"], label)
193
249
 
194
250
  fail_due_invalid_components unless input[label].value.respond_to?(:each)
195
251
 
196
- components = input[label].value.map { |c| Starry.serialize_item(c) }
252
+ parsed_items = input[label].value
253
+ components = serialize_parsed_items(parsed_items)
197
254
  parameters = input[label].parameters
198
255
 
199
- new(components, raw_signature, label, parameters)
256
+ new(components, raw_signature, label, parameters, parsed_items: parsed_items)
200
257
  end
201
258
 
202
259
  private
@@ -223,19 +280,77 @@ module Linzer
223
280
  raise Error.new "Unexpected value for covered components."
224
281
  end
225
282
 
226
- def parse_structured_dictionary(str, field_name = nil)
227
- Starry.parse_dictionary(str)
228
- rescue Starry::ParseError => _
229
- raise Error.new "Cannot parse \"#{field_name}\" field. Bailing out!"
283
+ # Extracts the raw signature bytes for a given label from the
284
+ # signature header without a full Starry dictionary parse.
285
+ #
286
+ # The signature header format is: label=:base64data:
287
+ # For multiple signatures: label1=:b64:, label2=:b64:
288
+ #
289
+ # Falls back to full Starry parsing for non-trivial cases.
290
+ #
291
+ # @param header_value [String] the raw signature header
292
+ # @param label [String] the signature label to extract
293
+ # @return [String] the decoded signature bytes (ASCII-8BIT)
294
+ # @raise [Error] if the label is not found or decoding fails
295
+ def extract_raw_signature(header_value, label)
296
+ value = header_value.is_a?(String) ? header_value : header_value.to_s
297
+ prefix = "#{label}=:"
298
+
299
+ # Fast path: single signature (most common case)
300
+ if value.start_with?(prefix)
301
+ end_idx = value.index(":", prefix.length)
302
+ if end_idx
303
+ encoded = value[prefix.length...end_idx]
304
+ return Base64.strict_decode64(encoded)
305
+ .force_encoding(Encoding::ASCII_8BIT)
306
+ end
307
+ end
308
+
309
+ # Multi-signature or unusual format: find the right entry
310
+ # Split on comma-separated dictionary members
311
+ value.split(",").each do |entry|
312
+ entry = entry.strip
313
+ if entry.start_with?(prefix)
314
+ end_idx = entry.index(":", prefix.length)
315
+ if end_idx
316
+ encoded = entry[prefix.length...end_idx]
317
+ return Base64.strict_decode64(encoded)
318
+ .force_encoding(Encoding::ASCII_8BIT)
319
+ end
320
+ end
321
+ end
322
+
323
+ # Label not found via fast path — fall back to Starry
324
+ signature = HTTP::StructuredField.parse_dictionary(
325
+ value.encode(Encoding::US_ASCII),
326
+ field_name: "signature"
327
+ )
328
+ fail_with_signature_not_found label unless signature.key?(label)
329
+ signature[label].value.force_encoding(Encoding::ASCII_8BIT)
330
+ rescue ArgumentError
331
+ # Base64 decode failed — fall back to Starry
332
+ signature = HTTP::StructuredField.parse_dictionary(
333
+ value.encode(Encoding::US_ASCII),
334
+ field_name: "signature")
335
+ fail_with_signature_not_found label unless signature.key?(label)
336
+ signature[label].value.force_encoding(Encoding::ASCII_8BIT)
230
337
  end
231
338
 
232
- # Parses a structured field value as a dictionary.
233
- # @see https://datatracker.ietf.org/doc/html/rfc8941 RFC 8941
234
- def parse_structured_field(hsh, field_name)
235
- # Serialized Structured Field values for HTTP are ASCII strings.
236
- # See: RFC 8941 (https://datatracker.ietf.org/doc/html/rfc8941)
237
- value = hsh[field_name].encode(Encoding::US_ASCII)
238
- parse_structured_dictionary(value, field_name)
339
+ # Serializes parsed structured field items to their RFC 8941
340
+ # string representations.
341
+ #
342
+ # Serialization is delegated to `Starry.serialize_item` to ensure
343
+ # consistent RFC-compliant formatting of structured field items and
344
+ # parameters.
345
+ #
346
+ # @param items [Array<Starry::Item>]
347
+ # Parsed structured field items.
348
+ #
349
+ # @return [Array<String>]
350
+ # The serialized structured field item representations.
351
+ #
352
+ def serialize_parsed_items(items)
353
+ items.map { |item| HTTP::StructuredField.serialize_item(item) }
239
354
  end
240
355
  end
241
356
  end
data/lib/linzer/signer.rb CHANGED
@@ -68,16 +68,31 @@ module Linzer
68
68
  # tag: "my-app"
69
69
  # )
70
70
  def sign(key, message, components, options = {})
71
- serialized_components = FieldId.serialize_components(Array(components))
72
- validate key, message, serialized_components
71
+ serialized_components, field_ids =
72
+ FieldId.serialize_components_with_field_ids(Array(components))
73
+ validate key, message, serialized_components, field_ids: field_ids
74
+
75
+ # Reuse the already-parsed items from field_ids
76
+ parsed_items = field_ids.map(&:item)
73
77
 
74
78
  parameters = populate_parameters(key, options)
75
- signature_base = signature_base(message, serialized_components, parameters)
79
+ signature_base = signature_base(message, serialized_components, parameters,
80
+ field_ids: field_ids)
76
81
 
77
- signature = key.sign(signature_base)
82
+ raw_signature = key.sign(signature_base)
78
83
  label = options[:label] || DEFAULT_LABEL
79
84
 
80
- Linzer::Signature.build(serialize(signature, serialized_components, parameters, label))
85
+ # Build the signature directly, bypassing the serialize -> parse round-trip
86
+ headers = serialize(raw_signature, serialized_components, parameters, label)
87
+
88
+ Linzer::Signature.from_components(
89
+ components: serialized_components,
90
+ raw_signature: raw_signature,
91
+ label: label,
92
+ parameters: parameters,
93
+ parsed_items: parsed_items,
94
+ headers: headers
95
+ )
81
96
  end
82
97
 
83
98
  # Returns the default signature label.
@@ -91,19 +106,22 @@ module Linzer
91
106
 
92
107
  # Validates signing inputs.
93
108
  # @raise [SigningError] If any input is invalid
94
- def validate(key, message, components)
109
+ def validate(key, message, components, field_ids: nil)
95
110
  msg = "Message cannot be signed with null %s"
96
111
  raise SigningError, msg % "value" if message.nil?
97
112
  raise SigningError, msg % "key" if key.nil?
98
113
  raise SigningError, msg % "component" if components.nil?
99
114
 
100
115
  begin
101
- validate_components message, components
116
+ validate_components message, components, field_ids: field_ids
102
117
  rescue Error => ex
103
118
  raise SigningError, ex.message, cause: ex
104
119
  end
105
120
  end
106
121
 
122
+ RESERVED_OPTIONS = %i[created keyid label].freeze
123
+ private_constant :RESERVED_OPTIONS
124
+
107
125
  # Builds the signature parameters hash from options and key.
108
126
  # @return [Hash] The populated parameters
109
127
  def populate_parameters(key, options)
@@ -114,22 +132,25 @@ module Linzer
114
132
  key_id = options[:keyid] || (key.key_id if key.respond_to?(:key_id))
115
133
  parameters[:keyid] = key_id unless key_id.nil?
116
134
 
117
- (options.keys - %i[created keyid label]).each { |k| parameters[k] = options[k] }
135
+ options.each { |k, v| parameters[k] = v unless RESERVED_OPTIONS.include?(k) }
118
136
 
119
137
  parameters
120
138
  end
121
139
 
122
140
  # Serializes the signature into HTTP header format.
141
+ #
142
+ # Uses direct string building instead of Starry.serialize to avoid
143
+ # the overhead of generic structured field serialization.
144
+ #
123
145
  # @return [Hash] Hash with "signature" and "signature-input" keys
124
146
  def serialize(signature, components, parameters, label)
147
+ sig_b64 = Base64.strict_encode64(signature)
148
+ input_params = HTTP::StructuredField.serialize_parameters(parameters)
149
+ components_str = components.join(" ")
150
+
125
151
  {
126
- "signature" => Starry.serialize({label => signature}),
127
- "signature-input" =>
128
- Starry.serialize(label =>
129
- Starry::InnerList.new(
130
- components.map { |c| Starry.parse_item(c) },
131
- parameters
132
- ))
152
+ "signature" => "#{label}=:#{sig_b64}:",
153
+ "signature-input" => "#{label}=(#{components_str})#{input_params}"
133
154
  }
134
155
  end
135
156
  end
@@ -59,7 +59,12 @@ module Linzer
59
59
  parameters = signature.parameters
60
60
  serialized_components = signature.serialized_components
61
61
 
62
- signature_base = signature_base(message, serialized_components, parameters)
62
+ # Build fresh field_ids for signature_base (validate already
63
+ # consumed its own set, which may have been mutated by adapters).
64
+ field_ids = signature.field_ids
65
+
66
+ signature_base = signature_base(message, serialized_components, parameters,
67
+ field_ids: field_ids)
63
68
 
64
69
  verify_or_fail key, signature.value, signature_base
65
70
  end
@@ -78,10 +83,11 @@ module Linzer
78
83
  end
79
84
 
80
85
  raise VerifyError, "Signature raw value to cannot be null" if signature.value.nil?
81
- raise VerifyError, "Components cannot be null" if signature.components.nil?
86
+ raise VerifyError, "Components cannot be null" if signature.serialized_components.nil?
82
87
 
83
88
  begin
84
- validate_components message, signature.serialized_components
89
+ validate_components message, signature.serialized_components,
90
+ field_ids: signature.field_ids
85
91
  rescue Error => ex
86
92
  raise VerifyError, ex.message, cause: ex
87
93
  end
@@ -3,5 +3,5 @@
3
3
  module Linzer
4
4
  # Current version of the Linzer gem.
5
5
  # @return [String]
6
- VERSION = "0.7.9"
6
+ VERSION = "0.8.0.beta2"
7
7
  end
data/lib/linzer.rb CHANGED
@@ -2,17 +2,21 @@
2
2
 
3
3
  require "starry"
4
4
  require "openssl"
5
- require "rack"
6
5
  require "uri"
7
6
  require "net/http"
8
7
 
9
8
  require_relative "linzer/version"
9
+ require_relative "linzer/http"
10
+ require_relative "linzer/http/structured_field"
10
11
  require_relative "linzer/common"
12
+ require_relative "linzer/signature/context"
13
+ require_relative "linzer/signature/profile"
11
14
  require_relative "linzer/helper"
12
15
  require_relative "linzer/options"
13
16
  require_relative "linzer/message"
14
17
  require_relative "linzer/message/adapter"
15
18
  require_relative "linzer/message/wrapper"
19
+ require_relative "linzer/message/overlay"
16
20
  require_relative "linzer/message/field"
17
21
  require_relative "linzer/message/field/parser"
18
22
  require_relative "linzer/signature"
@@ -25,7 +29,6 @@ require_relative "linzer/ecdsa"
25
29
  require_relative "linzer/key/helper"
26
30
  require_relative "linzer/signer"
27
31
  require_relative "linzer/verifier"
28
- require_relative "linzer/http"
29
32
 
30
33
  # Linzer is a Ruby library for HTTP Message Signatures as defined in RFC 9421.
31
34
  #
@@ -157,5 +160,3 @@ module Linzer
157
160
  # Used for serializing and deserializing component identifiers.
158
161
  FieldId = Message::Field::Identifier
159
162
  end
160
-
161
- require_relative "rack/auth/signature"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: linzer
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.9
4
+ version: 0.8.0.beta2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Miguel Landaeta
@@ -23,26 +23,6 @@ dependencies:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
25
  version: '0.2'
26
- - !ruby/object:Gem::Dependency
27
- name: rack
28
- requirement: !ruby/object:Gem::Requirement
29
- requirements:
30
- - - ">="
31
- - !ruby/object:Gem::Version
32
- version: '2.2'
33
- - - "<"
34
- - !ruby/object:Gem::Version
35
- version: '4.0'
36
- type: :runtime
37
- prerelease: false
38
- version_requirements: !ruby/object:Gem::Requirement
39
- requirements:
40
- - - ">="
41
- - !ruby/object:Gem::Version
42
- version: '2.2'
43
- - - "<"
44
- - !ruby/object:Gem::Version
45
- version: '4.0'
46
26
  - !ruby/object:Gem::Dependency
47
27
  name: uri
48
28
  requirement: !ruby/object:Gem::Requirement
@@ -63,26 +43,6 @@ dependencies:
63
43
  - - ">="
64
44
  - !ruby/object:Gem::Version
65
45
  version: 1.0.2
66
- - !ruby/object:Gem::Dependency
67
- name: logger
68
- requirement: !ruby/object:Gem::Requirement
69
- requirements:
70
- - - "~>"
71
- - !ruby/object:Gem::Version
72
- version: '1.7'
73
- - - ">="
74
- - !ruby/object:Gem::Version
75
- version: 1.7.0
76
- type: :runtime
77
- prerelease: false
78
- version_requirements: !ruby/object:Gem::Requirement
79
- requirements:
80
- - - "~>"
81
- - !ruby/object:Gem::Version
82
- version: '1.7'
83
- - - ">="
84
- - !ruby/object:Gem::Version
85
- version: 1.7.0
86
46
  - !ruby/object:Gem::Dependency
87
47
  name: forwardable
88
48
  requirement: !ruby/object:Gem::Requirement
@@ -156,6 +116,10 @@ files:
156
116
  - LICENSE.txt
157
117
  - README.md
158
118
  - Rakefile
119
+ - benchmarks/profile_sign_ed25519.rb
120
+ - benchmarks/profile_verify_ed25519.rb
121
+ - benchmarks/sign.rb
122
+ - benchmarks/verify.rb
159
123
  - examples/sinatra/Gemfile
160
124
  - examples/sinatra/config.ru
161
125
  - examples/sinatra/http-signatures.yml
@@ -175,6 +139,7 @@ files:
175
139
  - lib/linzer/http.rb
176
140
  - lib/linzer/http/bootstrap.rb
177
141
  - lib/linzer/http/signature_feature.rb
142
+ - lib/linzer/http/structured_field.rb
178
143
  - lib/linzer/jws.rb
179
144
  - lib/linzer/key.rb
180
145
  - lib/linzer/key/helper.rb
@@ -195,11 +160,18 @@ files:
195
160
  - lib/linzer/message/adapter/rack/response.rb
196
161
  - lib/linzer/message/field.rb
197
162
  - lib/linzer/message/field/parser.rb
163
+ - lib/linzer/message/overlay.rb
198
164
  - lib/linzer/message/wrapper.rb
199
165
  - lib/linzer/options.rb
166
+ - lib/linzer/rack.rb
200
167
  - lib/linzer/rsa.rb
201
168
  - lib/linzer/rsa_pss.rb
202
169
  - lib/linzer/signature.rb
170
+ - lib/linzer/signature/context.rb
171
+ - lib/linzer/signature/profile.rb
172
+ - lib/linzer/signature/profile/base.rb
173
+ - lib/linzer/signature/profile/example.rb
174
+ - lib/linzer/signature/profile/web_bot_auth.rb
203
175
  - lib/linzer/signer.rb
204
176
  - lib/linzer/verifier.rb
205
177
  - lib/linzer/version.rb