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
@@ -1,10 +1,57 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Linzer
4
+ # Handles HTTP message signature verification according to RFC 9421.
5
+ #
6
+ # This module verifies that a signature on an HTTP message is valid by:
7
+ # 1. Reconstructing the signature base from the message and signature parameters
8
+ # 2. Verifying the signature using the provided public key
9
+ #
10
+ # @example Direct usage (prefer Linzer.verify for convenience)
11
+ # signature = Linzer::Signature.build(signature_headers)
12
+ # message = Linzer::Message.new(request)
13
+ # Linzer::Verifier.verify(pubkey, message, signature)
14
+ #
15
+ # @see https://www.rfc-editor.org/rfc/rfc9421.html#section-3.2 RFC 9421 Section 3.2
4
16
  module Verifier
5
17
  class << self
6
18
  include Common
7
19
 
20
+ # Verifies an HTTP message signature.
21
+ #
22
+ # Verification succeeds if:
23
+ # - All covered components exist in the message
24
+ # - The signature base matches what was signed
25
+ # - The cryptographic signature is valid for the public key
26
+ # - The signature is not older than `no_older_than` (if specified)
27
+ #
28
+ # @param key [Linzer::Key] The public key to verify with. Must respond to
29
+ # `#verify` and should contain public key material.
30
+ # @param message [Linzer::Message] The HTTP message to verify
31
+ # @param signature [Linzer::Signature] The signature to verify. Typically
32
+ # built from the `signature` and `signature-input` headers using
33
+ # {Signature.build}.
34
+ # @param no_older_than [Integer, nil] Maximum age in seconds. If the
35
+ # signature's `created` parameter is older than this, verification fails.
36
+ # This helps mitigate replay attacks. See RFC 9421 Section 7.2.2.
37
+ #
38
+ # @return [true] Returns true if verification succeeds
39
+ #
40
+ # @raise [VerifyError] If the message is nil
41
+ # @raise [VerifyError] If the key is nil
42
+ # @raise [VerifyError] If the signature is nil or invalid
43
+ # @raise [VerifyError] If required components are missing from the message
44
+ # @raise [VerifyError] If the signature is too old (when no_older_than is set)
45
+ # @raise [VerifyError] If the cryptographic verification fails
46
+ #
47
+ # @example Basic verification
48
+ # Linzer::Verifier.verify(pubkey, message, signature)
49
+ # # => true (or raises VerifyError)
50
+ #
51
+ # @example With age validation (5 minute window)
52
+ # Linzer::Verifier.verify(pubkey, message, signature, no_older_than: 300)
53
+ #
54
+ # @see https://www.rfc-editor.org/rfc/rfc9421.html#section-7.2.2 Signature Replay
8
55
  def verify(key, message, signature, no_older_than: nil)
9
56
  validate message, key, signature, no_older_than: no_older_than
10
57
 
@@ -18,6 +65,8 @@ module Linzer
18
65
 
19
66
  private
20
67
 
68
+ # Validates all verification inputs before attempting verification.
69
+ # @raise [VerifyError] If any input is invalid or signature is too old
21
70
  def validate(message, key, signature, no_older_than: nil)
22
71
  raise VerifyError, "Message to verify cannot be null" if message.nil?
23
72
  raise VerifyError, "Key to verify signature cannot be null" if key.nil?
@@ -45,6 +94,9 @@ module Linzer
45
94
  end
46
95
  end
47
96
 
97
+ # Performs cryptographic verification and raises on failure.
98
+ # @return [true] If verification succeeds
99
+ # @raise [VerifyError] If verification fails
48
100
  def verify_or_fail(key, signature, data)
49
101
  return true if key.verify(signature, data)
50
102
  raise VerifyError, "Failed to verify message: Invalid signature."
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Linzer
4
- VERSION = "0.7.7"
4
+ # Current version of the Linzer gem.
5
+ # @return [String]
6
+ VERSION = "0.7.8"
5
7
  end
data/lib/linzer.rb CHANGED
@@ -28,30 +28,134 @@ require_relative "linzer/signer"
28
28
  require_relative "linzer/verifier"
