linzer 0.7.7 → 0.7.8

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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +5 -0
  3. data/LICENSE.txt +1 -1
  4. data/README.md +3 -1
  5. data/flake.lock +109 -0
  6. data/flake.nix +73 -0
  7. data/lib/linzer/common.rb +51 -0
  8. data/lib/linzer/ecdsa.rb +51 -0
  9. data/lib/linzer/ed25519.rb +35 -0
  10. data/lib/linzer/helper.rb +79 -0
  11. data/lib/linzer/hmac.rb +47 -1
  12. data/lib/linzer/http/bootstrap.rb +11 -0
  13. data/lib/linzer/http/signature_feature.rb +53 -1
  14. data/lib/linzer/http.rb +54 -0
  15. data/lib/linzer/jws.rb +74 -0
  16. data/lib/linzer/key/helper.rb +186 -10
  17. data/lib/linzer/key.rb +73 -0
  18. data/lib/linzer/message/adapter/abstract.rb +75 -10
  19. data/lib/linzer/message/adapter/generic/request.rb +27 -0
  20. data/lib/linzer/message/adapter/generic/response.rb +17 -0
  21. data/lib/linzer/message/adapter/http_gem/request.rb +11 -0
  22. data/lib/linzer/message/adapter/http_gem/response.rb +8 -5
  23. data/lib/linzer/message/adapter/net_http/request.rb +7 -0
  24. data/lib/linzer/message/adapter/net_http/response.rb +4 -0
  25. data/lib/linzer/message/adapter/rack/common.rb +14 -6
  26. data/lib/linzer/message/adapter/rack/request.rb +13 -0
  27. data/lib/linzer/message/adapter/rack/response.rb +11 -0
  28. data/lib/linzer/message/adapter.rb +17 -0
  29. data/lib/linzer/message/field/parser.rb +14 -0
  30. data/lib/linzer/message/field.rb +32 -2
  31. data/lib/linzer/message/wrapper.rb +20 -0
  32. data/lib/linzer/message.rb +113 -3
  33. data/lib/linzer/options.rb +13 -0
  34. data/lib/linzer/rsa.rb +34 -0
  35. data/lib/linzer/rsa_pss.rb +44 -0
  36. data/lib/linzer/signature.rb +113 -1
  37. data/lib/linzer/signer.rb +69 -0
  38. data/lib/linzer/verifier.rb +52 -0
  39. data/lib/linzer/version.rb +3 -1
  40. data/lib/linzer.rb +104 -0
  41. data/lib/rack/auth/signature.rb +90 -6
  42. metadata +30 -16
@@ -2,27 +2,48 @@
2
2
 
3
3
  module Linzer
4
4
  class Message
5
+ # Handles HTTP message field identification and serialization.
6
+ #
7
+ # Fields represent HTTP header fields and derived components that can
8
+ # be included in signatures. This class handles parsing and serialization
9
+ # of component identifiers according to RFC 9421.
10
+ #
11
+ # @api private
5
12
  class Field
13
+ # Methods mixed into the Identifier class for field name handling.
14
+ # @api private
6
15
  module IdentifierMethods
16
+ # Initializes the identifier by parsing the field name.
17
+ # @param field_name [String] The component identifier string
7
18
  def initialize(field_name:)
8
19
  @item = Identifier::Parser.parse(field_name) rescue nil
9
20
  super
10
21
  end
11
22
 
23
+ # @return [Starry::Item, nil] The parsed structured field item
12
24
  attr_reader :item
13
25
 
26
+ # Checks if this is a derived component (starts with @).
27
+ # @return [Boolean] true if derived (e.g., @method, @path)
14
28
  def derived?
15
29
  item&.value&.start_with?("@")
16
30
  end
17
31
 
32
+ # Serializes the component identifier.
33
+ # @return [String] The serialized identifier (e.g., '"@method"')
34
+ # @raise [Error] If the component identifier is invalid
18
35
  def serialize
19
36
  raise Error, "Invalid component identifier: '#{field_name}'!" unless item
20
37
  Starry.serialize(@item)
21
38
  end
