linzer 0.8.0.beta1 → 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.
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Linzer
4
+ class Message
5
+ # Overlay provides a signing-time augmentation layer for HTTP headers.
6
+ #
7
+ # It allows additional headers to be introduced during HTTP Message
8
+ # Signature generation without mutating the underlying HTTP message.
9
+ #
10
+ # IMPORTANT SEMANTICS
11
+ #
12
+ # Overlay affects ONLY HTTP header resolution.
13
+ #
14
+ # It MUST NOT influence derived HTTP Message Signature components such as:
15
+ # - @method
16
+ # - @authority
17
+ # - @target-uri
18
+ #
19
+ # These values are always computed from the underlying HTTP message.
20
+ #
21
+ # ---
22
+ #
23
+ # Resolution rules:
24
+ #
25
+ # Header lookup:
26
+ # 1. Underlying message headers
27
+ # 2. Overlay headers (fallback only)
28
+ #
29
+ # Derived components:
30
+ # Always resolved from the underlying message only
31
+ #
32
+ # ---
33
+ #
34
+ # Purpose:
35
+ #
36
+ # This class exists to support signing-time augmentation of header values
37
+ # (e.g., injected or synthesized headers required by signing profiles)
38
+ # without altering the canonical representation of the HTTP message.
39
+ #
40
+ # This is NOT a full message override layer.
41
+ # It is a header-only augmentation mechanism used during signing.
42
+ #
43
+ # DESIGN NOTE:
44
+ # Overlay does not implement full HTTP message semantics.
45
+ # It only participates in header resolution for signing-time evaluation.
46
+ class Overlay
47
+ # Creates a new overlay message.
48
+ #
49
+ # @param message [Linzer::Message]
50
+ # The underlying message to wrap
51
+ # @param overlay_headers [#to_h, Hash]
52
+ # Additional headers to overlay onto the message
53
+ # A hash-like object containing HTTP headers to use as an overlay layer.
54
+ # Keys and values must be compatible with Net::HTTP header semantics.
55
+ def initialize(message, overlay_headers)
56
+ @message = message
57
+ @overlay_headers = overlay_headers
58
+
59
+ # Internal adapter-backed overlay used to reuse Linzer's field
60
+ # resolution logic for header/field evaluation.
61
+ @overlay = build_overlay_message(overlay_headers)
62
+ end
63
+
64
+ # Returns a header value from the signing-time resolution view.
65
+ #
66
+ # Overlay headers are only used if the underlying message does not
67
+ # provide a value for the requested header.
68
+ #
69
+ # Derived components (e.g. @authority, @target-uri) are not affected.
70
+ #
71
+ # @param name [String]
72
+ # @return [String, nil]
73
+ def header(name)
74
+ @message.header(name) || @overlay.header(name)
75
+ end
76
+
77
+ # Returns true if the field can be resolved from:
78
+ #
79
+ # - the underlying HTTP message (including derived fields), or
80
+ # - overlay headers (header fields only)
81
+ #
82
+ # NOTE:
83
+ # Overlay headers do not participate in derived component resolution
84
+ # (e.g. @method, @target-uri, @authority).
85
+ #
86
+ # @param field [Linzer::FieldId]
87
+ # @return [Boolean]
88
+ def field?(field)
89
+ @message.field?(field) || (!field.derived? && @overlay.field?(field))
90
+ end
91
+
92
+ # Attaches signature headers to the underlying HTTP message.
93
+ #
94
+ # Overlay headers are applied only as HTTP headers at attachment time.
95
+ # They do not affect derived HTTP Message Signature components.
96
+ #
97
+ # @param signature [Linzer::Signature] The signature to attach
98
+ # @return [Object]
99
+ # The underlying message returned by Linzer::Message#attach!
100
+ def attach!(signature)
101
+ @message.attach!(signature, additional_headers: @overlay_headers.to_h)
102
+ end
103
+
104
+ # Retrieves a resolved header or field value from the signing-time view.
105
+ #
106
+ # Resolution order:
107
+ #
108
+ # 1. Underlying HTTP message
109
+ # 2. Overlay headers (fallback only)
110
+ #
111
+ # IMPORTANT:
112
+ # Overlay values are ONLY used when the underlying message does not
113
+ # provide a value. They do not override existing message values.
114
+ #
115
+ # Derived HTTP Message Signature components (e.g. @method,
116
+ # @target-uri, @authority) are always resolved exclusively from the
117
+ # underlying message and are never influenced by overlay headers.
118
+ #
119
+ # @param name [Linzer::FieldId]
120
+ # @return [Object, nil]
121
+ def [](name)
122
+ value = @message[name]
123
+ return value unless value.nil?
124
+
125
+ return nil if Linzer::FieldId.new(field_name: name).derived?
126
+ @overlay[name]
127
+ end
128
+
129
+ private
130
+
131
+ # Builds a synthetic Net::HTTP request used solely to reuse Linzer's
132
+ # Generic::Request field resolution logic.
133
+ #
134
+ # The URI is a placeholder because only header and field resolution
135
+ # behavior is required; no network request is performed.
136
+ def build_overlay_message(headers)
137
+ request = Net::HTTP::Get.new(URI("https://example.invalid/"))
138
+ request.initialize_http_header(headers.to_h)
139
+ Adapter::Generic::Request.new(request)
140
+ end
141
+ end
142
+ end
143
+ end
@@ -113,6 +113,24 @@ module Linzer
113
113
  # message.attach!(signature)