29
29
  require_relative "linzer/http"
30
30
 
31
+ # Linzer is a Ruby library for HTTP Message Signatures as defined in RFC 9421.
32
+ #
33
+ # It provides functionality to sign and verify HTTP messages using various
34
+ # cryptographic algorithms including RSA-PSS, HMAC-SHA256, ECDSA, and Ed25519.
35
+ #
36
+ # @example Signing a request with Ed25519
37
+ # key = Linzer.generate_ed25519_key("my-key-id")
38
+ # request = Net::HTTP::Post.new(URI("https://example.com/api"))
39
+ # request["date"] = Time.now.httpdate
40
+ #
41
+ # Linzer.sign!(request,
42
+ # key: key,
43
+ # components: %w[@method @request-target date]
44
+ # )
45
+ #
46
+ # @example Verifying a signed request
47
+ # pubkey = Linzer.new_ed25519_public_key(public_key_pem, "my-key-id")
48
+ # Linzer.verify!(request, key: pubkey)
49
+ #
50
+ # @see https://www.rfc-editor.org/rfc/rfc9421.html RFC 9421 - HTTP Message Signatures
51
+ # @see https://github.com/nomadium Author on GitHub
52
+ # @author Miguel Landaeta
31
53
  module Linzer
54
+ # Base error class for all Linzer errors.
55
+ # @see VerifyError
56
+ # @see SigningError
32
57
  class Error < StandardError; end
33
58
 
59
+ # Raised when signature verification fails.
60
+ #
61
+ # @example Handling verification errors
62
+ # begin
63
+ # Linzer.verify(pubkey, message, signature)
64
+ # rescue Linzer::VerifyError => e
65
+ # puts "Verification failed: #{e.message}"
66
+ # end
34
67
  class VerifyError < Error; end
35
68
 
69
+ # Raised when message signing fails.
70
+ #
71
+ # @example Handling signing errors
72
+ # begin
73
+ # Linzer.sign(key, message, components)
74
+ # rescue Linzer::SigningError => e
75
+ # puts "Signing failed: #{e.message}"
76
+ # end
36
77
  class SigningError < Error; end
37
78
 
38
79
  class << self
39
80
  include Key::Helper
40
81
  include Helper
41
82
 
83
+ # Verifies an HTTP message signature.
84
+ #
85
+ # @param pubkey [Linzer::Key] The public key to verify the signature with
86
+ # @param message [Linzer::Message] The HTTP message to verify
87
+ # @param signature [Linzer::Signature] The signature to verify
88
+ # @param no_older_than [Integer, nil] Maximum age of signature in seconds.
89
+ # If provided, signatures with a `created` timestamp older than this
90
+ # value will be rejected to mitigate replay attacks.
91
+ #
92
+ # @return [true] Returns true if verification succeeds
93
+ # @raise [VerifyError] If verification fails for any reason
94
+ #
95
+ # @example Basic verification
96
+ # Linzer.verify(pubkey, message, signature)
97
+ #
98
+ # @example Verification with age limit (reject signatures older than 5 minutes)
99
+ # Linzer.verify(pubkey, message, signature, no_older_than: 300)
100
+ #
101
+ # @see Linzer::Verifier.verify
42
102
  def verify(pubkey, message, signature, no_older_than: nil)
43
103
  Linzer::Verifier.verify(pubkey, message, signature, no_older_than: no_older_than)
44
104
  end
45
105
 
