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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +1 -1
- data/README.md +3 -1
- data/flake.lock +109 -0
- data/flake.nix +73 -0
- data/lib/linzer/common.rb +51 -0
- data/lib/linzer/ecdsa.rb +51 -0
- data/lib/linzer/ed25519.rb +35 -0
- data/lib/linzer/helper.rb +79 -0
- data/lib/linzer/hmac.rb +47 -1
- data/lib/linzer/http/bootstrap.rb +11 -0
- data/lib/linzer/http/signature_feature.rb +53 -1
- data/lib/linzer/http.rb +54 -0
- data/lib/linzer/jws.rb +74 -0
- data/lib/linzer/key/helper.rb +186 -10
- data/lib/linzer/key.rb +73 -0
- data/lib/linzer/message/adapter/abstract.rb +75 -10
- data/lib/linzer/message/adapter/generic/request.rb +27 -0
- data/lib/linzer/message/adapter/generic/response.rb +17 -0
- data/lib/linzer/message/adapter/http_gem/request.rb +11 -0
- data/lib/linzer/message/adapter/http_gem/response.rb +8 -5
- data/lib/linzer/message/adapter/net_http/request.rb +7 -0
- data/lib/linzer/message/adapter/net_http/response.rb +4 -0
- data/lib/linzer/message/adapter/rack/common.rb +14 -6
- data/lib/linzer/message/adapter/rack/request.rb +13 -0
- data/lib/linzer/message/adapter/rack/response.rb +11 -0
- data/lib/linzer/message/adapter.rb +17 -0
- data/lib/linzer/message/field/parser.rb +14 -0
- data/lib/linzer/message/field.rb +32 -2
- data/lib/linzer/message/wrapper.rb +20 -0
- data/lib/linzer/message.rb +113 -3
- data/lib/linzer/options.rb +13 -0
- data/lib/linzer/rsa.rb +34 -0
- data/lib/linzer/rsa_pss.rb +44 -0
- data/lib/linzer/signature.rb +113 -1
- data/lib/linzer/signer.rb +69 -0
- data/lib/linzer/verifier.rb +52 -0
- data/lib/linzer/version.rb +3 -1
- data/lib/linzer.rb +104 -0
- data/lib/rack/auth/signature.rb +90 -6
- metadata +30 -16
data/lib/linzer/message/field.rb
CHANGED
|
@@ -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
|
-
#
|
|
25
|
-
#
|
|
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 }
|
data/lib/linzer/message.rb
CHANGED
|
@@ -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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
data/lib/linzer/options.rb
CHANGED
|
@@ -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)
|
data/lib/linzer/rsa_pss.rb
CHANGED
|
@@ -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",
|
data/lib/linzer/signature.rb
CHANGED
|
@@ -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
|
-
|
|
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}),
|