22
39
  end
23
40
 
24
- # Excluded from coverage as obviously both branches cannot be covered
25
- # on a single test run.
41
+ # Component identifier for HTTP message fields.
42
+ #
43
+ # Uses Data.define on Ruby 3.2+ for immutability, falls back to Struct
44
+ # on older versions.
45
+ #
46
+ # @api private
26
47
  # :nocov:
27
48
  if Gem::Version.new(RUBY_VERSION) < Gem::Version.new("3.2.0")
28
49
  class Identifier < Struct.new(:field_name, keyword_init: true); end
@@ -35,14 +56,23 @@ module Linzer
35
56
 
36
57
  class Identifier
37
58
  class << self
59
+ # Serializes a single component identifier.
60
+ # @param component [String] The component name
61
+ # @return [String] The serialized identifier
38
62
  def serialize(component)
39
63
  new(field_name: component).serialize
40
64
  end
41
65
 
66
+ # Serializes an array of component identifiers.
67
+ # @param components [Array<String>] Component names
68
+ # @return [Array<String>] Serialized identifiers
42
69
  def serialize_components(components)
43
70
  components.map(&method(:serialize))
44
71
  end
45
72
 
73
+ # Deserializes component identifiers back to names.
74
+ # @param components [Array<String>] Serialized identifiers
75
+ # @return [Array<String>] Component names
46
76
  def deserialize_components(components)
47
77
  components.map do |c|
48
78
  item = Starry.parse_item(c)
@@ -2,7 +2,15 @@
2
2
 
3
3
  module Linzer
4
4
  class Message
5
+ # Handles wrapping HTTP messages with the appropriate adapter.
6
+ #
7
+ # This module maintains a registry of adapter classes for different
8
+ # HTTP message types (Rack, Net::HTTP, etc.) and selects the correct
9
+ # one when wrapping a message.
10
+ #
11
+ # @api private
5
12
  module Wrapper
13
+ # Default adapter mappings for built-in HTTP library support.
6
14
  @adapters = {
7
15
  Rack::Request => Linzer::Message::Adapter::Rack::Request,
8
16
  Rack::Response => Linzer::Message::Adapter::Rack::Response,
@@ -11,6 +19,12 @@ module Linzer
11
19
  }
12
20
 
13
21
  class << self
22
+ # Wraps an HTTP message with the appropriate adapter.
23
+ #
24
+ # @param operation [Object] The HTTP request or response object
25
+ # @param options [Hash] Additional options (e.g., :attached_request)
26
+ # @return [Adapter::Abstract] The wrapped message
27
+ # @raise [Error] If no suitable adapter is found
14
28
  def wrap(operation, **options)
15
29
  adapter_class = adapters[operation.class]
16
30
 
@@ -22,6 +36,10 @@ module Linzer
22
36
  (adapter_class || ancestor).new(operation, **options)
23
37
  end
24
38
 
39
+ # Registers a custom adapter for an HTTP message class.
40
+ #
41
+ # @param operation_class [Class] The HTTP message class
42
+ # @param adapter_class [Class] The adapter class to use
25
43
  def register_adapter(operation_class, adapter_class)
26
44
  adapters[operation_class] = adapter_class
27
45
  end
@@ -30,6 +48,8 @@ module Linzer
30
48
 
31
49
  attr_reader :adapters
32
50
 
51
+ # Finds an adapter by checking if operation inherits from a known class.
52
+ # This allows subclasses of Net::HTTPRequest etc. to work automatically.
33
53
  def find_ancestor(operation)
34
54
  adapters
35
55
  .select { |klz, adpt| operation.is_a? klz }
@@ -3,24 +3,134 @@
3
3
  require "forwardable"
4
4
 
5
5
  module Linzer