106
+ # Signs an HTTP message.
107
+ #
108
+ # @param key [Linzer::Key] The private key to sign with
109
+ # @param message [Linzer::Message] The HTTP message to sign
110
+ # @param components [Array<String>] The message components to include in
111
+ # the signature (e.g., `["@method", "@path", "content-type"]`)
112
+ # @param options [Hash] Additional signature parameters
113
+ # @option options [Integer] :created Unix timestamp for signature creation
114
+ # (defaults to current time)
115
+ # @option options [String] :keyid Key identifier to include in signature
116
+ # @option options [String] :label Signature label (defaults to "sig1")
117
+ # @option options [String] :nonce A unique nonce value
118
+ # @option options [String] :tag Application-specific tag
119
+ # @option options [Integer] :expires Unix timestamp for signature expiration
120
+ #
121
+ # @return [Linzer::Signature] The generated signature
122
+ # @raise [SigningError] If signing fails
123
+ #
124
+ # @example Sign with default options
125
+ # signature = Linzer.sign(key, message, %w[@method @path date])
126
+ #
127
+ # @example Sign with custom parameters
128
+ # signature = Linzer.sign(key, message, %w[@method @path],
129
+ # keyid: "my-key",
130
+ # created: Time.now.to_i,
131
+ # nonce: SecureRandom.hex(16)
132
+ # )
133
+ #
134
+ # @see Linzer::Signer.sign
46
135
  def sign(key, message, components, options = {})
47
136
  Linzer::Signer.sign(key, message, components, options)
48
137
  end
49
138
 
139
+ # Computes the signature base string for an HTTP message.
140
+ #
141
+ # The signature base is the canonical string representation that gets
142
+ # signed. This method is primarily useful for debugging or implementing
143
+ # custom signing logic.
144
+ #
145
+ # @param message [Linzer::Message] The HTTP message
146
+ # @param components [Array<String>] Serialized component identifiers
147
+ # @param parameters [Hash] Signature parameters
148
+ #
149
+ # @return [String] The signature base string
150
+ #
151
+ # @see https://www.rfc-editor.org/rfc/rfc9421.html#section-2.5 RFC 9421 Section 2.5
50
152
  def signature_base(message, components, parameters)
51
153
  Linzer::Common.signature_base(message, components, parameters)
52
154
  end
53
155
  end
54
156
 
157
+ # Alias for {Message::Field::Identifier} for convenient access.
158
+ # Used for serializing and deserializing component identifiers.
55
159
  FieldId = Message::Field::Identifier
56
160
  end
57
161
 
@@ -4,23 +4,96 @@ require "linzer"
4
4
  require "logger"
5
5
  require_relative "signature/helpers"
6
6
 
7
- # Rack::Auth::Signature implements HTTP Message Signatures, as per RFC 9421.
8
- #
9
- # Initialize with the Rack application that you want protecting.
10
- # A hash with options and a block can be passed to customize, enhance
11
- # or disable security checks applied to incoming requests.
12
- #
13
7
  module Rack
14
8
  module Auth
9
+ # Rack middleware for HTTP Message Signature verification (RFC 9421).
10
+ #
11
+ # This middleware verifies that incoming requests have valid HTTP signatures.
12
+ # Requests without valid signatures are rejected with a 401 Unauthorized response.
13
+ #
14
+ # @example Basic usage in config.ru
15
+ # require "linzer"
16
+ #
17
+ # use Rack::Auth::Signature,
18
+ # except: "/health",
19
+ # default_key: {
20
+ # material: File.read("public_key.pem"),
21
+ # alg: "ed25519"
22
+ # }
23
+ #
24
+ # run MyApp
25
+ #
26
+ # @example With configuration file
27
+ # use Rack::Auth::Signature,
28
+ # except: ["/login", "/health"],
29
+ # config_path: "config/http-signatures.yml"
30
+ #
31
+ # @example In a Rails application (config/application.rb)
32
+ # config.middleware.use Rack::Auth::Signature,
33
+ # except: "/login",
34
+ # config_path: "config/http-signatures.yml"
35
+ #
36
+ # @example With a block for custom configuration
37
+ # use Rack::Auth::Signature do
38
+ # # Custom configuration via instance_eval
39
+ # end
40
+ #
41
+ # Configuration file format (YAML):
42
+ #
43
+ # signatures:
44
+ # reject_older_than: 900 # Reject signatures older than 15 minutes
45
+ # created_required: true # Require 'created' parameter
46
+ # keyid_required: false # Require 'keyid' parameter
47
+ # covered_components: # Required components in signature
48
+ # - "@method"
49
+ # - "@request-target"
50
+ # - "date"
51
+ # keys:
52
+ # my-key-id:
53
+ # alg: ed25519
54
+ # material: | # Inline PEM
55
+ # -----BEGIN PUBLIC KEY-----
56
+ # ...
57
+ # -----END PUBLIC KEY-----
58
+ # other-key:
59
+ # alg: rsa-pss-sha512
60
+ # path: keys/public.pem # Or path to key file
61
+ #
62
+ # @see https://www.rfc-editor.org/rfc/rfc9421.html RFC 9421
63
+ # @see Helpers::Configuration For configuration options
64
+ # @see Helpers::Key For key lookup behavior
15
65
  class Signature
