Skip to content

feat(x402): implement BSV x402 protocol middleware gem#194

Closed
sgbett wants to merge 1 commit intomasterfrom
feature/97-bsv-x402-gem
Closed

feat(x402): implement BSV x402 protocol middleware gem#194
sgbett wants to merge 1 commit intomasterfrom
feature/97-bsv-x402-gem

Conversation

@sgbett
Copy link
Owner

@sgbett sgbett commented Mar 21, 2026

Summary

  • New bsv-x402 gem implementing the Merkleworks x402-BSV spec v1.0
  • Rack middleware for server-side HTTP 402 challenge/proof flow
  • Auto-pay client for client-side 402 handling
  • RFC 8785 canonical JSON, base64url encoding, request binding
  • 9-step ordered verification procedure (spec invariant V-1)
  • Test vector conformance (challenge-vector-001)
  • 317 x402-specific specs, all passing (2036 total across all gems)

Components

  • Protocol objects: Challenge, Proof, NonceUTXO, CanonicalJSON, Encoding, RequestBinding
  • Verification: Verifier (9-step), VerificationResult with HTTP status mapping
  • Middleware: Rack middleware, RouteMap, ChallengeGenerator, ChallengeStore, NonceProvider
  • Client: TransactionBuilder, ProofBuilder, Client (auto-pay)
  • Examples: Rack app setup, client usage

Test plan

  • All specs pass (bundle exec rake) -- 2036 examples, 0 failures
  • Linter clean (bundle exec rubocop) -- 42 x402 files, no offences
  • Test vector conformance verified (challenge-vector-001 SHA-256 matches)
  • E2E integration tests cover full 402 -> pay -> 200 flow
  • Error paths tested (expired, replay, insufficient payment, wrong payee, malformed proof)

Closes #97

Generated with Claude Code

Implement the bsv-x402 gem providing Rack middleware for HTTP 402
settlement-gated access using the Merkleworks x402-BSV spec v1.0.

Server-side: Rack middleware with route protection, challenge generation,
9-step proof verification, nonce provider interface, challenge store.

Client-side: auto-pay HTTP client, transaction builder, proof builder.

Protocol: RFC 8785 canonical JSON, base64url encoding, request binding,
test vector conformance (challenge-vector-001).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@sgbett
Copy link
Owner Author

sgbett commented Mar 21, 2026

Security Audit Report — bsv-x402 Rack Middleware

Reviewed by: White Hat (automated offensive security review)
Scope: PR #194 — full x402 gem implementation
Risk summary: No critical or high-severity findings. The implementation is well-structured with appropriate input validation and error handling. Several medium and low findings are documented below, plus informational notes. The most significant concern is the unbounded memory growth in MemoryChallengeStore, which is a production DoS risk when the store is used without an eviction policy.


MEDIUM Findings


M-1 — Unbounded memory growth in MemoryChallengeStore (memory DoS)

File: lib/bsv/x402/challenge_store.rb, lines 45–81

Description: MemoryChallengeStore stores challenges indefinitely — there is no TTL, no maximum capacity, and no eviction logic. Every inbound request to a protected route causes a new Challenge to be written into the store (regardless of whether the client ever follows up with a proof). The comment on line 43 acknowledges this:

Challenges are not automatically expired from the store; they remain until the process restarts. For long-running processes, consider a store with TTL support.

An attacker can drive unbounded heap growth by hammering the 402 endpoint without ever submitting a proof. Each challenge is a frozen Challenge object containing a frozen NonceUTXO — relatively small, but at scale (e.g. 10 million requests) this will exhaust server RAM and OOM-kill the process.

Attack scenario:

  1. Attacker identifies a protected route (e.g. GET /api/premium).
  2. Sends a continuous stream of GET /api/premium with no X402-Proof header.
  3. Each request causes ChallengeGenerator.generate + @config.challenge_store.store(...) to fire.
  4. MemoryChallengeStore accumulates challenges without bound.
  5. Process OOMs and dies; any queued legitimate requests are lost.

No authentication or rate-limiting is applied before challenge issuance.

Severity: MEDIUM — exploitable without credentials, but requires sustained traffic and is mitigated if the operator replaces MemoryChallengeStore with a TTL-backed store (as documented). The severity would be HIGH for deployments that rely on the default store in production.

Remediation:

Add a capacity cap and/or TTL eviction to MemoryChallengeStore. A simple approach:

MAX_ENTRIES = 100_000

def store(sha256, challenge)
  @mutex.synchronize do
    evict_expired! # remove challenges past expires_at
    raise 'store capacity exceeded' if @store.size >= MAX_ENTRIES
    @store[sha256] = challenge
  end