6
+ # Wraps an HTTP request or response for signing and verification.
7
+ #
8
+ # Message provides a unified interface for accessing HTTP message components
9
+ # regardless of the underlying HTTP library (Rack, Net::HTTP, http.rb, etc.).
10
+ # It handles the extraction of both regular header fields and derived
11
+ # components (like `@method`, `@path`, `@authority`).
12
+ #
13
+ # @example Wrapping a Rack request
14
+ # request = Rack::Request.new(env)
15
+ # message = Linzer::Message.new(request)
16
+ # message.request? # => true
17
+ # message["@method"] # => "GET"
18
+ #
19
+ # @example Wrapping a Net::HTTP request
20
+ # request = Net::HTTP::Post.new(uri)
21
+ # request["content-type"] = "application/json"
22
+ # message = Linzer::Message.new(request)
23
+ # message["content-type"] # => "application/json"
24
+ #
25
+ # @example Wrapping a response with an attached request
26
+ # response = Net::HTTPOK.new("1.1", "200", "OK")
27
+ # message = Linzer::Message.new(response, attached_request: request)
28
+ # message["@status"] # => 200
29
+ # message['"content-type";req'] # => value from the attached request
30
+ #
31
+ # @see https://www.rfc-editor.org/rfc/rfc9421.html#section-2 RFC 9421 Section 2
6
32
  class Message
7
33
  extend Forwardable
8
34
 
35
+ # Creates a new Message wrapper.
36
+ #
37
+ # @param operation [Rack::Request, Rack::Response, Net::HTTPRequest,
38
+ # Net::HTTPResponse, HTTP::Request, HTTP::Response] The HTTP message to wrap.
39
+ # Linzer automatically selects the appropriate adapter based on the class.
40
+ # @param attached_request [Object, nil] For response messages, an optional
41
+ # request that can be referenced using the `;req` parameter in component
42
+ # identifiers. This enables signing responses that cover request fields.
43
+ #
44
+ # @raise [Error] If the operation class is not supported and no adapter
45
+ # has been registered for it.
46
+ #
47
+ # @example Basic usage
48
+ # message = Linzer::Message.new(request)
49
+ #
50
+ # @example Response with attached request (for `;req` parameter support)
51
+ # message = Linzer::Message.new(response, attached_request: original_request)
9
52
  def initialize(operation, attached_request: nil)
10
53
  @adapter = Wrapper.wrap(operation, attached_request: attached_request)
11
54
  freeze
12
55
  end
13
56
 
14
- # common predicates
57
+ # @!method request?
58
+ # Checks if this message wraps an HTTP request.
59
+ # @return [Boolean] true if the underlying message is a request
60
+
61
+ # @!method response?
62
+ # Checks if this message wraps an HTTP response.
63
+ # @return [Boolean] true if the underlying message is a response
64
+
65
+ # @!method attached_request?
66
+ # Checks if this response message has an attached request.
67
+ # @return [Boolean] true if an attached request is present
15
68
  def_delegators :@adapter, :request?, :response?, :attached_request?
16
69
 
17
- # fields look up
70
+ # @!method header(name)
71
+ # Retrieves a header value by name.
72
+ # @param name [String] The header name (case-insensitive)
73
+ # @return [String, nil] The header value, or nil if not present
74
+
75
+ # @!method field?(component)
76
+ # Checks if a component exists in the message.
77
+ # @param component [String] The component identifier
78
+ # @return [Boolean] true if the component can be retrieved
79
+
80
+ # @!method [](component)
81
+ # Retrieves a component value from the message.
82
+ #
83
+ # Supports both regular header fields and derived components:
84
+ # - Header fields: `"content-type"`, `"date"`, `"x-custom-header"`
85
+ # - Derived components: `"@method"`, `"@path"`, `"@authority"`, `"@status"`
86
+ # - With parameters: `"content-type";bs`, `"example-dict";key="a"`, `"date";req`
87
+ #
88
+ # @param component [String] The component identifier, optionally with parameters
89
+ # @return [String, Integer, nil] The component value, or nil if not found
90
+ #
91
+ # @example Accessing headers
92
+ # message["content-type"] # => "application/json"
93
+ #
94
+ # @example Accessing derived components
95
+ # message["@method"] # => "POST"
96
+ # message["@path"] # => "/api/resource"
97
+ #
98
+ # @example With parameters
99
+ # message['"content-type";bs'] # => base64-encoded value
18
100
  def_delegators :@adapter, :header, :field?, :[]