16
66
  include Helpers
17
67
 
68
+ # Creates a new signature verification middleware.
69
+ #
70
+ # @param app [#call] The Rack application to protect
71
+ # @param options [Hash] Configuration options
72
+ # @option options [String, Array<String>] :except Paths to exclude from
73
+ # signature verification (e.g., "/login", "/health")
74
+ # @option options [String] :config_path Path to YAML configuration file
75
+ # @option options [Hash] :default_key Default key configuration when
76
+ # keyid is not present or not found in keys hash
77
+ # @option options [Hash] :keys Hash of key configurations keyed by keyid
78
+ # @option options [Hash] :signatures Signature verification options
79
+ #
80
+ # @yield Optional block for additional configuration via instance_eval
18
81
  def initialize(app, options = {}, &block)
19
82
  @app = app
20
83
  @options = load_options(Hash(options))
21
84
  instance_eval(&block) if block
22
85
  end
23
86
 
87
+ # Processes an incoming request.
88
+ #
89
+ # If the request path is excluded or the signature is valid, the request
90
+ # is passed to the wrapped application. Otherwise, returns a 401 response.
91
+ #
92
+ # On successful verification, the signature is stored in `env["rack.signature"]`
93
+ # for use by the application.
94
+ #
95
+ # @param env [Hash] The Rack environment
96
+ # @return [Array] Rack response tuple [status, headers, body]
24
97
  def call(env)
25
98
  @request = Rack::Request.new(env)
26
99
 
@@ -34,25 +107,30 @@ module Rack
34
107
 
35
108
  private
36
109
 
110
+ # Checks if the current request path is excluded from verification.
37
111
  def excluded?
38
112
  return false if !request
39
113
  Array(options[:except]).include?(request.path_info)
40
114
  end
41
115
 
116
+ # Checks if the request should be allowed (has valid signature).
42
117
  def allowed?
43
118
  has_signature? && acceptable? && verifiable?
44
119
  end
45
120
 
46
121
  attr_reader :request, :options
47
122
 
123
+ # Returns the signature parameters.
48
124
  def params
49
125
  @signature.parameters || {}
50
126
  end
51
127
 
128
+ # Returns the logger instance.
52
129
  def logger
53
130
  @logger ||= request.logger || ::Logger.new($stderr)
54
131
  end
55
132
 
133
+ # Checks if the request has signature headers.
56
134
  def has_signature?
57
135
  @signature = build_signature
58
136
  (@signature.to_h.keys & %w[signature signature-input]).size == 2
@@ -61,6 +139,7 @@ module Rack
61
139
  false
62
140
  end
63
141
 
142
+ # Builds a Signature object from request headers.
64
143
  def build_signature
65
144
  signature_opts = {}
66
145
  label = options[:signatures][:default_label]
@@ -77,6 +156,7 @@ module Rack
77
156
  signature
78
157
  end
79
158
 
159
+ # Checks if required signature parameters are present.
80
160
  def has_required_params?
81
161
  created? && expires? && keyid? && nonce? && alg? && tag?
82
162
  rescue => ex
@@ -84,6 +164,7 @@ module Rack
84
164
  false
85
165
  end
86
166
 
167
+ # Checks if required components are covered by the signature.
87
168
  def has_required_components?
88
169
  components = @signature.serialized_components || []
89
170
  covered_components = options[:signatures][:covered_components]
@@ -92,10 +173,12 @@ module Rack
92
173
  (covered_components || []).all? { |c| components.include?(c) }
93
174
  end
94
175
 
176
+ # Checks if the signature meets all requirements.
95
177
  def acceptable?
96
178
  has_required_params? && has_required_components?