end

def evict_expired!
  now = Time.now.to_i
  @store.delete_if { |_, c| c.expires_at < now }
end

Alternatively, make expires_in a constructor parameter and delete entries atomically on fetch if expired.


M-2 — No size limit on rawtx_b64 transaction bytes (transaction-level DoS)

File: lib/bsv/x402/encoding.rb lines 53–57; lib/bsv/x402/proof.rb line 97

Description: The 64 KiB size check in Encoding.base64url_decode (line 31) applies to the outer proof header, but the transaction bytes are embedded inside the already-decoded JSON as the rawtx_b64 value — a standard base64 string. Encoding.base64_decode (line 53) applies Base64.strict_decode64 with no size limit at all.

A crafted proof can carry an arbitrarily large rawtx_b64 value. Since it is inside the JSON payload of the outer base64url blob, the 64 KiB outer limit provides only a coarse bound: a 64 KiB base64url envelope decodes to ~48 KiB of JSON, within which the rawtx_b64 field can consume most of that budget. That is larger than a typical BSV transaction, so this is a modest but real concern.

More importantly, BSV::Transaction::Transaction.from_binary(raw_bytes) (verifier.rb line 203) is called on the decoded bytes from an untrusted source. If the BSV SDK's deserialiser has any allocations proportional to field values (e.g. a script length prefix claiming a very large script), the 64 KiB outer cap does limit worst-case allocation to roughly 48 KiB, which is acceptable. However, if the SDK is ever updated to increase MAX_HEADER_BYTES, this implicit dependency becomes a silent risk.

Severity: MEDIUM (bounded by the outer 64 KiB cap today, but fragile)

Remediation: Add an explicit size check on the decoded rawtx_b64 bytes before parsing:

# In Proof#decoded_tx or Verifier#step6:
MAX_TX_BYTES = 10_000  # BSV Genesis removed the 1MB limit, but 10KB covers typical micropayment txs
raw_bytes = Encoding.base64_decode(payment['rawtx_b64'])
raise EncodingError, 'rawtx_b64 exceeds maximum transaction size' if raw_bytes.bytesize > MAX_TX_BYTES
raw_bytes

M-3 — Challenge hash comparison is not constant-time (timing side-channel)

File: lib/bsv/x402/verifier.rb, lines 127–132

Description: Step 3 compares the expected challenge SHA-256 with the proof-supplied value using Ruby's == operator:

return fail(3, '...') unless actual == expected

Ruby's String#== short-circuits on the first differing byte. This creates a timing oracle: an attacker who can measure server response time with sufficient resolution can recover the correct challenge hash one byte at a time. They could use this to forge a lookup key for an expired or otherwise invalid challenge.

Practical impact: The SHA-256 values are hex strings (64 ASCII chars). Exploiting this requires many thousands of requests with sub-millisecond timing precision, which is non-trivial over a network but feasible in co-located environments. The challenge hash is computed deterministically from the canonical JSON, so knowing the hash doesn't directly let an attacker replay the challenge — the challenge content must also be correct. This limits the severity.

Severity: MEDIUM (harder to exploit than a typical MAC timing oracle, but the pattern is incorrect and should be fixed)

Remediation: Use constant-time comparison:

require 'openssl'

def constant_time_equal?(a, b)
  OpenSSL.fixed_length_secure_compare(a, b)
rescue ArgumentError
  false  # lengths differ — still constant-time in the length check
end

Apply this in step3_verify_challenge_sha256 and in Proof#valid_txid? where computed_txid == payment['txid'] is compared.

Note: OpenSSL.fixed_length_secure_compare requires equal-length strings and raises ArgumentError if they differ — handle this explicitly.


LOW Findings


L-1 — Client recurses on every 402 response, including its own retry headers (payment double-spend risk)

File: lib/bsv/x402/client.rb, lines 155–161

Description: When a retry returns another 402, handle_payment recurses with retry_headers — which already contain the X402-Proof header from the previous attempt. The new 402 response will contain a fresh challenge, but the recursive call will merge another X402-Proof into retry_headers, building on the previous headers. In practice, merge overwrites the key, so only the latest proof is sent. However, the already-broadcast transaction from the first attempt has spent the client's funding UTXOs. The second TransactionBuilder.build call will attempt to reuse the same funding_utxos array — including UTXOs that are now spent on-chain.

