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 +4 -4
- data/CHANGELOG.md +37 -0
- data/lib/bsv/auth/auth_fetch.rb +387 -0
- data/lib/bsv/auth/auth_headers.rb +150 -0
- data/lib/bsv/auth/auth_middleware.rb +336 -0
- data/lib/bsv/auth/auth_payload.rb +265 -0
- data/lib/bsv/auth/certificate.rb +289 -0
- data/lib/bsv/auth/get_verifiable_certificates.rb +77 -0
- data/lib/bsv/auth/master_certificate.rb +335 -0
- data/lib/bsv/auth/peer.rb +505 -41
- data/lib/bsv/auth/simplified_fetch_transport.rb +291 -0
- data/lib/bsv/auth/validate_certificates.rb +93 -0
- data/lib/bsv/auth/verifiable_certificate.rb +172 -0
- data/lib/bsv/auth.rb +37 -2
- data/lib/bsv/version.rb +1 -1
- data/lib/bsv/wire_format.rb +209 -0
- data/lib/bsv-sdk.rb +1 -0
- metadata +12 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7b562fa9e3a12641ddf7e24ca393560c70df5a92e75af2c8ed68c7cc1ff0bda0
|
|
4
|
+
data.tar.gz: 912f02ff095cdb88af595e9f97d340a80492cd1b72abd696b90e0bb7a64aa8b5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|