97
179
  end
98
180
 
181
+ # Verifies the signature cryptographically.
99
182
  def verifiable?
100
183
  verify_opts = build_and_check_verify_opts || {}
101
184
  Linzer.verify(key, @message, @signature, **verify_opts)
@@ -104,6 +187,7 @@ module Rack
104
187
  false
105
188
  end
106
189
 
190
+ # Builds verification options and logs warnings for security issues.
107
191
  def build_and_check_verify_opts
108
192
  verify_opts = {}
109
193
  reject_older = options[:signatures][:reject_older_than]
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.7
4
+ version: 0.7.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Miguel Landaeta
@@ -13,22 +13,22 @@ dependencies:
13
13
  name: openssl
14
14
  requirement: !ruby/object:Gem::Requirement
15
15
  requirements:
16
- - - "~>"
17
- - !ruby/object:Gem::Version
18
- version: '3.0'
19
16
  - - ">="
20
17
  - !ruby/object:Gem::Version
21
- version: 3.0.0
18
+ version: '3'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '5'
22
22
  type: :runtime
23
23
  prerelease: false
24
24
  version_requirements: !ruby/object:Gem::Requirement
25
25
  requirements:
26
- - - "~>"
27
- - !ruby/object:Gem::Version
28
- version: '3.0'
29
26
  - - ">="
30
27
  - !ruby/object:Gem::Version
31
- version: 3.0.0
28
+ version: '3'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '5'
32
32
  - !ruby/object:Gem::Dependency
33
33
  name: starry
34
34
  requirement: !ruby/object:Gem::Requirement
@@ -147,30 +147,42 @@ dependencies:
147
147
  name: net-http
148
148
  requirement: !ruby/object:Gem::Requirement
149
149
  requirements:
150
- - - "~>"
150
+ - - ">="
151
151
  - !ruby/object:Gem::Version
152
- version: 0.6.0
152
+ version: '0.6'
153
+ - - "<"
154
+ - !ruby/object:Gem::Version
155
+ version: '0.10'
153
156
  type: :runtime
154
157
  prerelease: false
155
158
  version_requirements: !ruby/object:Gem::Requirement
156
159
  requirements:
157
- - - "~>"
160
+ - - ">="
158
161
  - !ruby/object:Gem::Version
159
- version: 0.6.0
162
+ version: '0.6'
163
+ - - "<"
164
+ - !ruby/object:Gem::Version
165
+ version: '0.10'
160
166
  - !ruby/object:Gem::Dependency
161
167
  name: cgi
162
168
  requirement: !ruby/object:Gem::Requirement
163
169
  requirements:
164
- - - "~>"
170
+ - - ">="
165
171
  - !ruby/object:Gem::Version
166
172
  version: 0.4.2
173
+ - - "<"
174
+ - !ruby/object:Gem::Version
175
+ version: 0.6.0
167
176
  type: :runtime
168
177
  prerelease: false
169
178
  version_requirements: !ruby/object:Gem::Requirement
170
179
  requirements:
171
- - - "~>"
180
+ - - ">="
172
181
  - !ruby/object:Gem::Version
173
182
  version: 0.4.2
183
+ - - "<"
184
+ - !ruby/object:Gem::Version
185
+ version: 0.6.0
174
186
  email:
175
187
  - miguel@miguel.cc
176
188
  executables: []
@@ -188,6 +200,8 @@ files:
188
200
  - examples/sinatra/config.ru
189
201
  - examples/sinatra/http-signatures.yml
190
202
  - examples/sinatra/myapp.rb
203
+ - flake.lock
204
+ - flake.nix
191
205
  - lib/linzer.rb
192
206
  - lib/linzer/common.rb
193
207
  - lib/linzer/ecdsa.rb
@@ -245,7 +259,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
245
259
  - !ruby/object:Gem::Version
246
260
  version: '0'
247
261
  requirements: []
248
- rubygems_version: 3.6.7
262
+ rubygems_version: 3.6.8
249
263
  specification_version: 4
250
264
  summary: An implementation of HTTP Messages Signatures (RFC9421)
251
265
  test_files: []