19
101
 
20
- # to attach a signature to the underlying HTTP message
102
+ # @!method attach!(signature)
103
+ # Attaches a signature to the underlying HTTP message.
104
+ #
105
+ # Modifies the original HTTP message by adding the `signature` and
106
+ # `signature-input` headers from the signature.
107
+ #
108
+ # @param signature [Linzer::Signature] The signature to attach
109
+ # @return [Object] The underlying HTTP message object
110
+ #
111
+ # @example
112
+ # signature = Linzer.sign(key, message, components)
113
+ # message.attach!(signature)
21
114
  def_delegators :@adapter, :attach!
22
115
 
23
116
  class << self
117
+ # Registers a custom adapter for an HTTP message class.
118
+ #
119
+ # Use this to add support for HTTP libraries not built into Linzer.
120
+ # The adapter class must inherit from {Adapter::Abstract} and implement
121
+ # the required interface.
122
+ #
123
+ # @param operation_class [Class] The HTTP message class to register
124
+ # @param adapter_class [Class] The adapter class to use for wrapping
125
+ #
126
+ # @example Registering a custom adapter
127
+ # class MyHttpRequest; end
128
+ # class MyAdapter < Linzer::Message::Adapter::Abstract
129
+ # # ... implementation
130
+ # end
131
+ # Linzer::Message.register_adapter(MyHttpRequest, MyAdapter)
132
+ #
133
+ # @see Adapter::Abstract
24
134
  def register_adapter(operation_class, adapter_class)
25
135
  Wrapper.register_adapter(operation_class, adapter_class)
26
136
  end
@@ -1,7 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Linzer
4
+ # Default configuration options for HTTP message signatures.
5
+ #
6
+ # These defaults provide a reasonable starting point for most applications.
7
+ # They can be overridden when signing or in middleware configuration.
4
8
  module Options
9
+ # Default configuration values.
10
+ #
11
+ # @return [Hash] Frozen hash of default options
12
+ #
13
+ # @option DEFAULT [Array<String>] :covered_components Default components
14
+ # to include in signatures: `@method`, `@request-target`, `@authority`,
15
+ # and `date`. These provide good baseline security for most use cases.
16
+ #
17
+ # @see https://www.rfc-editor.org/rfc/rfc9421.html#section-7.2.1 RFC 9421 Section 7.2.1
5
18
  DEFAULT = {
6
19
  covered_components: %w[@method @request-target @authority date]
7
20
  }.freeze
data/lib/linzer/rsa.rb CHANGED
@@ -1,18 +1,52 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Linzer
4
+ # RSA PKCS#1 v1.5 signature algorithm support.
5
+ #
6
+ # This implements the traditional RSA signature scheme using PKCS#1 v1.5
7
+ # padding. For new applications, consider using RSA-PSS ({RSAPSS}) instead,
8
+ # which provides better security properties.
9
+ #
10
+ # @note RSA-PSS is recommended over PKCS#1 v1.5 for new applications.
11
+ #
12
+ # @see RSAPSS RSA-PSS (recommended)
13
+ # @see https://www.rfc-editor.org/rfc/rfc9421.html#section-3.3.2 RFC 9421 Section 3.3.2
4
14
  module RSA
15
+ # RSA PKCS#1 v1.5 signing key implementation.
16
+ #
17
+ # Uses the `rsa-v1_5-sha256` algorithm identifier.
18
+ #
19
+ # @example Generating a new key
20
+ # key = Linzer.generate_rsa_v1_5_sha256_key(2048, "my-rsa-key")
21
+ #
22
+ # @example Loading from PEM
23
+ # key = Linzer.new_rsa_v1_5_sha256_key(File.read("rsa.pem"), "key-1")
24
+ #
25
+ # @see Linzer::Key::Helper#generate_rsa_v1_5_sha256_key
26
+ # @see Linzer::Key::Helper#new_rsa_v1_5_sha256_key
5
27
  class Key < Linzer::Key
28
+ # @api private
6
29
  def validate
7
30
  super
