bsv-sdk 0.11.1 → 0.12.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dcf5a5168875f0ff711c5bd328a513689dca51ec26e7d2c1ccf48a0ba54389d9
4
- data.tar.gz: 378499b7a78c41aa0b10026cf40a3dcf80b07e66a0df2c9c364336d9180a0051
3
+ metadata.gz: 7b562fa9e3a12641ddf7e24ca393560c70df5a92e75af2c8ed68c7cc1ff0bda0
4
+ data.tar.gz: 912f02ff095cdb88af595e9f97d340a80492cd1b72abd696b90e0bb7a64aa8b5
5
5
  SHA512:
6
- metadata.gz: fb7ad05f65c746db6ad60cd7d85ff90067df2f70d11f88ae65beed84521479fa9c898261d56ca18a220c774f98532d8cc367ac1f85a22b6fd287e283eb0285d5
7
- data.tar.gz: ae3baed0d279ffb883267656a0a3734a3192f9f84d9bdd8f6f67d4e50adbf01bbdcea3e1ab9f079572afb5f4a86c537b39eaca34821e8ed5d2c02b192a6d0ab9
6
+ metadata.gz: 44ab1a69fc9e2a9e7eb4fbcdfa09b7d6870bbba81f1f8d1e8f0265f89e450c38488fefb23729bc9b545aa4b9baacaa49621faa484468d121753950a207108b45
7
+ data.tar.gz: bf945875e3253d82376b27c23f9bb359a45fc426b99447f98a54b1b30335876708dc50e98d848b1a9f7546028ede523e7edd3790d3d33e01e3a47eb16dd151d7
data/CHANGELOG.md CHANGED
@@ -5,6 +5,43 @@ All notable changes to the `bsv-sdk` gem are documented here.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
6
6
  and this gem adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## 0.12.0 — 2026-04-15