114
114
  def_delegators :@adapter, :attach!
115
115
 
116
+ # Returns a new message wrapper with additional or overridden headers.
117
+ #
118
+ # The returned message preserves the original message contents while
119
+ # overlaying the provided headers for component resolution and
120
+ # signature generation.
121
+ #
122
+ # @param headers [#to_h] Headers to overlay onto the message
123
+ # @return [Linzer::Message::Overlay] A message wrapper using the
124
+ # merged header set
125
+ #
126
+ # @example Add related signature headers without mutating the message
127
+ # signed_message = message.with_headers(
128
+ # "signature-agent" => "https://example.org/automated-agent"
129
+ # )
130
+ def with_headers(overlay_headers)
131
+ Overlay.new(self, overlay_headers)
132
+ end
133
+
116
134
  class << self
117
135
  # Registers a custom adapter for an HTTP message class.
118
136
  #
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Linzer
4
+ class Signature
5
+ # A mutable context object used during signature generation.
6
+ #
7
+ # The context represents all state required to produce a signature,
8
+ # including:
9
+ #
10
+ # - the HTTP message being signed
11
+ # - the signing key
12
+ # - covered signature components
13
+ # - signature parameters
14
+ # - optional overlay headers introduced by signing profiles
15
+ #
16
+ # Profiles may mutate this context before or during signing in order
17
+ # to influence the final signature output (e.g., adding headers,
18
+ # modifying components, or adjusting parameters).
19
+ #
20
+ # This object is intentionally mutable and is not thread-safe.
21
+ #
22
+ # @attr_reader [Linzer::Message] message
23
+ # the HTTP message being signed
24
+ #
25
+ # @attr_reader [Object] key
26
+ # the signing key used to generate the signature
27
+ #
28
+ # @attr_reader [Array<String>] components
29
+ # list of covered signature components
30
+ #
31
+ # @attr_reader [Hash] params
32
+ # signature parameters (may include :label if provided)
33
+ #
34
+ # @attr_reader [Hash] overlay_headers
35
+ # Overlay headers are merged into the message view during signature
36
+ # computation but do not mutate the underlying message.
37
+ class Context
38
+ # Creates a new signing context.
39
+ #
40
+ # @param message [Linzer::Message]
41
+ # The HTTP message being signed
42
+ #
43
+ # @param key [Linzer::Key]
44
+ # The signing key used to generate the signature
45
+ #
46
+ # @param label [String, nil]
47
+ # Optional signature label. If provided, it is merged into params
48
+ # as `:label`.
49
+ #
50
+ # @param components [Array<String>]
51
+ # The list of HTTP components covered by the signature
52
+ #
53
+ # @param params [Hash]
54
+ # Signature parameters passed to the signing algorithm
55
+ def initialize(message:, key:, label:, components:, params:)
56
+ @message = message
57
+ @key = key
58
+ @components = components.dup
59
+ @params = (label ? params.merge(label: label) : params).dup
60
+ @overlay_headers = {}
61
+ end
62
+ attr_reader :key, :components, :params, :overlay_headers
63
+
64
+ # Returns a message view that includes any overlay headers.
65
+ #
66
+ # The returned object is cached after first construction.
67
+ #
68
+ # Overlay headers are applied lazily and only affect the derived
69
+ # signing view; the original message remains unchanged.
70
+ #
71
+ # @return [Linzer::Message]
72
+ def message
73
+ return @overlay_message if defined?(@overlay_message)
74
+ return @message if @overlay_headers.empty?
75
+
76
+ @overlay_message = @message.with_headers(overlay_headers)
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Linzer
4
+ class Signature
5
+ module Profile
6
+ # Base class for all signing profiles.
7
+ #
8
+ # A signing profile encapsulates policy logic that can modify a
9
+ # {Linzer::Signature::Context} before signature generation.
10
+ #
11
+ # Subclasses are expected to implement {#apply}.
12
+ #
13
+ # ## Lifecycle
14
+ #
15
+ # 1. Context is created
16
+ # 2. Profile is resolved via {.resolve}
17
+ # 3. {#apply} is invoked with the signing context
18
+ # 4. Context is used to generate signature
19
+ #
20
+ # @abstract
21
+ class Base
22
+ # Applies the profile to a signing context.
23
+ #
24
+ # Implementations may:
25
+ #
26
+ # - modify context parameters
27
+ # - inject overlay headers
28
+ # - adjust covered components
29
+ #
30
+ # @param ctx [Linzer::Signature::Context]
31
+ # The mutable signing context
32
+ #
33
+ # @return [void]
34
+ #
35
+ # @raise [Linzer::Error]
36
+ # If the subclass does not implement this method
37
+ def apply(ctx)
38
+ raise Error, "Sub-classes are required to implement this method!"
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Linzer
4
+ class Signature
5
+ module Profile
6
+ # Example no-op signing profile.
7
+ #
8
+ # This profile exists solely for documentation and testing purposes.
9
+ # It does not modify the signing context in any way.
10
+ #
11
+ # It demonstrates the expected structure of a signing profile:
12
+ #
13
+ # - initializer receives configuration parameters
14
+ # - {#apply} mutates a {Linzer::Signature::Context}
15
+ #
16
+ # This profile is safe to use but has no effect on signature output.
17
+ class Example < Base
18
+ # Creates a new example profile instance.
19
+ #
20
+ # @param foo [Object] example configuration parameter (unused)
21
+ # @param bar [Object] example configuration parameter (unused)
22
+ def initialize(foo:, bar:)
23
+ @foo = foo
24
+ @bar = bar
25
+ end
26
+
27
+ # Applies this profile to the signing context.
28
+ #
29
+ # This implementation intentionally performs no modifications.
30
+ #
31
+ # @param ctx [Linzer::Signature::Context]
32
+ # @return [void]
33
+ def apply(ctx)
34
+ # no-op
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Linzer
4
+ class Signature
5
+ module Profile
6
+ # Web Bot Auth signing profile implementation.
7
+ #
8
+ # This profile applies the behavior defined in the Web Bot Auth
9
+ # HTTP Message Signatures draft specification.
10
+ #
11
+ # It mutates a signing context to ensure compliance with the
12
+ # spec requirements, including:
13
+ #
14
+ # - selection of required signature components
15
+ # - generation of nonce values
16
+ # - enforcement of Web Bot Auth signature parameters
17
+ # - optional Signature-Agent header injection
18
+ #
19
+ # ## Lifecycle
20
+ #
21
+ # 1. Context is created
22
+ # 2. Profile is resolved
23
+ # 3. {#apply} mutates signing context
24
+ # 4. signature is generated using modified context
25
+ #
26
+ # @see https://datatracker.ietf.org/wg/webbotauth/documents/
27
+ class WebBotAuth < Base
28
+ # Creates a new Web Bot Auth signing profile.
29
+ #
30
+ # @param params [Symbol, nil]
31
+ # Controls default Web Bot Auth signature parameters.
32
+ #
33
+ # - :recommended → apply Web Bot Auth recommended defaults
34
+ # - nil → do not modify signature parameters
35
+ #
36
+ # @param nonce [Symbol, nil]
37
+ # Controls nonce generation behavior.
38
+ #
39
+ # - :generate → inject a cryptographically random nonce
40
+ # - nil → no nonce is added
41
+ #
42
+ # @param agent [String, nil]
43
+ # Optional Signature-Agent identifier URI.
44
+ #
45
+ # When provided, a structured Signature-Agent header is injected
46
+ # and included as a covered signature component.
47
+ def initialize(params: :recommended, nonce: :generate, agent: nil)
48
+ @params = params
49
+ @nonce = nonce
50
+ @agent = agent
51
+ freeze
52
+ end
53
+
54
+ attr_reader :params, :nonce, :agent
55
+
56
+ SIGNATURE_AGENT = "signature-agent"
57
+ private_constant :SIGNATURE_AGENT
58
+
59
+ REQUIRED_AUTH_COMPONENTS = %w[@authority @target-uri].freeze
60
+
61
+ # Applies the Web Bot Auth profile to a signing context.
62
+ #
63
+ # This method mutates:
64
+ # - signature parameters (ctx.params)
65
+ # - covered components (ctx.components)
66
+ # - overlay headers (ctx.overlay_headers)
67
+ #
68
+ # @param ctx [Linzer::Signature::Context]
69
+ # Mutable signing context
70
+ #
71
+ # @return [void]
72
+ # @raise [Linzer::Error]
73
+ # If key or message are incompatible with Web Bot Auth rules
74
+ def apply(ctx)
75
+ validate ctx.key, ctx.message
76
+
77
+ if @params == :recommended
78
+ set_params!(ctx.key, ctx.components, ctx.params)
79
+ end
80
+
81
+ ctx.params[:nonce] = generate_nonce if @nonce == :generate
82
+
83
+ if @agent
84
+ set_agent!(
85
+ @agent,
86
+ ctx.params[:label],
87
+ ctx.message,
88
+ ctx.components,
89
+ ctx.overlay_headers
90
+ )
91
+ end
92
+ end
93
+
94
+ # Returns a default Web Bot Auth profile instance.
95
+ #
96
+ # This represents the standard recommended configuration:
97
+ #
98
+ # - recommended signature parameters enabled
99
+ # - nonce generation enabled
100
+ #
101
+ # @return [WebBotAuth]
102
+ def self.default
103
+ new(params: :recommended, nonce: :generate)
104
+ end
105
+
106
+ private
107
+
108
+ # Applies Web Bot Auth recommended signature parameter rules.
109
+ #
110
+ # This ensures compliance with Web Bot Auth requirements:
111
+ #
112
+ # - At least one of @authority or @target-uri must be covered
113
+ # - expires is set to a default lifetime if not provided
114
+ # - tag is set to "web-bot-auth"
115
+ # - keyid is derived from the signing key fingerprint
116
+ #
117
+ # @param key [Linzer::JWS::Key]
118
+ # @param components [Array<String>]
119
+ # @param params [Hash]
120
+ # @return [void]
121
+ def set_params!(key, components, params)
122
+ # 4.2. Generating HTTP Message Signature
123
+ #
124
+ # Agents MUST include at least one of the following components:
125
+ # - @authority
126
+ # - @target-uri
127
+ #
128
+ if (components & REQUIRED_AUTH_COMPONENTS).empty?
129
+ components << REQUIRED_AUTH_COMPONENTS.sample
130
+ end
131
+
132
+ # Agents MUST include the following @signature-params:
133
+ # - created
134
+ # - expires
135
+ # - keyid MUST be a base64url JWK SHA-256 Thumbprint
136
+ # - tag MUST be web-bot-auth
137
+ #
138
+ # options[:created] is set by default by linzer at signature creation time
139
+ #
140
+ params[:expires] ||= Time.now.to_i + 3600
141
+ params[:tag] ||= "web-bot-auth"
142
+ params[:keyid] ||= key.material.key_digest
143
+ end
144
+
145
+ # Injects and signs the Signature-Agent header.
146
+ #
147
+ # The header is only added if:
148
+ # - it is not already present, OR
149
+ # - its value differs from the configured agent
150
+ #
151
+ # When added:
152
+ # - a structured Signature-Agent header is written into overlay headers
153
+ # - the corresponding structured field is added to covered components
154
+ #
155
+ # @param agent [String]
156
+ # @param label [String]
157
+ # @param message [Linzer::Message]
158
+ # @param components [Array<String>]
159
+ # @param overlay_headers [Hash]
160
+ # @return [void]
161
+ # @raise [Linzer::Error]
162
+ # If the header cannot be serialized as a structured field
163
+ def set_agent!(agent, label, message, components, overlay_headers)
164
+ if message[SIGNATURE_AGENT] != agent
165
+ overlay_headers[SIGNATURE_AGENT] =
166
+ HTTP::StructuredField.serialize_dictionary(label => agent)
167
+
168
+ field = HTTP::StructuredField::Item.new(SIGNATURE_AGENT, key: label)
169
+ serialized_field = HTTP::StructuredField.serialize(field)
170
+ if !components.include?(serialized_field)
171
+ components << serialized_field
172
+ end
173
+ end
174
+ rescue Error => ex
175
+ raise Error, "Invalid #{SIGNATURE_AGENT} header value!", cause: ex
176
+ end
177
+
178
+ # Validates that the context is compatible with Web Bot Auth.
179
+ #
180
+ # @param key [Object]
181
+ # @param message [Linzer::Message]
182
+ # @return [void]
183
+ # @raise [Linzer::Error]
184
+ def validate(key, message)
185
+ raise Error, "Unsupported/invalid key!" unless key.is_a?(Linzer::JWS::Key)
186
+ raise Error, "Web Bot Auth is defined only for requests!" unless message.request?
187
+ end
188
+
189
+ # Generates a cryptographically random nonce.
190
+ #
191
+ # The nonce is URL-safe and suitable for inclusion in HTTP signature
192
+ # parameters.
193
+ #
194
+ # @return [String]
195
+ def generate_nonce
196
+ SecureRandom.urlsafe_base64(64)
197
+ end
198
+ end
199
+ end
200
+ end
201
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "profile/base"
4
+ require_relative "profile/web_bot_auth"
5
+
6
+ module Linzer
7
+ class Signature
8
+ # A signing profile defines optional behavior that can modify a signing
9
+ # context prior to HTTP Message Signature generation.
10
+ #
11
+ # Profiles are used to encapsulate domain-specific signing rules such as:
12
+ #
13
+ # - default covered components
14
+ # - parameter enrichment
15
+ # - contextual header injection
16
+ # - policy-based adjustments to signing behavior
17
+ #
18
+ # Profiles are applied *before signature computation* and may mutate
19
+ # {Linzer::Signature::Context}.
20
+ module Profile
21
+ # Resolves a signing profile from a symbolic or object-based reference.
22
+ #
23
+ # This allows callers to pass either:
24
+ #
25
+ # - +nil+ (no profile)
26
+ # - an already constructed profile instance
27
+ # - a symbolic identifier for a registered profile
28
+ #
29
+ # @param profile [Symbol, Profile::Base, nil]
30
+ # The profile identifier or instance to resolve
31
+ #
32
+ # @return [Profile::Base, nil]
33
+ # A resolved profile instance, or +nil+ if no profile is used
34
+ #
35
+ # @raise [Linzer::Error]
36
+ # If the profile symbol is unknown or unsupported
37
+ def self.resolve(profile)
38
+ unsupported = "Unknown/unsupported signing profile!"
39
+
40
+ case profile
41
+ when NilClass, Profile::Base
42
+ profile
43
+ when Symbol
44
+ case profile
45
+ when :web_bot_auth
46
+ WebBotAuth.default
47
+ else
48
+ raise Error, unsupported
49
+ end
50
+ else
51
+ raise Error, unsupported
52
+ end
53
+ end
54
+
55
+ # Convenience constructor for a Web Bot Auth signing profile.
56
+ #
57
+ # This is a helper for constructing a profile instance directly
58
+ # without using {Profile.resolve}.
59
+ #
60
+ # @param options [Hash]
61
+ # Options passed through to WebBotAuth initializer
62
+ #
63
+ # @return [Profile::WebBotAuth]
64
+ # A configured WebBotAuth profile instance
65
+ def self.web_bot_auth(**options)
66
+ WebBotAuth.new(**options)
67
+ end
68
+ end
69
+ end
70
+ end