8
31
  validate_digest
9
32
  end
10
33
 
34
+ # Signs data using RSA PKCS#1 v1.5.
35
+ #
36
+ # @param data [String] The data to sign
37
+ # @return [String] The RSA signature
38
+ # @raise [SigningError] If this key does not contain private key material
11
39
  def sign(data)
12
40
  validate_signing_key
13
41
  material.sign(@params[:digest], data)
14
42
  end
15
43
 
44
+ # Verifies an RSA PKCS#1 v1.5 signature.
45
+ #
46
+ # @param signature [String] The signature bytes to verify
47
+ # @param data [String] The data that was signed
48
+ # @return [Boolean] true if the signature is valid, false otherwise
49
+ # @raise [VerifyError] If this key does not contain public key material
16
50
  def verify(signature, data)
17
51
  validate_verify_key
18
52
  material.verify(@params[:digest], signature, data)
@@ -1,20 +1,60 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Linzer
4
+ # RSA-PSS (RSASSA-PSS) signature algorithm support.
5
+ #
6
+ # RSA-PSS is the recommended RSA signature scheme, providing better
7
+ # security properties than the older PKCS#1 v1.5 scheme. It uses
8
+ # probabilistic padding which makes signatures non-deterministic.
9
+ #
10
+ # @see https://www.rfc-editor.org/rfc/rfc9421.html#section-3.3.1 RFC 9421 Section 3.3.1
11
+ # @see https://www.rfc-editor.org/rfc/rfc8017.html#section-8.1 RFC 8017 RSASSA-PSS
4
12
  module RSAPSS
13
+ # Default salt length for PSS padding (64 bytes).
14
+ # @return [Integer]
5
15
  SALT_LENGTH = 64
6
16
 
17
+ # RSA-PSS signing key implementation.
18
+ #
19
+ # Uses the `rsa-pss-sha512` algorithm identifier with a 64-byte salt.
20
+ #
21
+ # @note RSA-PSS signatures are non-deterministic due to random salt.
22
+ # The same data signed twice will produce different signatures,
23
+ # but both will verify successfully.
24
+ #
25
+ # @example Generating a new key
26
+ # key = Linzer.generate_rsa_pss_sha512_key(2048, "my-key")
27
+ #
28
+ # @example Loading from PEM
29
+ # key = Linzer.new_rsa_pss_sha512_key(File.read("rsa_pss.pem"), "key-1")
30
+ #
31
+ # @see Linzer::Key::Helper#generate_rsa_pss_sha512_key
32
+ # @see Linzer::Key::Helper#new_rsa_pss_sha512_key
7
33
  class Key < Linzer::Key
34
+ # @api private
8
35
  def validate
9
36
  super
10
37
  validate_digest
11
38
  end
12
39
 
40
+ # Signs data using RSA-PSS.
41
+ #
42
+ # @param data [String] The data to sign
43
+ # @return [String] The RSA-PSS signature
44
+ # @raise [SigningError] If this key does not contain private key material
45
+ #
46
+ # @note The signature is non-deterministic due to random PSS salt.
13
47
  def sign(data)
14
48
  validate_signing_key
15
49
  material.sign(@params[:digest], data, signature_options)
16
50
  end
17
51
 
52
+ # Verifies an RSA-PSS signature.
53
+ #
54
+ # @param signature [String] The signature bytes to verify
55
+ # @param data [String] The data that was signed
56
+ # @return [Boolean] true if the signature is valid, false otherwise
57
+ # @raise [VerifyError] If this key does not contain public key material
18
58
  def verify(signature, data)
19
59
  validate_verify_key
20
60
  material.verify(
@@ -25,16 +65,20 @@ module Linzer
25
65
  )
26
66
  end
27
67
 
68
+ # @return [Boolean] true if this key contains public key material
28
69
  def public?
29
70
  has_pem_public?
30
71
  end
31
72
 
73
+ # @return [Boolean] true if this key contains private key material
32
74
  def private?
33
75
  has_pem_private?
34
76
  end
35
77
 
36
78
  private
37
79
 