9
+
10
+ ### Added
11
+ - Certificate infrastructure: `BSV::Auth::Certificate` base class with field map,
12
+ serialisation, and signature verification (#420)
13
+ - `BSV::Auth::MasterCertificate` for certificate issuance with identity key
14
+ binding (#421)
15
+ - `BSV::Auth::VerifiableCertificate` for selective field revelation with
16
+ proof-of-field-revelation (#422)
17
+ - Certificate utilities: `validate_certificates` and `get_verifiable_certificates`
18
+ helpers (#427, #428)
19
+ - Peer protocol: `certificateRequest`/`certificateResponse` message handling,
20
+ callback registration, and `last_interacted_peer` (#430, #431)
21
+ - High-level peer session API: `Peer#to_peer` and `Peer#get_authenticated_session`
22
+ for reusable authenticated sessions (#433)
23
+ - BRC-104 HTTP auth transport: `AuthFetch` client, `SimplifiedFetchTransport`,
24
+ `AuthMiddleware` (Rack), and `AuthHeaders`/`AuthPayload` serialiser (#437–#441)
25
+ - `AuthFetch` 402 payment handling for paid API endpoints (#441)
26
+ - `BSV::WireFormat` module for camelCase/snake_case conversion at JSON
27
+ boundaries (#447)
28
+ - Cross-SDK conformance and integration specs for certificates (#423)
29
+ - Peer protocol integration tests (#434)
30
+ - BRC-104 integration tests (#442)
31
+
32
+ ### Fixed
33
+ - Auth handshake now uses shallow key conversion to avoid corrupting nested
34
+ message payloads
35
+ - Certificate classes hardened against edge cases from code review
36
+ - Certifier allowlist enforced in `process_certificate_response`
37
+ - Flaky `validate_certificates_spec` fixed (missing `require 'base64'`)
38
+
39
+ ### Changed
40
+ - `GetVerifiableCertificates` bug warning note removed (underlying bug now
41
+ fixed in bsv-wallet)
42
+ - Peer protocol internals refactored: `PairedTransport` helper, deduplicated
43
+ high-level API, hardened message processing
44
+
8
45
  ## 0.11.1 — 2026-04-13
9
46
 
10
47
  ### Fixed
@@ -0,0 +1,387 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+ require 'json'
5
+ require 'base64'
6
+ require 'securerandom'
7
+ require 'timeout'
8
+
9
+ module BSV
10
+ module Auth
11
+ # BRC-104 high-level client for authenticated HTTP requests.
12
+ #
13
+ # Manages a pool of {Peer} instances (one per base URL) and serialises
14
+ # HTTP requests as BRC-104 binary payloads. The handshake is initiated
15
+ # automatically on the first request to each base URL.
16
+ #
17
+ # When the server responds with HTTP 402 Payment Required, AuthFetch
18
+ # automatically constructs a BSV payment transaction via the wallet and
19
+ # retries the original request with an +x-bsv-payment+ header attached.
20
+ #
21
+ # Thread safety: the +@peers+ hash is protected by a mutex. Each in-flight
22
+ # request uses its own +Queue+ to receive exactly the matching response.
23
+ #
24
+ # @example
25
+ # client = BSV::Auth::AuthFetch.new(wallet: my_wallet)
26
+ # response = client.fetch('https://api.example.com/resource')
27
+ # puts response.status # => 200
28
+ # puts response.body # => "..."
29
+ class AuthFetch
30
+ DEFAULT_TIMEOUT = 30
31
+ DEFAULT_PAYMENT_RETRIES = 3
32
+ PAYMENT_RETRY_DELAY_MS = 250
33
+ PAYMENT_PROTOCOL_ID = [2, '3241645161d8'].freeze
34
+ SUPPORTED_PAYMENT_VERSION = '1.0'
35
+
36
+ # @param wallet [BSV::Wallet::Interface] wallet for crypto operations
37
+ # @param requested_certificates [Hash, nil] certificate set to request from peers
38
+ # @param session_manager [SessionManager, nil] optional shared session store
39
+ # @param payment_max_retries [Integer] maximum number of payment retry attempts (default 3)
40
+ def initialize(wallet:, requested_certificates: nil, session_manager: nil, payment_max_retries: DEFAULT_PAYMENT_RETRIES)
41
+ @wallet = wallet
42
+ @requested_certificates = requested_certificates
43
+ @session_manager = session_manager
44
+ @payment_max_retries = payment_max_retries
45
+ @peers = {}
46
+ @peers_mutex = Mutex.new
47
+ end
48
+
49
+ # Sends an authenticated HTTP request to +url+.
50
+ #
51
+ # The first request to a new base URL performs a BRC-103 mutual-auth
52
+ # handshake automatically. Subsequent requests reuse the cached peer.
53
+ #
54
+ # If the server responds with 402, a BSV payment is created and the
55
+ # request is retried with an +x-bsv-payment+ header (up to
56
+ # +payment_max_retries+ times).
57
+ #
58
+ # @param url [String] full URL including scheme, host, optional port, path, and query
59
+ # @param method [String] HTTP method (default 'GET')
60
+ # @param headers [Hash] request headers; only +content-type+, +authorization+,
61
+ # and +x-bsv-*+ (excluding +x-bsv-auth-*+) are allowed
62
+ # @param body [String, Hash, nil] request body
63
+ # @param timeout [Integer] seconds to wait for the authenticated response (default 30)
64
+ # @return [AuthResponse]
65
+ # @raise [ArgumentError] if disallowed headers are provided
66
+ # @raise [Timeout::Error] if no response arrives within +timeout+ seconds
67
+ # @raise [AuthError] if 402 payment handling fails or max retries are exceeded
68
+ def fetch(url, method: 'GET', headers: {}, body: nil, timeout: DEFAULT_TIMEOUT)
69
+ do_fetch(url, method: method, headers: headers, body: body, timeout: timeout,
70
+ retry_count: 0, payment_context: nil)
71
+ end
72
+
73
+ private
74
+
75
+ def do_fetch(url, method:, headers:, body:, timeout:, retry_count:, payment_context:)
76
+ uri = URI.parse(url)
77
+ base_url = extract_base_url(uri)
78
+ path = uri.path.empty? ? '/' : uri.path
79
+ query = uri.query ? "?#{uri.query}" : nil
80
+
81
+ peer = get_or_create_peer(base_url)
82
+
83
+ # Validate and filter headers (raises ArgumentError for unsupported headers)
84
+ filtered_headers = AuthHeaders.filter_request_headers(headers)
85
+
86
+ # Normalise body: Hash → JSON string, set content-type if not already set
87
+ effective_body, filtered_headers = normalise_body(body, filtered_headers)
88
+
89
+ # Generate 32-byte request nonce
90
+ request_nonce = SecureRandom.random_bytes(32)
91
+
92
+ # Serialise request to binary payload
93
+ payload_bytes = AuthPayload.serialize_request(
94
+ request_nonce: request_nonce,
95
+ method: method,
96
+ path: path,
97
+ query: query,
98
+ headers: filtered_headers,
99
+ body: effective_body
100
+ )
101
+
102
+ # Queue to block until the matching response arrives
103
+ response_queue = Queue.new
104
+
105
+ # Register a one-shot callback on the peer
106
+ callback_id = peer.on_general_message do |sender_key, raw_payload|
107
+ binary = array_to_binary(raw_payload)
108
+ # Match by first 32 bytes of payload
109
+ next unless binary.bytesize >= 32 && binary.byteslice(0, 32) == request_nonce
110
+
111
+ begin
112
+ deserialized = AuthPayload.deserialize_response(binary)
113
+ response_queue.push({ ok: true, data: deserialized, identity_key: sender_key })
114
+ rescue StandardError => e
115
+ response_queue.push({ ok: false, error: e })
116
+ end
117
+ end
118
+
119
+ begin
120
+ peer.to_peer(payload_bytes.bytes)
121
+ rescue AuthError => e
122
+ peer.off_general_message(callback_id)
123
+ if e.message.include?('Session not found') && retry_count < 3
124
+ # Stale session — clear the cached peer and retry with a fresh one
125
+ @peers_mutex.synchronize { @peers.delete(base_url) }
126
+ return do_fetch(url, method: method, headers: headers, body: body,
127
+ timeout: timeout, retry_count: retry_count + 1,
128
+ payment_context: payment_context)
129
+ end
130
+ raise
131
+ end
132
+
133
+ begin
134
+ result = Timeout.timeout(timeout) { response_queue.pop }
135
+ rescue Timeout::Error
136
+ peer.off_general_message(callback_id)
137
+ raise Timeout::Error, "AuthFetch timed out waiting for response from #{base_url}"
138
+ end
139
+
140
+ peer.off_general_message(callback_id)
141
+
142
+ raise result[:error] unless result[:ok]
143
+
144
+ data = result[:data]
145
+ response = AuthResponse.new(
146
+ status: data[:status],
147
+ headers: pairs_to_hash(data[:headers]),
148
+ body: data[:body] || '',
149
+ identity_key: result[:identity_key]
150
+ )
151
+
152
+ return response unless response.status == 402
153
+
154
+ handle_payment_required(url, method, headers, body, timeout, response, payment_context)
155
+ end
156
+
157
+ # Handles a 402 Payment Required response by creating a BSV payment
158
+ # transaction and retrying the original request.
159
+ #
160
+ # @param url [String] the original request URL
161
+ # @param method [String] HTTP method
162
+ # @param headers [Hash] original request headers
163
+ # @param body original request body
164
+ # @param timeout [Integer] request timeout in seconds
165
+ # @param response [AuthResponse] the 402 response
166
+ # @param payment_context [Hash, nil] existing payment context (for retries)
167
+ # @return [AuthResponse] the response after payment
168
+ # @raise [AuthError] if payment headers are invalid, wallet lacks capability,
169
+ # or maximum retries are exhausted
170
+ def handle_payment_required(url, method, headers, body, timeout, response, payment_context)
171
+ validated = validate_payment_headers(response.headers)
172
+
173
+ satoshis_required = validated[:satoshis_required]
174
+ server_identity_key = validated[:server_identity_key]
175
+ derivation_prefix = validated[:derivation_prefix]
176
+
177
+ # Determine whether we can reuse an existing payment context or must create a new one
178
+ new_context = if payment_context && compatible_payment_context?(payment_context, satoshis_required, server_identity_key, derivation_prefix)
179
+ payment_context
180
+ else
181
+ create_payment_context(url, satoshis_required, server_identity_key, derivation_prefix)
182
+ end
183
+
184
+ attempt = new_context[:attempts] + 1
185
+
186
+ if attempt > @payment_max_retries
187
+ raise AuthError, "Payment for #{url} failed after #{@payment_max_retries} attempt(s): " \
188
+ "#{satoshis_required} satoshis required"
189
+ end
190
+
191
+ new_context[:attempts] = attempt
192
+
193
+ payment_header_value = JSON.generate(
194
+ 'derivationPrefix' => new_context[:derivation_prefix],
195
+ 'derivationSuffix' => new_context[:derivation_suffix],
196
+ 'transaction' => new_context[:transaction_base64]
197
+ )
198
+
199
+ # Apply linear backoff for retries after the first attempt
200
+ sleep(PAYMENT_RETRY_DELAY_MS * attempt / 1000.0) if attempt > 1
201
+
202
+ retry_headers = headers.merge(AuthHeaders::PAYMENT => payment_header_value)
203
+
204
+ do_fetch(url, method: method, headers: retry_headers, body: body, timeout: timeout,
205
+ retry_count: 0, payment_context: new_context)
206
+ end
207
+
208
+ # Validates the payment-related headers from a 402 response.
209
+ #
210
+ # @param response_headers [Hash] response headers (string keys)
211
+ # @return [Hash] validated values: :satoshis_required, :server_identity_key, :derivation_prefix
212
+ # @raise [AuthError] if any required header is missing or invalid
213
+ def validate_payment_headers(response_headers)
214
+ version = response_headers[AuthHeaders::PAYMENT_VERSION]
215
+ unless version == SUPPORTED_PAYMENT_VERSION
216
+ raise AuthError,
217
+ "Unsupported #{AuthHeaders::PAYMENT_VERSION} response header. " \
218
+ "Client version: #{SUPPORTED_PAYMENT_VERSION}, server version: #{version.inspect}"
219
+ end
220
+
221
+ satoshis_str = response_headers[AuthHeaders::PAYMENT_SATOSHIS_REQUIRED]
222
+ raise AuthError, "Missing #{AuthHeaders::PAYMENT_SATOSHIS_REQUIRED} response header." unless satoshis_str
223
+
224
+ satoshis = Integer(satoshis_str, 10)
225
+ raise AuthError, "Invalid #{AuthHeaders::PAYMENT_SATOSHIS_REQUIRED} value: must be a positive integer." unless satoshis.positive?
226
+
227
+ server_identity_key = response_headers[AuthHeaders::IDENTITY_KEY]
228
+ unless server_identity_key.is_a?(String) && !server_identity_key.empty?
229
+ raise AuthError,
230
+ "Missing #{AuthHeaders::IDENTITY_KEY} response header."
231
+ end
232
+
233
+ derivation_prefix = response_headers[AuthHeaders::PAYMENT_DERIVATION_PREFIX]
234
+ unless derivation_prefix.is_a?(String) && !derivation_prefix.empty?
235
+ raise AuthError,
236
+ "Missing #{AuthHeaders::PAYMENT_DERIVATION_PREFIX} response header."
237
+ end
238
+
239
+ {
240
+ satoshis_required: satoshis,
241
+ server_identity_key: server_identity_key,
242
+ derivation_prefix: derivation_prefix
243
+ }
244
+ rescue ArgumentError
245
+ raise AuthError, "Invalid #{AuthHeaders::PAYMENT_SATOSHIS_REQUIRED} value: must be a positive integer."
246
+ end
247
+
248
+ # Creates a new payment context by deriving a payment key and building a transaction.
249
+ #
250
+ # @param url [String] request URL (used in the transaction description)
251
+ # @param satoshis_required [Integer] payment amount
252
+ # @param server_identity_key [String] server's compressed public key hex
253
+ # @param derivation_prefix [String] BIP-32 derivation prefix from the server
254
+ # @return [Hash] payment context with keys :satoshis_required, :server_identity_key,
255
+ # :derivation_prefix, :derivation_suffix, :transaction_base64, :attempts
256
+ # @raise [AuthError] if the wallet does not support payment methods
257
+ def create_payment_context(url, satoshis_required, server_identity_key, derivation_prefix)
258
+ unless @wallet.respond_to?(:get_public_key) && @wallet.respond_to?(:create_action)
259
+ raise AuthError, '402 payment handling requires a wallet that supports create_action and get_public_key'
260
+ end
261
+
262
+ base_url = extract_base_url(URI.parse(url))
263
+
264
+ derivation_suffix = Nonce.create(@wallet)
265
+
266
+ key_result = @wallet.get_public_key(
267
+ protocol_id: PAYMENT_PROTOCOL_ID,
268
+ key_id: "#{derivation_prefix} #{derivation_suffix}",
269
+ counterparty: server_identity_key
270
+ )
271
+ public_key_hex = key_result[:public_key]
272
+
273
+ address = BSV::Primitives::PublicKey.from_hex(public_key_hex).address
274
+ lock_script = BSV::Script::Script.p2pkh_lock(address)
275
+
276
+ action_result = @wallet.create_action(
277
+ description: "Payment for request to #{base_url}",
278
+ outputs: [{
279
+ satoshis: satoshis_required,
280
+ locking_script: lock_script.to_hex,
281
+ output_description: 'HTTP request payment'
282
+ }]
283
+ )
284
+
285
+ tx_bytes = action_result[:tx] || action_result['tx']
286
+ raise AuthError, 'wallet.create_action did not return a :tx byte array' unless tx_bytes.is_a?(Array)
287
+
288
+ transaction_base64 = Base64.strict_encode64(tx_bytes.pack('C*'))
289
+
290
+ {
291
+ satoshis_required: satoshis_required,
292
+ server_identity_key: server_identity_key,
293
+ derivation_prefix: derivation_prefix,
294
+ derivation_suffix: derivation_suffix,
295
+ transaction_base64: transaction_base64,
296
+ attempts: 0
297
+ }
298
+ end
299
+
300
+ # Returns true if the existing payment context is compatible with the
301
+ # current 402 response requirements (same satoshis, identity key, and prefix).
302
+ def compatible_payment_context?(context, satoshis_required, server_identity_key, derivation_prefix)
303
+ context[:satoshis_required] == satoshis_required &&
304
+ context[:server_identity_key] == server_identity_key &&
305
+ context[:derivation_prefix] == derivation_prefix
306
+ end
307
+
308
+ # Returns an existing peer for +base_url+ or creates a new one.
309
+ def get_or_create_peer(base_url)
310
+ @peers_mutex.synchronize do
311
+ @peers[base_url] ||= build_peer(base_url)
312
+ end
313
+ end
314
+
315
+ def build_peer(base_url)
316
+ transport = SimplifiedFetchTransport.new(base_url)
317
+ Peer.new(
318
+ wallet: @wallet,
319
+ transport: transport,
320
+ session_manager: @session_manager,
321
+ certificates_to_request: @requested_certificates
322
+ )
323
+ end
324
+
325
+ # Extracts scheme + host + port from a URI.
326
+ #
327
+ # For standard ports (80 for http, 443 for https), the port is omitted
328
+ # to match browser +URL#origin+ behaviour used in the TS SDK.
329
+ def extract_base_url(uri)
330
+ default_port = uri.scheme == 'https' ? 443 : 80
331
+ if uri.port == default_port
332
+ "#{uri.scheme}://#{uri.host}"
333
+ else
334
+ "#{uri.scheme}://#{uri.host}:#{uri.port}"
335
+ end
336
+ end
337
+
338
+ # Normalises the request body and updates headers accordingly.
339
+ #
340
+ # - Hash body → JSON.generate, sets content-type to application/json if absent
341
+ # - String/nil body → used as-is (nil body defaults handled by AuthPayload)
342
+ #
343
+ # Returns [normalised_body, updated_filtered_headers].
344
+ def normalise_body(body, filtered_headers)
345
+ if body.is_a?(Hash)
346
+ json_body = JSON.generate(body)
347
+ # Add content-type: application/json if not already present
348
+ has_ct = filtered_headers.any? { |k, _| k == 'content-type' }
349
+ filtered_headers = (filtered_headers + [['content-type', 'application/json']]).sort_by { |k, _| k } unless has_ct
350
+ [json_body, filtered_headers]
351
+ else
352
+ [body, filtered_headers]
353
+ end
354
+ end
355
+
356
+ # Converts an Array<[key, value]> to a Hash with string keys.
357
+ def pairs_to_hash(pairs)
358
+ return {} unless pairs.is_a?(Array)
359
+
360
+ pairs.each_with_object({}) { |(k, v), h| h[k.to_s] = v.to_s }
361
+ end
362
+
363
+ # Converts Array<Integer> or binary String to a binary String.
364
+ def array_to_binary(payload)
365
+ return payload if payload.is_a?(String)
366
+
367
+ payload.map(&:chr).join.force_encoding('BINARY')
368
+ end
369
+ end
370
+
371
+ # Immutable value object representing an authenticated HTTP response.
372
+ class AuthResponse
373
+ attr_reader :status, :headers, :body, :identity_key
374
+
375
+ # @param status [Integer] HTTP status code
376
+ # @param headers [Hash] response headers
377
+ # @param body [String] response body
378
+ # @param identity_key [String] server's compressed public key hex
379
+ def initialize(status:, headers:, body:, identity_key:)
380
+ @status = status
381
+ @headers = headers
382
+ @body = body
383
+ @identity_key = identity_key
384
+ end
385
+ end
386
+ end
387
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BSV
4
+ module Auth
5
+ # BRC-104 HTTP header name constants and filtering helpers.
6
+ #
7
+ # Provides the canonical header names used in BSV auth HTTP transport,
8
+ # matching the Go SDK's brc104/auth_http_headers.go exactly.
9
+ #
10
+ # Header filtering:
11
+ # - Requests: content-type (normalised), authorization, x-bsv-* (excl. x-bsv-auth-*)
12
+ # - Responses: authorization, x-bsv-* (excl. x-bsv-auth-*) — content-type is NOT signed
13
+ #
14
+ # Both {.filter_request_headers} and {.filter_response_headers} return sorted
15
+ # [key, value] pairs ready for payload serialisation.
16
+ module AuthHeaders
17
+ module_function
18
+
19
+ # Common prefix for all BSV auth headers.
20
+ AUTH_PREFIX = 'x-bsv-auth'
21
+
22
+ # Prefix for BSV payment headers (excluded from signed payload).
23
+ PAYMENT_PREFIX = 'x-bsv-payment'
24
+
25
+ # Prefix for all BSV custom headers (non-auth).
26
+ BSV_PREFIX = 'x-bsv-'
27
+
28
+ # Protocol version header.
29
+ VERSION = 'x-bsv-auth-version'
30
+
31
+ # Sender's identity public key (compressed hex).
32
+ IDENTITY_KEY = 'x-bsv-auth-identity-key'
33
+
34
+ # Sender's nonce.
35
+ NONCE = 'x-bsv-auth-nonce'
36
+
37
+ # Recipient's nonce (echoed back for mutual verification).
38
+ YOUR_NONCE = 'x-bsv-auth-your-nonce'
39
+
40
+ # Request ID for correlation (base64-encoded 32 bytes).
41
+ REQUEST_ID = 'x-bsv-auth-request-id'
42
+
43
+ # Message type string (e.g. 'general', 'initialRequest').
44
+ MESSAGE_TYPE = 'x-bsv-auth-message-type'
45
+
46
+ # ECDSA/Schnorr signature (hex-encoded).
47
+ SIGNATURE = 'x-bsv-auth-signature'
48
+
49
+ # Requested certificate set (JSON-encoded).
50
+ REQUESTED_CERTIFICATES = 'x-bsv-auth-requested-certificates'
51
+
52
+ # Payment protocol version.
53
+ PAYMENT_VERSION = 'x-bsv-payment-version'
54
+
55
+ # Number of satoshis required for payment.
56
+ PAYMENT_SATOSHIS_REQUIRED = 'x-bsv-payment-satoshis-required'
57
+
58
+ # BIP-32 derivation prefix for payment key.
59
+ PAYMENT_DERIVATION_PREFIX = 'x-bsv-payment-derivation-prefix'
60
+
61
+ # Raw payment transaction (hex or base64).
62
+ PAYMENT = 'x-bsv-payment'
63
+
64
+ # Filters request headers to the set that is signed in the payload.
65
+ #
66
+ # Includes (matching TS/Go SDK behaviour):
67
+ # - +content-type+ — normalised by stripping "; charset=..." params
68
+ # - +authorization+
69
+ # - +x-bsv-*+ headers, excluding any that start with +x-bsv-auth+
70
+ #
71
+ # Raises +ArgumentError+ if any header does not fall into the above
72
+ # categories (matching TS SDK strictness — an unsupported header that
73
+ # the caller expects to be signed is a security concern).
74
+ #
75
+ # @param headers [Hash] header key/value pairs (keys need not be lowercased)
76
+ # @return [Array<Array(String, String)>] sorted [key, value] pairs, keys lowercased
77
+ # @raise [ArgumentError] if an unsupported header is present
78
+ def filter_request_headers(headers)
79
+ result = []
80
+ headers.each do |k, v|
81
+ key = k.to_s.downcase
82
+ if key.start_with?(BSV_PREFIX)
83
+ raise ArgumentError, "BSV auth headers are not allowed in the request payload: #{key}" if key.start_with?(AUTH_PREFIX)
84
+ next if key.start_with?(PAYMENT_PREFIX) # payment headers are transport-level, not signed
85
+
86
+ result << [key, v.to_s]
87
+ elsif key == 'authorization'
88
+ result << [key, v.to_s]
89
+ elsif key == 'content-type'
90
+ # Normalise: strip parameters like "; charset=utf-8"
91
+ normalised = v.to_s.split(';').first.to_s.strip
92
+ result << [key, normalised]
93
+ else
94
+ raise ArgumentError,
95
+ 'Unsupported header in the simplified fetch implementation. ' \
96
+ "Only content-type, authorization, and x-bsv-* headers are supported. Got: #{key}"
97
+ end
98
+ end
99
+ result.sort_by { |key, _| key }
100
+ end
101
+
102
+ # Filters response headers to the set that is signed in the payload.
103
+ #
104
+ # Includes (matching TS/Go SDK behaviour):
105
+ # - +authorization+
106
+ # - +x-bsv-*+ headers, excluding any that start with +x-bsv-auth+
107
+ #
108
+ # Note: +content-type+ is intentionally excluded from response signing
109
+ # (matches both Go and TS SDK behaviour).
110
+ #
111
+ # @param headers [Hash] header key/value pairs
112
+ # @return [Array<Array(String, String)>] sorted [key, value] pairs, keys lowercased
113
+ def filter_response_headers(headers)
114
+ result = []
115
+ headers.each do |k, v|
116
+ key = k.to_s.downcase
117
+ if key.start_with?(BSV_PREFIX)
118
+ next if key.start_with?(AUTH_PREFIX)
119
+
120
+ result << [key, v.to_s]
121
+ elsif key == 'authorization'
122
+ result << [key, v.to_s]
123
+ end
124
+ end
125
+ result.sort_by { |key, _| key }
126
+ end
127
+
128
+ # Extracts +x-bsv-auth-*+ headers from a hash and returns a structured hash
129
+ # with symbolised keys derived from the header name suffix.
130
+ #
131
+ # For example, +x-bsv-auth-identity-key+ becomes +:identity_key+.
132
+ #
133
+ # @param headers [Hash] incoming headers hash (keys may be mixed case)
134
+ # @return [Hash] symbolised auth header values; only present keys are included
135
+ def extract_auth_headers(headers)
136
+ prefix = "#{AUTH_PREFIX}-"
137
+ result = {}
138
+ headers.each do |k, v|
139
+ key = k.to_s.downcase
140
+ next unless key.start_with?(prefix)
141
+
142
+ suffix = key[prefix.length..]
143
+ symbol_key = suffix.tr('-', '_').to_sym
144
+ result[symbol_key] = v
145
+ end
146
+ result
147
+ end
148
+ end
149
+ end
150
+ end