The builder does not check whether UTXOs have already been consumed (it cannot, without a UTXO set query). The resulting double-spend attempt will be rejected by miners but the client will have lost funds from the broadcast first transaction if the server is malicious (e.g. a server that deliberately returns 402 repeatedly to drain a client's UTXOs).

Severity: LOW (requires a malicious server; legitimate servers would not do this intentionally)

Remediation: Document this clearly and consider tracking spent UTXOs across retries:

def handle_payment(method, url, req_headers, body, resp_headers, attempts:, spent_utxo_txids: [])
  # Filter out already-spent UTXOs before building the next transaction
  available_utxos = @funding_utxos.reject { |u| spent_utxo_txids.include?(u[:txid]) }
  raise InsufficientFundsError, 'all funding UTXOs spent in prior attempts' if available_utxos.empty?
  # ...
  spent_utxo_txids += available_utxos.map { |u| u[:txid] }
  handle_payment(..., spent_utxo_txids: spent_utxo_txids)
end

L-2 — Net::HTTP.const_get(method.capitalize) accepts arbitrary method strings (reflection injection)

File: lib/bsv/x402/client.rb, lines 210–211

Description:

req_class = Net::HTTP.const_get(method.capitalize)

If method is an attacker-controlled string (possible if the caller passes it from user input), const_get will look up any constant in the Net::HTTP namespace. For example:

  • method = "Version"Net::HTTP::Version (a string constant) → NoMethodError when .new is called
  • method = "Proxy_pass" → similar constant lookup surprise

This is not a direct code execution path, but it is unexpected behaviour and could cause confusing exceptions that leak Net::HTTP internals in error messages.

Severity: LOW (the method value flows from Client#get/Client#post/Client#request which are called programmatically, not directly from HTTP input; however the API accepts arbitrary strings)

Remediation: Whitelist HTTP methods:

ALLOWED_METHODS = %w[Get Post Put Patch Delete Head Options].freeze

def default_http_request(method, url, headers, body)
  class_name = method.capitalize
  raise ArgumentError, "unsupported HTTP method: #{method}" unless ALLOWED_METHODS.include?(class_name)
  req_class = Net::HTTP.const_get(class_name)
  # ...
end

L-3 — domain field in challenge is set from untrusted HTTP_HOST header without validation

File: lib/bsv/x402/challenge_generator.rb, lines 70–72

Description:

def self.extract_domain(env)
  (env['HTTP_HOST'] || env['SERVER_NAME'] || '').to_s
end

The domain field is embedded verbatim into the challenge's canonical JSON (and thus hashed into the challenge SHA-256). A reverse-proxy misconfiguration or a direct request with a crafted Host: header could cause the challenge to embed an attacker-controlled domain string. This does not affect payment security directly (the domain is not verified during proof validation), but it means the challenge header the server issues could contain an arbitrary string supplied by the client — a form of reflected injection into a trusted data structure.

Severity: LOW (no direct payment bypass, but a hygiene concern; operators behind a proper reverse proxy are unaffected)

Remediation: Either validate domain against a configured allowlist, or set it from a server-side configuration value rather than from the inbound request.


L-4 — on_payment_verified callback receives the full Rack env, which may contain sensitive data

File: lib/bsv/x402/middleware.rb, line 142

Description:

@on_verified&.call(env, result)

The full Rack environment is passed to an operator-supplied callable. If the callback is used for logging or auditing, it may inadvertently log rack.input (the request body), session cookies, HTTP_AUTHORIZATION headers, or other sensitive request data that happens to be in env.

Severity: LOW (operator-controlled callback; the risk is of the operator making a mistake, not of an attacker exploiting the middleware directly)

Remediation: Document clearly which keys are safe to log, and consider providing a sanitised view — e.g. pass a subset { path:, method:, amount_sats: } alongside result rather than the raw env.


INFORMATIONAL


I-1 — MemoryChallengeStore is not suitable for production multi-process deployments

File: lib/bsv/x402/challenge_store.rb

This is documented in the code, but worth emphasising: in a multi-worker Rack server (Puma, Unicorn), each worker process has an isolated heap. A challenge stored by worker A cannot be fetched by worker B. Proofs from clients will fail with a fresh 402 on worker B. This is functionally correct (client retries) but may cause a payment loop if the client keeps hitting a different worker. No code change needed — the documentation is adequate; this is noted for operator awareness.


I-2 — rawtx_b64 uses standard base64 while headers use base64url — correct and intentional, but warrants a note

Files: lib/bsv/x402/encoding.rb

The distinction is correctly implemented: challenge/proof headers use base64url (URL-safe, no +//), while rawtx_b64 inside the JSON uses standard base64 with padding. This matches the spec. No action required, but reviewers should be aware of the encoding split when adding future fields.


I-3 — Challenge expiry checked server-side only; no clock-skew tolerance

File: lib/bsv/x402/verifier.rb, line 185

Challenge#expired? compares Time.now.to_i > expires_at with no tolerance. If the client's clock is slightly ahead of the server's, a challenge may be judged expired before the client has finished constructing the proof. The spec defines no clock-skew tolerance, so this is protocol-correct behaviour. The client-side ChallengeExpiredError is also raised in TransactionBuilder.build (line 65) using its own challenge.expired? check, which catches this before broadcasting. No action required.


I-4 — tx.sign_all followed by per-input tx.sign(idx, key) — double-signing possible

File: lib/bsv/x402/transaction_builder.rb, lines 114–119

tx.sign_all
funding_utxos.each_with_index do |utxo, idx|
  tx.sign(idx + 1, utxo[:private_key])
end

sign_all processes every input that has an unlocking_script_template set. Only the nonce input has a template (set to CallableUnlockingTemplate). Funding inputs do not have a template set in build_funding_input, so sign_all should skip them. The subsequent explicit tx.sign(idx + 1, key) then signs the funding inputs. This is correct provided the SDK's sign_all does not attempt to sign inputs without a template. If a future SDK change alters that assumption, funding inputs could be double-signed (which would corrupt the unlocking script). Consider an explicit comment or assertion to document this invariant dependency.


I-5 — Proof#validate_request! does not enforce non-empty string values

File: lib/bsv/x402/proof.rb, lines 136–143

value = request[field] || request[field.to_sym]
raise ValidationError, "proof.request missing required field: #{field}" if value.nil?

A field present with an empty string ("") passes this check. For req_headers_sha256 and req_body_sha256, the expected values are SHA-256 hex digests (64-character strings). An empty string would fail at step 4 of verification (hash mismatch), so there is no bypass — but the validation could be tightened to check for non-empty values and optionally validate hex-digest format, consistent with how Challenge#validate! checks value.to_s.empty?.


I-6 — locking_script_hex and txid in NonceUTXO have no format validation

File: lib/bsv/x402/nonce_utxo.rb, line 64

validate! only checks satoshis. The txid and locking_script_hex fields accept any string. A txid that is not 64 hex characters, or a locking_script_hex that is not valid hex, will propagate silently until they cause a failure in Script.from_hex or TransactionInput.txid_from_hex. This is caught by exceptions at the point of use, so there is no security bypass — but early validation would make errors easier to diagnose.


Attack Surface Notes

The following areas warrant ongoing attention as the gem evolves:

  1. Nonce UTXO exhaustion — the replay-prevention mechanism depends entirely on nonce UTXOs being consumed atomically and not reissued. StaticNonceProvider violates this and must never reach production. Any production nonce provider must be audited for TOCTOU races (check-then-act on UTXO availability).

  2. settlement_verifier trust boundary — the callable receives raw transaction bytes from an untrusted proof. If the callable makes an RPC call to a node, it must handle node errors and timeouts safely. A malicious client cannot control the bytes beyond what is structurally valid (txid is verified first), but extreme transaction sizes should be handled gracefully.

  3. BSV::Transaction::Transaction.from_binary on untrusted bytes — the BSV SDK's transaction deserialiser is exercised here. Any future deserialisation vulnerabilities in the SDK would be exploitable via the proof's rawtx_b64 field. The explicit ArgumentError rescue in step 6 provides a safe fallback, but should be extended to rescue StandardError if the SDK raises other exception types on malformed input.

  4. Multi-output payment aggregation — step 8 intentionally does not sum across multiple outputs with the payee script (noted in the comment). This is the correct conservative choice; document it as a spec interpretation decision so future maintainers do not change it without understanding the implication.

@codecov
Copy link

codecov bot commented Mar 21, 2026

Codecov Report

❌ Patch coverage is 97.48521% with 17 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
lib/bsv/x402/client.rb 84.21% 9 Missing ⚠️
lib/bsv/x402/challenge_store.rb 80.00% 4 Missing ⚠️
lib/bsv/x402/proof.rb 97.01% 2 Missing ⚠️
lib/bsv/x402/middleware.rb 98.30% 1 Missing ⚠️
lib/bsv/x402/route_map.rb 95.00% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@sgbett
Copy link
Owner Author

sgbett commented Mar 21, 2026

Extracted to standalone repo: https://github.com/sgbett/x402 — namespace renamed from BSV::X402 to X402.

@sgbett sgbett closed this Mar 21, 2026
@sgbett sgbett deleted the feature/97-bsv-x402-gem branch March 24, 2026 00:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[HLR] BSV x402 protocol middleware gem

1 participant