80
+ # Returns OpenSSL options for PSS signature operations.
81
+ # @return [Hash] OpenSSL signature options
38
82
  def signature_options
39
83
  {
40
84
  rsa_padding_mode: "pss",
@@ -1,7 +1,32 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Linzer
4
+ # Represents an HTTP message signature as defined in RFC 9421.
5
+ #
6
+ # A Signature encapsulates:
7
+ # - The raw signature bytes
8
+ # - The covered components (fields included in the signature)
9
+ # - The signature parameters (created, keyid, etc.)
10
+ # - The signature label (for identifying multiple signatures)
11
+ #
12
+ # Signatures are immutable once created. Use {.build} to create instances
13
+ # from HTTP headers, or receive them from {Signer.sign}.
14
+ #
15
+ # @example Building a signature from HTTP headers
16
+ # headers = {
17
+ # "signature-input" => 'sig1=("@method" "@path");created=1618884473',
18
+ # "signature" => "sig1=:base64encodedvalue...:"
19
+ # }
20
+ # signature = Linzer::Signature.build(headers)
21
+ #
22
+ # @example Attaching a signature to a request
23
+ # signature = Linzer.sign(key, message, components)
24
+ # signature.to_h.each { |name, value| request[name] = value }
25
+ #
26
+ # @see https://www.rfc-editor.org/rfc/rfc9421.html#section-4 RFC 9421 Section 4
4
27
  class Signature
28
+ # @api private
29
+ # Use {.build} to create Signature instances.
5
30
  def initialize(metadata, value, label, parameters = {})
6
31
  @metadata = metadata.clone.freeze
7
32
  @value = value.clone.freeze
@@ -10,14 +35,50 @@ module Linzer
10
35
  freeze
11
36
  end
12
37
 
13
- attr_reader :metadata, :value, :parameters, :label
38
+ # @!attribute [r] metadata
39
+ # @return [Array<String>] The serialized component identifiers
40
+ # @see #serialized_components
41
+ attr_reader :metadata
42
+
43
+ # @!attribute [r] value
44
+ # @return [String] The raw signature bytes (binary string)
45
+ attr_reader :value
46
+
47
+ # @!attribute [r] parameters
48
+ # @return [Hash] The signature parameters (created, keyid, nonce, etc.)
49
+ # @note Keys are strings, not symbols
50
+ attr_reader :parameters
51
+
52
+ # @!attribute [r] label
53
+ # @return [String] The signature label (e.g., "sig1")
54
+ attr_reader :label
55
+
56
+ # @!method serialized_components
57
+ # Returns the serialized component identifiers.
58
+ # @return [Array<String>] Component identifiers in serialized form
59
+ # (e.g., `['"@method"', '"content-type"']`)
14
60
  alias_method :serialized_components, :metadata
61
+
62
+ # @!method bytes
63
+ # Returns the raw signature bytes.
64
+ # @return [String] The signature value as binary string
15
65
  alias_method :bytes, :value
16
66
 
67
+ # Returns the deserialized component identifiers.
68
+ #
69
+ # Unlike {#serialized_components}, this returns the components in a more
70
+ # human-readable form.
71
+ #
72
+ # @return [Array<String>] Component identifiers (e.g., `["@method", "content-type"]`)
17
73
  def components
18
74
  FieldId.deserialize_components(serialized_components)
19
75
  end
20
76
 
77
+ # Returns the signature creation timestamp.
78
+ #
79
+ # @return [Integer, nil] Unix timestamp when the signature was created,
80
+ # or nil if the `created` parameter is not present
81
+ # @raise [Error] If the `created` parameter exists but is not an integer
21
82
  def created
22
83
  Integer(parameters["created"])
23
84
  rescue
@@ -25,11 +86,31 @@ module Linzer
25
86
  raise Error.new "Signature has a non-integer `created` parameter"
26
87
  end
27
88
 
89
+ # Checks if the signature is older than a given number of seconds.
90
+ #
91
+ # This is useful for implementing replay attack protection by rejecting
92
+ # signatures that are too old.
93
+ #
94
+ # @param seconds [Integer] The maximum age in seconds
95
+ # @return [Boolean] true if the signature is older than the specified seconds
96
+ # @raise [Error] If the signature is missing the `created` parameter
97
+ #
98
+ # @example Check if signature is older than 5 minutes
99
+ # signature.older_than?(300) # => true or false
28
100
  def older_than?(seconds)
29
101
  raise Error.new "Signature is missing the `created` parameter" if created.nil?
30
102
  (Time.now.to_i - created) > seconds
31
103
  end
32
104
 
105
+ # Converts the signature to HTTP header format.
106
+ #
107
+ # Returns a hash suitable for setting as HTTP headers on a request or
108
+ # response. The hash contains `signature` and `signature-input` keys.
109
+ #
110
+ # @return [Hash{String => String}] Hash with "signature" and "signature-input" keys
111
+ #
112
+ # @example Attaching to a Net::HTTP request
113
+ # signature.to_h.each { |name, value| request[name] = value }
33
114
  def to_h
34
115
  {
35
116
  "signature" => Starry.serialize({label => value}),
@@ -45,6 +126,35 @@ module Linzer
45
126
  class << self
46
127
  private :new
47
128
 
129
+ # Builds a Signature from HTTP headers.
130
+ #
131
+ # Parses the `signature` and `signature-input` headers according to
132
+ # RFC 9421 and RFC 8941 (Structured Field Values).
133
+ #
134
+ # @param headers [Hash{String => String}] HTTP headers containing
135
+ # `signature` and `signature-input` fields. Keys are case-insensitive.
136
+ # @param options [Hash] Build options
137
+ # @option options [String] :label The signature label to extract when
138
+ # multiple signatures are present. If not specified and multiple
139
+ # signatures exist, an error is raised.
140
+ #
141
+ # @return [Signature] The parsed signature
142
+ #
143
+ # @raise [Error] If headers are nil or empty
144
+ # @raise [Error] If required signature headers are missing
145
+ # @raise [Error] If multiple signatures exist and no label is specified
146
+ # @raise [Error] If the specified label is not found
147
+ # @raise [Error] If the headers cannot be parsed as structured fields
148
+ #
149
+ # @example Building from request headers
150
+ # headers = {
151
+ # "signature-input" => 'sig1=("@method");created=1618884473',
152
+ # "signature" => "sig1=:HIbjHC5rS0BYaa9v4QfD4193TORw7u9..=:"
153
+ # }
154
+ # signature = Linzer::Signature.build(headers)
155
+ #
156
+ # @example Selecting a specific signature by label
157
+ # signature = Linzer::Signature.build(headers, label: "sig2")
48
158
  def build(headers, options = {})
49
159
  basic_validate headers
50
160
  headers.transform_keys!(&:downcase)
@@ -99,6 +209,8 @@ module Linzer
99
209
  raise Error.new "Cannot parse \"#{field_name}\" field. Bailing out!"
100
210
  end
101
211
 
212
+ # Parses a structured field value as a dictionary.
213
+ # @see https://datatracker.ietf.org/doc/html/rfc8941 RFC 8941
102
214
  def parse_structured_field(hsh, field_name)
103
215
  # Serialized Structured Field values for HTTP are ASCII strings.
104
216
  # See: RFC 8941 (https://datatracker.ietf.org/doc/html/rfc8941)
data/lib/linzer/signer.rb CHANGED
@@ -1,12 +1,72 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Linzer
4
+ # Handles HTTP message signing according to RFC 9421.
5
+ #
6
+ # This module provides the core signing functionality. It creates signatures
7
+ # by computing a signature base from the message components and signing it
8
+ # with the provided key.
9
+ #
10
+ # @example Direct usage (prefer Linzer.sign for convenience)
11
+ # message = Linzer::Message.new(request)
12
+ # components = %w[@method @path content-type]
13
+ # signature = Linzer::Signer.sign(key, message, components)
14
+ #
15
+ # @see https://www.rfc-editor.org/rfc/rfc9421.html#section-3.1 RFC 9421 Section 3.1
4
16
  module Signer
17
+ # Default label used for signatures when none is specified.
18
+ # @return [String]
5
19
  DEFAULT_LABEL = "sig1"
6
20
 
7
21
  class << self
8
22
  include Common
9
23
 
24
+ # Signs an HTTP message.
25
+ #
26
+ # Creates a signature by:
27
+ # 1. Serializing the component identifiers
28
+ # 2. Building the signature base from the message and parameters
29
+ # 3. Signing the signature base with the key
30
+ # 4. Returning a Signature object with the result
31
+ #
32
+ # @param key [Linzer::Key] The private key to sign with. Must respond to
33
+ # `#sign` and should contain private key material.
34
+ # @param message [Linzer::Message] The HTTP message to sign
35
+ # @param components [Array<String>] Component identifiers to include in
36
+ # the signature. Can be header names (e.g., `"content-type"`) or derived
37
+ # components (e.g., `"@method"`, `"@path"`). May include parameters
38
+ # (e.g., `"content-type";bs` for binary-wrapped).
39
+ # @param options [Hash] Additional signature parameters
40
+ # @option options [Integer] :created Unix timestamp for signature creation.
41
+ # Defaults to the current UTC time.
42
+ # @option options [String] :keyid Key identifier. If not provided, uses
43
+ # the key's `key_id` if available.
44
+ # @option options [String] :label The signature label (defaults to "sig1").
45
+ # Multiple signatures on a message must have distinct labels.
46
+ # @option options [String] :nonce A unique nonce value to prevent replay
47
+ # @option options [String] :tag Application-specific tag
48
+ # @option options [Integer] :expires Unix timestamp when signature expires
49
+ # @option options [String] :alg Algorithm identifier (usually inferred from key)
50
+ #
51
+ # @return [Linzer::Signature] The generated signature, ready to be attached
52
+ # to the message via {Signature#to_h}
53
+ #
54
+ # @raise [SigningError] If the message, key, or components are invalid
55
+ # @raise [SigningError] If required components are missing from the message
56
+ # @raise [SigningError] If components are duplicated
57
+ # @raise [SigningError] If `@signature-params` is included in components
58
+ #
59
+ # @example Basic signing
60
+ # signature = Linzer::Signer.sign(key, message, %w[@method @path])
61
+ #
62
+ # @example With all options
63
+ # signature = Linzer::Signer.sign(key, message, %w[@method @path date],
64
+ # created: Time.now.to_i,
65
+ # keyid: "my-key-2024",
66
+ # label: "request-sig",
67
+ # nonce: SecureRandom.hex(16),
68
+ # tag: "my-app"
69
+ # )
10
70
  def sign(key, message, components, options = {})
11
71
  serialized_components = FieldId.serialize_components(Array(components))
12
72
  validate key, message, serialized_components
@@ -20,12 +80,17 @@ module Linzer
20
80
  Linzer::Signature.build(serialize(signature, serialized_components, parameters, label))
21
81
  end
22
82
 
83
+ # Returns the default signature label.
84
+ #
85
+ # @return [String] The default label ("sig1")
23
86
  def default_label
24
87
  DEFAULT_LABEL
25
88
  end
26
89
 
27
90
  private
28
91
 
92
+ # Validates signing inputs.
93
+ # @raise [SigningError] If any input is invalid
29
94
  def validate(key, message, components)
30
95
  msg = "Message cannot be signed with null %s"
31
96
  raise SigningError, msg % "value" if message.nil?
@@ -39,6 +104,8 @@ module Linzer
39
104
  end
40
105
  end
41
106
 
107
+ # Builds the signature parameters hash from options and key.
108
+ # @return [Hash] The populated parameters
42
109
  def populate_parameters(key, options)
43
110
  parameters = {}
44
111
 
@@ -52,6 +119,8 @@ module Linzer
52
119
  parameters
53
120
  end
54
121
 
122
+ # Serializes the signature into HTTP header format.
123
+ # @return [Hash] Hash with "signature" and "signature-input" keys
55
124
  def serialize(signature, components, parameters, label)
56
125
  {
57
126
  "signature" => Starry.serialize({label => signature}),