CVE-2026-40070
Description
BSV Ruby SDK is the Ruby SDK for the BSV blockchain. From 0.3.1 to before 0.8.2, BSV::Wallet::WalletClient#acquire_certificate persists certificate records to storage without verifying the certifier's signature over the certificate contents. In acquisition_protocol: 'direct', the caller supplies all certificate fields (including signature:) and the record is written to storage verbatim. In acquisition_protocol: 'issuance', the client POSTs to a certifier URL and writes whatever signature the response body contains, also without verification. An attacker who can reach either API (or who controls a certifier endpoint targeted by the issuance path) can forge identity certificates that subsequently appear authentic to list_certificates and prove_certificate.
Affected products
2Patches
14992e8a265fdMerge pull request #306 from sgbett/fix/305-security-hotfixes
14 files changed · +1118 −69
bsv-wallet.gemspec+9 −1 modified@@ -25,5 +25,13 @@ Gem::Specification.new do |spec| spec.require_paths = ['lib'] spec.add_dependency 'base64', '~> 0.2' - spec.add_dependency 'bsv-sdk', '~> 0.4' + + # bsv-wallet and bsv-sdk are released in lockstep from the same + # repository. The `~> 0.4` constraint this replaces was stale — it + # allowed bsv-sdk 0.4.x–0.9.x, but wallet hasn't been tested against + # anything below current (0.8.x) in months. Pinning the floor at + # 0.8.2 ensures consumers installing bsv-wallet 0.3.4 (which ships + # the F8.15 security fix for issue #305) also pick up the F1.3 + # and F5.13 bsv-sdk security fixes that ship together with it. + spec.add_dependency 'bsv-sdk', '>= 0.8.2', '< 1.0' end
.claude/plans/20260408-v090-compliance-rollout.md+98 −47 modified@@ -1,20 +1,30 @@ -# Plan: 0.9.0 Compliance Review Rollout +# Plan: Compliance Review Rollout (bsv-sdk 0.9.0 + bsv-wallet 0.4.0) **Review**: [`.architecture/reviews/20260408-cross-sdk-compliance-review.md`](../../.architecture/reviews/20260408-cross-sdk-compliance-review.md) -**Target release**: 0.9.0 (major correctness release) -**Previous release**: 0.8.2 (A1 security hotfixes, HLR sgbett/bsv-ruby-sdk#305) -**Next release**: 0.10.0 (Chronicle implementation — separate plan) +**Target releases**: **bsv-sdk 0.9.0** and **bsv-wallet 0.4.0** (paired major correctness release) +**Previous releases**: **bsv-sdk 0.8.2** and **bsv-wallet 0.3.4** (A1 security hotfixes, HLR sgbett/bsv-ruby-sdk#305) +**Next release**: **bsv-sdk 0.10.0** (Chronicle implementation — separate plan, no bsv-wallet bump expected) + +## Repository layout note + +This repository ships three gems from one tree with independent versioning: + +- **bsv-sdk** — declarative SDK. Files under `lib/bsv/{primitives,script,transaction,network,wallet,auth,overlay,identity,registry}/`. Current: 0.8.1 → 0.8.2 (A1) → 0.9.0 (most of this plan). +- **bsv-wallet** — BRC-100 wallet interface. Files under `lib/bsv/wallet_interface/`. Current: 0.3.3 → 0.3.4 (A1 F8.15) → 0.4.0 (A7 wallet-layer items). Depends on bsv-sdk. +- **bsv-attest** — attestation workflow. Not touched by this plan; deferred to Tier B per F8.25. + +Findings in this plan are **labelled by target gem**. A single cluster (A7) straddles both gems because it collects scattered defensive fixes; its HLR / PR will land in lockstep with paired release commits. ## Context The 2026-04-08 cross-SDK compliance review surfaced 137 findings across 8 phases. After the 10-member project team quorum vote, 54 reached CONSENSUS and 15 more were near-consensus (blocked only by Pragmatic Enforcer's solo YAGNI dissent on Phase 8 architectural items). -**Three-release strategy** agreed by user and Implementation Strategist: +**Paired-release strategy** agreed by user and Implementation Strategist: -- **0.8.2** — A1 security hotfixes (HLR sgbett/bsv-ruby-sdk#305). Backportable patch. Ships first, not bundled with refactors. -- **0.9.0** — This plan. Everything else from Tier A (non-Chronicle correctness) plus the cross-SDK conformance test suite (C1). -- **0.10.0** — Chronicle implementation (F7.1/F7.2 full semantics with reference vectors). Separate plan. -- **1.0.0 / future** — Tier B Phase 8 wallet/auth architectural epic, pending `bsv-wallet` boundary ADR. +- **bsv-sdk 0.8.2 + bsv-wallet 0.3.4** — A1 security hotfixes (HLR sgbett/bsv-ruby-sdk#305, PR #306). Backportable patch. Must ship together: bsv-wallet depends on the bsv-sdk fixes and bsv-wallet.gemspec has been tightened to `>= 0.8.2`. +- **bsv-sdk 0.9.0 + bsv-wallet 0.4.0** — This plan. Everything else from Tier A (non-Chronicle correctness) plus the cross-SDK conformance test suite (C1). Paired release because A7 straddles both gems; rest of the plan is bsv-sdk-only, but shipping the bsv-wallet bump in lockstep avoids a stale-dependency trap. +- **bsv-sdk 0.10.0** — Chronicle implementation (F7.1/F7.2 full semantics with reference vectors). Separate plan. No bsv-wallet bump expected unless P305.1 or other wallet-layer items slip into that window. +- **bsv-sdk 1.0.0 + bsv-wallet 1.0.0 / future** — Tier B Phase 8 wallet/auth architectural epic, pending `bsv-wallet` boundary ADR. This is where most of the remaining bsv-wallet work lives. The goal of 0.9.0 is to convert the letter-vs-spirit cluster into letter-matches-spirit. The meta-fix (C1 conformance suite) enables confident execution of the rest by providing authoritative cross-SDK vectors as the regression net. @@ -26,20 +36,22 @@ Impact on 0.9: 5 trivial changes (promote `OP_SUBSTR`/`OP_LEFT`/`OP_RIGHT`/`OP_L ## Cluster overview (HLRs to create) -0.9.0 contains **eight separate HLRs**, sequenced for dependency safety. Each HLR maps to one cluster from the review's Tier A plus C1: +0.9.0 contains **eight separate HLRs**, sequenced for dependency safety. Each HLR maps to one cluster from the review's Tier A plus C1. The **Gem** column indicates which gem(s) the cluster affects. -| HLR | Cluster | Scope | Blocks | Blocked by | -|---|---|---|---|---| -| TBD — C1 | Conformance suite | Cross-SDK vector loader + fixture infrastructure | A3, A5, A6 regression tests | — | -| TBD — A2 | Foundation correctness | F4.2, F1.8, F1.5 | A5 (hex), possibly A3 | — | -| TBD — A3 | BEEF cluster (single PR) | F5.1 → F5.2/F5.4/F5.5/F5.6/F5.7/F5.8/F5.9/F5.10/F5.12/F5.3/F5.20 | — | C1, A2 (for hex module if touched) | -| TBD — A4 | Crypto hardening | F2.1, F2.3, F2.2, F2.4, F2.9 | — | — (parallel with A3) | -| TBD — A5 | Parser correctness | F3.1, F3.2, F3.3, F3.4, F3.5, F3.6, F3.10, F3.12, F3.14/F3.21, F3.16 | — | A2 (F1.5 hex module blocks F3.5) | -| TBD — A6 | Interpreter hardening + Chronicle fail-safe | F7.8, F7.9, F7.10, F7.11, F7.16, F7.18, F7.19, F7.1/F7.2 (raise first) | — | — (parallel with A3/A5) | -| TBD — A7 | Defensive bits (catch-all) | F4.1, F4.3, F4.4, F4.9, F8.7, F8.8, F8.10, F8.14, F8.18 | — | — | +| HLR | Cluster | Gem | Scope | Blocks | Blocked by | +|---|---|---|---|---|---| +| TBD — C1 | Conformance suite | bsv-sdk | Cross-SDK vector loader + fixture infrastructure | A3, A5, A6 regression tests | — | +| TBD — A2 | Foundation correctness | bsv-sdk | F4.2, F1.8, F1.5 | A5 (hex), possibly A3 | — | +| TBD — A3 | BEEF cluster (single PR) | bsv-sdk | F5.1 → F5.2/F5.4/F5.5/F5.6/F5.7/F5.8/F5.9/F5.10/F5.12/F5.3/F5.20 | — | C1, A2 (for hex module if touched) | +| TBD — A4 | Crypto hardening | bsv-sdk | F2.1, F2.3, F2.2, F2.4, F2.9 | — | — (parallel with A3) | +| TBD — A5 | Parser correctness | bsv-sdk | F3.1, F3.2, F3.3, F3.4, F3.5, F3.6, F3.10, F3.12, F3.14/F3.21, F3.16 | — | A2 (F1.5 hex module blocks F3.5) | +| TBD — A6 | Interpreter hardening + Chronicle fail-safe | bsv-sdk | F7.8, F7.9, F7.10, F7.11, F7.16, F7.18, F7.19, F7.1/F7.2 (raise first) | — | — (parallel with A3/A5) | +| TBD — A7 | Defensive bits (catch-all) | **both** | bsv-sdk: F4.1, F4.3, F4.4, F4.9; bsv-wallet: F8.7, F8.8, F8.10, F8.14, F8.18, P305.1 | — | — | HLR numbers will be assigned as they are created. This plan updates as each is opened. +**A7 is the only cluster that straddles both gems.** Its PR will likely need to be split into two commits (or two PRs) so the bsv-sdk and bsv-wallet changes can ship in paired release commits. Alternative: one PR with the split preserved at commit granularity, release commits reference both. + ## Sequencing within 0.9.0 Dependencies inside the cluster graph: @@ -166,21 +178,24 @@ Depends on A2 F1.5 (hex module). Parser-centric cluster, all in `lib/bsv/script/ | F7.19 | LOW | `interpreter.rb:271-273` | Conditional-depth counter (e.g. 256 levels). Depends on F7.18. | | **F7.1/F7.2 raise-first** | HIGH | `interpreter.rb:168-172,179-182`, `operations/flow_control.rb:76-92` | Promote Chronicle-slot opcodes (`OP_SUBSTR`, `OP_LEFT`, `OP_RIGHT`, `OP_LSHIFTNUM`, `OP_RSHIFTNUM`, `OP_VER`, `OP_VERIF`, `OP_VERNOTIF`) from silent no-op/reserved → `raise InterpreterError::UnimplementedOpcode`. Full semantics deferred to 0.10. CHANGELOG note pointing at 0.10 for full support. | -### A7 — Defensive bits (catch-all) +### A7 — Defensive bits (catch-all) — **bsv-sdk + bsv-wallet** -Small individually, none block anything. Can land as a single catch-all PR or ride along with adjacent HLRs. +Small individually, none block anything. This cluster straddles both gems — the bsv-sdk bits can land in a pure-sdk PR, the bsv-wallet bits in a pure-wallet PR, or both together with the split preserved at commit granularity for a paired release. -| Finding | Severity | Location | Summary | -|---|---|---|---| -| F4.1 | HIGH | `transaction.rb:789-791` | Change distribution drops outputs on `available <= n` instead of `change <= 0`; match TS. | -| F4.3 | MED | `transaction.rb:608-617` | `estimated_size` silent fallback to 148-byte P2PKH; raise matching TS/Go. | -| F4.4 | MED | `transaction.rb:576-581` | `total_input_satoshis` fallback through `source_transaction.outputs[index].satoshis`. | -| F4.9 | LOW | `transaction.rb:458-489` | `Transaction#sign` doesn't validate outputs have satoshis. Guard raising on nil. | -| F8.7 | MED | `wallet_interface/validators.rb:29` | Lowercase-and-trim protocol ID before validation (silent key-derivation fork). | -| F8.8 | LOW | `wallet_interface/validators.rb:32-33,71-73` | Move permission rules out of `Validators`. | -| F8.10 | LOW | `lib/bsv/wallet_interface.rb` | Namespace cleanup: delete empty `BSV::WalletInterface` shell. Legacy `BSV::Wallet::Wallet` extraction deferred to Tier B. | -| F8.14 | HIGH | `wallet_interface/wallet_client.rb:711-729` | `internalize_action` must verify BEEF merkle proofs against block headers before storing. | -| F8.18 | LOW | `wallet_interface/wallet_client.rb:545-559` | `wire_source_tx_ancestors` unbounded recursion. Add depth cap and cycle detection. | +| Finding | Gem | Severity | Location | Summary | +|---|---|---|---|---| +| F4.1 | bsv-sdk | HIGH | `transaction.rb:789-791` | Change distribution drops outputs on `available <= n` instead of `change <= 0`; match TS. | +| F4.3 | bsv-sdk | MED | `transaction.rb:608-617` | `estimated_size` silent fallback to 148-byte P2PKH; raise matching TS/Go. | +| F4.4 | bsv-sdk | MED | `transaction.rb:576-581` | `total_input_satoshis` fallback through `source_transaction.outputs[index].satoshis`. | +| F4.9 | bsv-sdk | LOW | `transaction.rb:458-489` | `Transaction#sign` doesn't validate outputs have satoshis. Guard raising on nil. | +| F8.7 | bsv-wallet | MED | `wallet_interface/validators.rb:29` | Lowercase-and-trim protocol ID before validation (silent key-derivation fork). | +| F8.8 | bsv-wallet | LOW | `wallet_interface/validators.rb:32-33,71-73` | Move permission rules out of `Validators`. | +| F8.10 | bsv-wallet | LOW | `lib/bsv/wallet_interface.rb` | Namespace cleanup: delete empty `BSV::WalletInterface` shell. Legacy `BSV::Wallet::Wallet` extraction deferred to Tier B. | +| F8.14 | bsv-wallet | HIGH | `wallet_interface/wallet_client.rb:711-729` | `internalize_action` must verify BEEF merkle proofs against block headers before storing. | +| F8.18 | bsv-wallet | LOW | `wallet_interface/wallet_client.rb:545-559` | `wire_source_tx_ancestors` unbounded recursion. Add depth cap and cycle detection. | +| **P305.1** | bsv-wallet | MED | `wallet_interface/proto_wallet.rb:144` | `ProtoWallet#create_signature` defaults `counterparty: 'self'`; TS defaults to `'anyone'` (see `ts-sdk/src/wallet/ProtoWallet.ts:259`). Silent cross-SDK divergence: Ruby and TS consumers calling `create_signature` without an explicit counterparty get signatures that don't verify against each other. Surfaced during #305 implementation while writing BRC-52 certificate tests (the correct BRC-52 sign path requires `counterparty: 'anyone'`, which Ruby users must pass explicitly). Fix: change Ruby's default to `'anyone'` to match TS. Breaking change for any Ruby consumer relying on the `'self'` default, so must ship with a release note. Only affects `create_signature` (TS's `verify_signature`, `encrypt`, `decrypt`, etc. all default to `'self'` on both sides). | + +**A7 split by gem**: bsv-sdk side is 4 findings (F4.1, F4.3, F4.4, F4.9). bsv-wallet side is 6 findings (F8.7, F8.8, F8.10, F8.14, F8.18, P305.1). ## Explicitly deferred from 0.9.0 @@ -202,7 +217,7 @@ Note: 0.9.0 ships the "raise first" fail-safe for all eight opcodes via A6. 0.10 - **F8.11** — WireFormat translator (snake↔camel at JSON boundaries) - **F8.12** — `listActions` / `listOutputs` include-flag honouring - **F8.13** — `sendWith` / `noSend` / batch broadcast model -- **F8.16** — `acquire_certificate` issuance via BRC-104 AuthFetch +- **F8.16** — `acquire_certificate` issuance via BRC-104 AuthFetch. **Partially closed in #305**: the "no signature verification on the issuance path" aspect (noted in F8.16 as "Same issue as F8.15") was fixed as a side effect of the F8.15 hotfix, since both `acquire_via_direct` and `acquire_via_issuance` now call `CertificateSignature.verify!`. What remains in Tier B is the transport switch from ad-hoc JSON POST to BRC-104 AuthFetch with BRC-103-signed requests against the certifier's identity key. - **F8.25** — `BSV::Attest` extraction to `bsv-attest` companion gem ### Backlog (no action, documented in review) @@ -211,9 +226,10 @@ Note: 0.9.0 ships the "raise first" fail-safe for all eight opcodes via A6. 0.10 - The 4 controversial findings resolved as "defer" (F2.5, F2.8, F6.1, F8.19 — all cross-SDK coordination items) - The 1 controversial finding resolved as "don't fix" (F7.6 — `findAndDelete` is dead code under FORKID-only sighash per Cryptography Specialist) -## Release criteria for 0.9.0 +## Release criteria -- [ ] C1, A2, A3, A4, A5, A6, A7 HLRs all closed +### bsv-sdk 0.9.0 +- [ ] C1, A2, A3, A4, A5, A6, and bsv-sdk side of A7 HLRs all closed - [ ] Full test suite green on Ruby 2.7, 3.0, 3.1, 3.2, 3.3, 3.4 - [ ] Conformance vector suite green (cross-SDK BEEF/sighash/BRC-42 vectors) - [ ] RuboCop clean @@ -226,25 +242,60 @@ Note: 0.9.0 ships the "raise first" fail-safe for all eight opcodes via A6. 0.10 - F7.1/F7.2 raise-first — breaking change for any caller running Chronicle opcodes through the interpreter; advertise 0.10 as the implementation target - [ ] Release notes prominently reference the 2026-04-08 compliance review document +### bsv-wallet 0.4.0 (ships paired with bsv-sdk 0.9.0) +- [ ] bsv-wallet side of A7 HLR closed (F8.7, F8.8, F8.10, F8.14, F8.18, P305.1) +- [ ] Full test suite green (same Ruby matrix) +- [ ] RuboCop clean +- [ ] CHANGELOG entry under `wallet-0.4.0` covering every finding ID addressed +- [ ] CHANGELOG migration notes for: + - P305.1 (ProtoWallet `create_signature` default counterparty changed from `'self'` → `'anyone'` — breaking change for any Ruby caller relying on the old default) + - F8.14 (internalize_action now rejects BEEFs without verifiable merkle paths — breaking change for callers feeding unverified BEEFs) + - F8.10 (`BSV::WalletInterface` namespace shell removed; the legacy `BSV::Wallet::Wallet` class is untouched until Tier B) +- [ ] bsv-wallet.gemspec bsv-sdk dependency tightened to `>= 0.9.0, < 1.0` +- [ ] Release notes cross-reference the bsv-sdk 0.9.0 release + +## Post-review addenda (findings surfaced during implementation) + +Items discovered while implementing the review's findings. Numbered `P<hlr>.<n>` to distinguish from phase-review findings. Added to the relevant cluster above with the cross-reference below. + +### P305.1 — `ProtoWallet#create_signature` counterparty default divergence + +- **Surfaced during**: sgbett/bsv-ruby-sdk#305 (F8.15 BRC-52 certificate verification) +- **Severity**: MED +- **Location**: `lib/bsv/wallet_interface/proto_wallet.rb:144` +- **Cluster**: A7 (defensive bits) +- **Description**: Ruby's `ProtoWallet#create_signature` defaults `counterparty` to `'self'`. TypeScript's `ProtoWallet.createSignature` defaults to `'anyone'` (`ts-sdk/src/wallet/ProtoWallet.ts:259`). This is a silent cross-SDK divergence: identical call sites without an explicit counterparty get signatures that cannot cross-verify between Ruby and TS. BRC-52 certificate signing specifically requires `counterparty: 'anyone'` — the review's implementation of F8.15 had to make this explicit in every certificate test to get signatures that verify against TS's canonical `counterparty: certifier_hex` path. It's the same letter-vs-spirit pattern: Ruby's self-tests pass because they sign and verify in the same SDK, but any cross-SDK flow that relies on the default diverges. +- **Note**: only `createSignature` has this default; TS's `verify_signature`, `encrypt`, `decrypt`, `create_hmac`, and `verify_hmac` all default to `'self'` on both sides. The asymmetry is intentional in TS (signatures are usually verified by others; encryption/HMAC are usually self-to-self), and Ruby should mirror it. +- **Fix**: change Ruby's default to `'anyone'` in `create_signature`. Breaking change for any Ruby consumer relying on the `'self'` default — add a release note. +- **Scheduled**: A7 (see the cluster table above) + +### F8.16 partial closure — issuance-path signature verification + +- **Closed by**: sgbett/bsv-ruby-sdk#305 (F8.15 fix) +- **Original description**: F8.16 noted two issues with `acquire_via_issuance` — (a) ad-hoc HTTP POST instead of BRC-104 AuthFetch, (b) "doesn't verify the returned signature before storing, same issue as F8.15" +- **What #305 closed**: aspect (b). Both `acquire_via_direct` and `acquire_via_issuance` now call `BSV::Wallet::CertificateSignature.verify!` before persisting, so a certifier that returns an invalid signature to the issuance HTTP endpoint is rejected with the same `InvalidError` as a tampered direct certificate. +- **What remains in Tier B**: aspect (a). The HTTP transport for the issuance path is still an ad-hoc JSON POST rather than BRC-104 AuthFetch with BRC-103-signed requests against the certifier's identity key. This is a transport-layer concern deferred with the rest of F8.2/F8.3/F8.6 until the Phase 8 architectural epic. + ## Open questions to resolve as HLRs are written 1. **C1 vector sync strategy**: vendor vectors in-repo (static snapshot) vs git-submodule reference SDKs (live sync)? Recommend static snapshot with documented sync procedure — submodules complicate CI and aren't worth it for a regression test suite. 2. **F5.1 byte-order convention**: pick TS's (display-hex in memory, wire-internal-order on the wire) or a different convention? Recommend TS's. Document in `BeefTx` class doc. 3. **F2.1 constant-time implementation**: pure-Ruby Montgomery ladder (matches secp256k1 precedent) or extract to a C extension? Recommend pure-Ruby for consistency — benchmark will reveal if it's unacceptable. -4. **A7 PR strategy**: one big catch-all PR or scatter fixes into the other HLRs' PRs as they touch adjacent code? Recommend scatter — most A7 findings naturally belong next to an A3/A4/A5/A6 fix. -5. **F8.14 BEEF header verification**: should this live in `internalize_action` or in `Beef#verify` (from F5.3 in A3)? Probably `Beef#verify` with `internalize_action` calling it. Clarify in the A3 HLR. +4. **A7 PR strategy**: one big catch-all PR or scatter fixes into the other HLRs' PRs as they touch adjacent code? Recommend scatter for bsv-sdk findings (F4.1/F4.3/F4.4/F4.9 naturally belong next to A3/A4 fixes). bsv-wallet findings (F8.x + P305.1) stay grouped because they require a paired bsv-wallet release anyway. +5. **F8.14 BEEF header verification**: should this live in `internalize_action` or in `Beef#verify` (from F5.3 in A3)? Probably `Beef#verify` with `internalize_action` calling it. Clarify in the A3 HLR. Note that this creates a cross-gem dependency: `Beef#verify` is bsv-sdk, `internalize_action` consuming it is bsv-wallet, which means A3 must land before the A7 bsv-wallet bits. ## Status -| Step | Status | Notes | -|---|---|---| -| Plan written | ✅ | this document | -| C1 HLR | pending | open next | -| A2 HLR | pending | after C1 | -| A3 HLR | pending | after A2 F1.5 | -| A4 HLR | pending | parallel with A3 | -| A5 HLR | pending | after A2 F1.5 | -| A6 HLR | pending | parallel with A3/A4/A5 | -| A7 HLR | pending | opportunistic | +| Step | Gem(s) | Status | Notes | +|---|---|---|---| +| Plan written | — | ✅ | this document | +| **A1 HLR (bsv-sdk 0.8.2 + bsv-wallet 0.3.4)** | both | ✅ | #305 / PR #306 — F1.3 + F5.13 (bsv-sdk), F8.15 (bsv-wallet); F8.16 verification aspect closed as side effect; P305.1 surfaced; bsv-wallet.gemspec bsv-sdk dep tightened to `>= 0.8.2` | +| C1 HLR | bsv-sdk | pending | open next | +| A2 HLR | bsv-sdk | pending | after C1 | +| A3 HLR | bsv-sdk | pending | after A2 F1.5 | +| A4 HLR | bsv-sdk | pending | parallel with A3 | +| A5 HLR | bsv-sdk | pending | after A2 F1.5 | +| A6 HLR | bsv-sdk | pending | parallel with A3/A4/A5 | +| A7 HLR | both | pending | opportunistic; bsv-sdk bits (F4.x) and bsv-wallet bits (F8.x + P305.1) to be split at PR / commit granularity | HLR numbers populated in the **Cluster overview** table at the top of this document as each is opened.
lib/bsv/network/arc.rb+83 −11 modified@@ -3,6 +3,7 @@ require 'net/http' require 'json' require 'uri' +require 'securerandom' module BSV module Network @@ -14,31 +15,67 @@ module Network # The HTTP client is injectable for testability. It must respond to # #request(uri, request) and return an object with #code and #body. class ARC - REJECTED_STATUSES = %w[REJECTED DOUBLE_SPEND_ATTEMPTED].freeze - - def initialize(url, api_key: nil, http_client: nil) + # ARC response statuses that indicate the transaction was NOT accepted. + # Matches the TypeScript SDK's ARC broadcaster failure set (issue #305, + # finding F5.13). Prior to this fix, Ruby only recognised REJECTED and + # DOUBLE_SPEND_ATTEMPTED, silently treating INVALID / MALFORMED / + # MINED_IN_STALE_BLOCK responses as successful broadcasts. + REJECTED_STATUSES = %w[ + REJECTED + DOUBLE_SPEND_ATTEMPTED + INVALID + MALFORMED + MINED_IN_STALE_BLOCK + ].freeze + + # Substring match for orphan detection in txStatus or extraInfo fields. + ORPHAN_MARKER = 'ORPHAN' + + # @param url [String] ARC base URL (without trailing slash) + # @param api_key [String, nil] optional bearer token for Authorization + # @param deployment_id [String, nil] optional deployment identifier for + # the +XDeployment-ID+ header; defaults to a per-instance random value + # @param callback_url [String, nil] optional +X-CallbackUrl+ for ARC + # status callbacks + # @param callback_token [String, nil] optional +X-CallbackToken+ for + # ARC status callback authentication + # @param http_client [#request, nil] injectable HTTP client for testing + def initialize(url, api_key: nil, deployment_id: nil, callback_url: nil, + callback_token: nil, http_client: nil) @url = url.chomp('/') @api_key = api_key + @deployment_id = deployment_id || "bsv-ruby-sdk-#{SecureRandom.hex(8)}" + @callback_url = callback_url + @callback_token = callback_token @http_client = http_client end # Submit a transaction to ARC. # + # The transaction is encoded as Extended Format (BRC-30) hex when every + # input has +source_satoshis+ and +source_locking_script+ populated, + # which lets ARC validate sighashes without fetching parents. Falls back + # to plain raw-tx hex when EF is unavailable. + # # @param tx [Transaction] the transaction to broadcast # @param wait_for [String, nil] ARC wait condition — one of # 'RECEIVED', 'STORED', 'ANNOUNCED_TO_NETWORK', # 'SEEN_ON_NETWORK', or 'MINED'. When set, ARC holds the # connection open until the transaction reaches the requested # state (or times out). Defaults to nil (no wait). # @return [BroadcastResponse] - # @raise [BroadcastError] + # @raise [BroadcastError] when ARC returns a non-2xx HTTP status or a + # rejected/orphan +txStatus+ def broadcast(tx, wait_for: nil) uri = URI("#{@url}/v1/tx") request = Net::HTTP::Post.new(uri) - request['Content-Type'] = 'application/octet-stream' + request['Content-Type'] = 'application/json' + request['XDeployment-ID'] = @deployment_id request['X-WaitFor'] = wait_for if wait_for + request['X-CallbackUrl'] = @callback_url if @callback_url + request['X-CallbackToken'] = @callback_token if @callback_token apply_auth_header(request) - request.body = tx.to_binary + request.body = JSON.generate(rawTx: raw_tx_hex(tx)) response = execute(uri, request) handle_broadcast_response(response) @@ -49,6 +86,7 @@ def broadcast(tx, wait_for: nil) def status(txid) uri = URI("#{@url}/v1/tx/#{txid}") request = Net::HTTP::Get.new(uri) + request['XDeployment-ID'] = @deployment_id apply_auth_header(request) response = execute(uri, request) @@ -57,6 +95,15 @@ def status(txid) private + # Prefer Extended Format (BRC-30) hex so ARC can validate sighashes + # without fetching parent transactions. Falls back to plain raw-tx hex + # when any input lacks source_satoshis / source_locking_script. + def raw_tx_hex(tx) + tx.to_ef_hex + rescue ArgumentError + tx.to_hex + end + def apply_auth_header(request) request['Authorization'] = "Bearer #{@api_key}" if @api_key end @@ -83,20 +130,45 @@ def handle_broadcast_response(response) ) end - tx_status = body['txStatus'] - if rejected_status?(tx_status) + if rejected_status?(body) raise BroadcastError.new( - body['detail'] || body['title'] || tx_status, + body['detail'] || body['title'] || body['txStatus'], status_code: code, txid: body['txid'] ) end + # A 2xx response without a txid is a malformed ARC reply — + # `parse_json` falls back to `{'detail' => raw}` on non-JSON, + # which would otherwise produce a `BroadcastResponse` full of + # `nil`s and `success? => true`. That's the same silent + # success-as-failure class of bug F5.13 closed for explicit + # error statuses; closing it here for shape corruption too. + unless body['txid'] + raise BroadcastError.new( + 'ARC returned a malformed 2xx response', + status_code: code + ) + end + build_response(body) end - def rejected_status?(tx_status) - REJECTED_STATUSES.include?(tx_status) + def rejected_status?(body) + # Case-insensitive match — the TypeScript reference + # (`ts-sdk/src/transaction/broadcasters/ARC.ts:155-166`) explicitly + # `.toUpperCase()`s both fields before membership / substring checks. + # ARC has a documented history of emitting values outside its own + # OpenAPI enum (e.g. `txStatus: "success"` for orphans in TS issue + # #105), so case normalisation is the defensive choice. + tx_status = body['txStatus'].to_s.upcase + return true if REJECTED_STATUSES.include?(tx_status) + return true if tx_status.include?(ORPHAN_MARKER) + + extra_info = body['extraInfo'].to_s.upcase + return true if extra_info.include?(ORPHAN_MARKER) + + false end def parse_json(raw)
lib/bsv/transaction/var_int.rb+8 −1 modified@@ -10,11 +10,18 @@ module Transaction module VarInt module_function + # Maximum value representable by a Bitcoin VarInt (unsigned 64-bit). + MAX_UINT64 = 0xFFFF_FFFF_FFFF_FFFF + # Encode an integer as a Bitcoin VarInt. # - # @param value [Integer] non-negative integer to encode + # @param value [Integer] non-negative integer to encode (0..2^64-1) # @return [String] encoded binary bytes + # @raise [ArgumentError] if +value+ is negative or exceeds 2^64-1 def encode(value) + raise ArgumentError, "varint requires non-negative integer, got #{value}" if value.negative? + raise ArgumentError, "varint value #{value} exceeds uint64 max (#{MAX_UINT64})" if value > MAX_UINT64 + if value < 0xFD [value].pack('C') elsif value <= 0xFFFF
lib/bsv/wallet_interface/certificate_signature.rb+212 −0 added@@ -0,0 +1,212 @@ +# frozen_string_literal: true + +require 'base64' + +module BSV + module Wallet + # BRC-52 identity certificate signature verification. + # + # Certificates carry a signature from the certifier over a canonical + # binary serialisation of their fields (excluding the signature itself). + # This module builds that canonical serialisation and delegates + # verification to a {ProtoWallet}-compatible verifier. + # + # Every field is included in the preimage in this order: + # + # - +type+ (base64 → 32 bytes) + # - +serial_number+ (base64 → 32 bytes) + # - +subject+ (hex → 33-byte compressed pubkey) + # - +certifier+ (hex → 33-byte compressed pubkey) + # - +revocation_outpoint+: txid hex (32 bytes) + output index VarInt + # - +fields+: VarInt count, then for each field (sorted + # lexicographically by name): VarInt name length + UTF-8 name bytes + # + VarInt value length + UTF-8 value bytes + # + # Signing uses BRC-42 key derivation with: + # + # - protocol ID: +[2, 'certificate signature']+ + # - key ID: +"\#{type} \#{serial_number}"+ + # - counterparty on sign: +'anyone'+ (default of + # +ProtoWallet#create_signature+ in TS — Ruby consumers should pass + # it explicitly since Ruby defaults to +'self'+) + # - counterparty on verify: the certifier's public key hex + # + # @see https://hub.bsvblockchain.org/brc/wallet/0052 BRC-52 + module CertificateSignature + PROTOCOL_ID = [2, 'certificate signature'].freeze + + # Error raised when a certificate's signature cannot be verified. + class InvalidError < InvalidSignatureError + def initialize(detail) + super("certificate signature verification failed: #{detail}") + end + end + + module_function + + # Verify a certificate's certifier signature. + # + # Raises {InvalidError} if the signature is missing, malformed, or + # does not match the expected certifier. + # + # @param cert [Hash] certificate fields. Required keys: + # +:type+, +:serial_number+, +:subject+, +:certifier+, + # +:revocation_outpoint+, +:fields+, +:signature+ + # @param verifier [#verify_signature] optional verifier; defaults to + # a fresh +ProtoWallet.new('anyone')+ + # @return [true] when the signature verifies + # @raise [InvalidError] otherwise + def verify!(cert, verifier: ProtoWallet.new('anyone')) + signature_hex = cert[:signature] + raise InvalidError, 'signature is missing' if signature_hex.nil? || signature_hex.empty? + + preimage = serialise_preimage(cert) + sig_bytes = hex_to_bytes(signature_hex) + + verifier.verify_signature({ + data: preimage.unpack('C*'), + signature: sig_bytes, + protocol_id: PROTOCOL_ID, + key_id: "#{cert[:type]} #{cert[:serial_number]}", + counterparty: cert[:certifier] + }) + + true + rescue InvalidSignatureError => e + raise if e.is_a?(InvalidError) + + raise InvalidError, e.message + rescue ArgumentError, EncodingError => e + # EncodingError covers Encoding::InvalidByteSequenceError and + # Encoding::UndefinedConversionError, which `encode_fields` + # raises for non-UTF-8 field names or values. Callers of + # `acquire_certificate` expect `InvalidError` on bad cert input + # — leaking EncodingError would break that contract. + raise InvalidError, e.message + end + + # Build the BRC-52 canonical preimage for signing or verifying. + # + # @param cert [Hash] certificate fields (see {.verify!}) + # @return [String] binary string suitable for +sha256+ (via + # {ProtoWallet#verify_signature}) + def serialise_preimage(cert) + buf = String.new(encoding: Encoding::ASCII_8BIT) + buf << decode_base64_exact(cert[:type], 32, 'type') + buf << decode_base64_exact(cert[:serial_number], 32, 'serial_number') + buf << decode_hex_exact(cert[:subject], 33, 'subject') + buf << decode_hex_exact(cert[:certifier], 33, 'certifier') + + buf << encode_revocation_outpoint(cert[:revocation_outpoint]) + buf << encode_fields(cert[:fields]) + buf + end + + class << self + private + + def encode_revocation_outpoint(outpoint) + raise ArgumentError, 'revocation_outpoint is missing' if outpoint.nil? || outpoint.empty? + + txid_hex, output_index_str = outpoint.to_s.split('.', 2) + raise ArgumentError, "revocation_outpoint #{outpoint.inspect} missing '.<output_index>'" if output_index_str.nil? + + unless output_index_str.match?(/\A\d+\z/) + raise ArgumentError, "revocation_outpoint output index must be a non-negative integer, got #{output_index_str.inspect}" + end + + buf = String.new(encoding: Encoding::ASCII_8BIT) + buf << decode_hex_exact(txid_hex, 32, 'revocation_outpoint txid') + buf << BSV::Transaction::VarInt.encode(output_index_str.to_i) + buf + end + + def encode_fields(fields) + raise ArgumentError, 'fields must be a Hash' unless fields.is_a?(Hash) + + normalised = normalise_field_keys(fields) + + buf = String.new(encoding: Encoding::ASCII_8BIT) + sorted_names = normalised.keys.sort + buf << BSV::Transaction::VarInt.encode(sorted_names.length) + + sorted_names.each do |name| + name_bytes = name.encode('UTF-8').b + value_bytes = normalised[name].to_s.encode('UTF-8').b + + buf << BSV::Transaction::VarInt.encode(name_bytes.bytesize) + buf << name_bytes + buf << BSV::Transaction::VarInt.encode(value_bytes.bytesize) + buf << value_bytes + end + buf + end + + # Normalise Hash keys to strings and reject post-normalisation + # duplicates (e.g. both `:email` and `'email'`). Without this, + # `fields.keys.map(&:to_s)` silently produces duplicate entries + # with ambiguous value ordering, which makes the BRC-52 preimage + # non-deterministic. + def normalise_field_keys(fields) + normalised = {} + fields.each do |key, value| + str_key = key.to_s + if normalised.key?(str_key) + raise ArgumentError, + "duplicate field name #{str_key.inspect} " \ + '(once as string, once as symbol)' + end + + normalised[str_key] = value + end + normalised + end + + def decode_base64_exact(value, expected_length, field_name) + raise ArgumentError, "#{field_name} is missing" if value.nil? || value.empty? + + # strict_decode64 (vs permissive decode64) rejects whitespace, + # non-base64 characters, and non-canonical padding. The rest + # of bsv-wallet (e.g. the wire serialiser) uses strict mode, + # and the BRC-52 preimage must be unambiguous — a cert with + # whitespace-injected type/serial_number would decode to the + # right length but produce a different canonical form than + # the same data re-submitted cleanly. + bytes = begin + Base64.strict_decode64(value) + rescue ArgumentError => e + raise ArgumentError, "#{field_name} is not valid base64: #{e.message}" + end + + if bytes.bytesize != expected_length + raise ArgumentError, + "#{field_name} must decode to #{expected_length} bytes, got #{bytes.bytesize}" + end + + bytes.b + end + + def decode_hex_exact(value, expected_length, field_name) + raise ArgumentError, "#{field_name} is missing" if value.nil? || value.empty? + raise ArgumentError, "#{field_name} must be a hex string" unless value.match?(/\A\h+\z/) + raise ArgumentError, "#{field_name} hex length must be even" unless value.length.even? + + bytes = [value].pack('H*') + if bytes.bytesize != expected_length + raise ArgumentError, + "#{field_name} must decode to #{expected_length} bytes, got #{bytes.bytesize}" + end + + bytes.b + end + + def hex_to_bytes(hex) + raise ArgumentError, 'signature must be a hex string' unless hex.is_a?(String) && hex.match?(/\A\h+\z/) + raise ArgumentError, 'signature hex length must be even' unless hex.length.even? + + [hex].pack('H*').unpack('C*') + end + end + end + end +end
lib/bsv/wallet_interface.rb+1 −0 modified@@ -20,6 +20,7 @@ module Wallet autoload :NullChainProvider, 'bsv/wallet_interface/null_chain_provider' autoload :WalletClient, 'bsv/wallet_interface/wallet_client' autoload :Wire, 'bsv/wallet_interface/wire' + autoload :CertificateSignature, 'bsv/wallet_interface/certificate_signature' # Error classes autoload :WalletError, 'bsv/wallet_interface/errors/wallet_error'
lib/bsv/wallet_interface/wallet_client.rb+19 −2 modified@@ -803,7 +803,7 @@ def validate_acquire_certificate!(args) end def acquire_via_direct(args) - { + cert = { type: args[:type], subject: @key_deriver.identity_key, serial_number: args[:serial_number], @@ -813,6 +813,15 @@ def acquire_via_direct(args) fields: args[:fields], keyring: args[:keyring_for_subject] } + + # BRC-52: verify the certifier's signature against the canonical + # serialisation before persisting. Fixes F8.15 from the 2026-04-08 + # cross-SDK compliance review (#305) — prior to this check, callers + # could pass any value in `args[:signature]` and it would be stored + # as if certifier-authentic. + CertificateSignature.verify!(cert) + + cert end def acquire_via_issuance(args) @@ -833,7 +842,7 @@ def acquire_via_issuance(args) body = JSON.parse(response.body) - { + cert = { type: body['type'] || args[:type], subject: @key_deriver.identity_key, serial_number: body['serialNumber'], @@ -843,6 +852,14 @@ def acquire_via_issuance(args) fields: body['fields'] || args[:fields], keyring: body['keyringForSubject'] } + + # BRC-52: the certifier's HTTP response is untrusted network data; + # verify the signature before persisting. Closes the F8.15 class of + # bug on the issuance path too (the finding's "Same issue as F8.15" + # note from F8.16). + CertificateSignature.verify!(cert) + + cert rescue JSON::ParserError raise WalletError, 'Certificate issuance failed: invalid JSON response' end
.rubocop.yml+3 −1 modified@@ -155,7 +155,7 @@ Metrics/ModuleLength: - 'lib/bsv/primitives/ecies.rb' - 'lib/bsv/script/opcodes.rb' - 'lib/bsv/script/interpreter/operations/**/*' - - 'lib/bsv/wallet_interface/wire/**/*' + - 'lib/bsv/wallet_interface/**/*' - 'lib/bsv/identity/**/*' - 'lib/bsv/registry/**/*' - 'spec/**/*' @@ -167,6 +167,7 @@ Metrics/ClassLength: - 'lib/bsv/transaction/transaction.rb' - 'lib/bsv/transaction/merkle_path.rb' - 'lib/bsv/transaction/beef.rb' + - 'lib/bsv/network/**/*' - 'lib/bsv/wallet_interface/**/*' - 'lib/bsv/auth/**/*' - 'lib/bsv/overlay/**/*' @@ -178,6 +179,7 @@ Metrics/ParameterLists: - 'lib/bsv/primitives/**/*' - 'lib/bsv/script/**/*' - 'lib/bsv/transaction/**/*' + - 'lib/bsv/network/**/*' - 'lib/bsv/overlay/**/*' - 'lib/bsv/identity/**/*' - 'lib/bsv/registry/**/*'
.security/advisories/2026-0001-acquire-certificate-signature-bypass.md+157 −0 added@@ -0,0 +1,157 @@ +--- +id: 2026-0001 +ghsa_id: GHSA-hc36-c89j-5f4j +ghsa_url: https://github.com/sgbett/bsv-ruby-sdk/security/advisories/GHSA-hc36-c89j-5f4j +cve_id: TBD +status: draft +title: bsv-sdk and bsv-wallet persist unverified certifier signatures in acquire_certificate (direct and issuance paths) +ecosystem: RubyGems +severity: HIGH +cwe: CWE-347 +cvss_v3: AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N +cvss_score: 8.1 +affected_products: + - package: bsv-sdk + affected: ">= 0.3.1, < 0.8.2" + patched: "0.8.2" + - package: bsv-wallet + affected: ">= 0.1.2, < 0.3.4" + patched: "0.3.4" +finding: F8.15 +review: .architecture/reviews/20260408-cross-sdk-compliance-review.md +hlr: sgbett/bsv-ruby-sdk#305 +reported: 2026-04-08 +published: TBD +--- + +# Unverified certifier signatures persisted by `acquire_certificate` + +## Affected packages + +Both `bsv-sdk` and `bsv-wallet` are published from the [sgbett/bsv-ruby-sdk](https://github.com/sgbett/bsv-ruby-sdk) repository. The vulnerable code lives in `lib/bsv/wallet_interface/wallet_client.rb`, which is **physically shipped inside both gems** (the `bsv-wallet.gemspec` `files` list bundles the entire `lib/bsv/wallet_interface/` tree). Consumers of either gem are independently vulnerable; the two packages are versioned separately, so each has its own affected range. + +| Package | Affected | Patched | +| --- | --- | --- | +| `bsv-sdk` | `>= 0.3.1, < 0.8.2` | `0.8.2` | +| `bsv-wallet` | `>= 0.1.2, < 0.3.4` | `0.3.4` | + +## Summary + +`BSV::Wallet::WalletClient#acquire_certificate` persists certificate records to storage **without verifying the certifier's signature** over the certificate contents. Both acquisition paths are affected: + +- `acquisition_protocol: 'direct'` — the caller supplies all certificate fields (including `signature:`) and the record is written to storage verbatim. +- `acquisition_protocol: 'issuance'` — the client POSTs to a certifier URL and writes whatever signature the response body contains, also without verification. + +An attacker who can reach either API (or who controls a certifier endpoint targeted by the issuance path) can forge identity certificates that subsequently appear authentic to `list_certificates` and `prove_certificate`. + +## Details + +BRC-52 requires a certificate's `signature` field to be verified against the claimed certifier's public key over a canonical hashing of `(type, subject, serialNumber, revocationOutpoint, fields)` before the certificate is trusted. The reference TypeScript SDK enforces this in `Certificate.verify()`. + +### Direct path + +The Ruby implementation's `acquire_via_direct` path (`lib/bsv/wallet_interface/wallet_client.rb`) constructs the certificate record directly from caller-supplied fields: + +```ruby +def acquire_via_direct(args) + { + type: args[:type], + subject: @key_deriver.identity_key, + serial_number: args[:serial_number], + certifier: args[:certifier], + revocation_outpoint: args[:revocation_outpoint], + signature: args[:signature], + fields: args[:fields], + keyring: args[:keyring_for_subject] + } +end +``` + +The returned record is then written to the storage adapter by `acquire_certificate`. No verification of `args[:signature]` against `args[:certifier]`'s public key occurs at any point in this path. + +### Issuance path + +`acquire_via_issuance` POSTs to a certifier-supplied URL and parses the response body into a certificate record, which is then written to storage without verifying the returned signature. A hostile or compromised certifier endpoint — or anyone able to redirect/MITM the plain HTTP request — can therefore return an arbitrary `signature` value for any subject and have it stored as authentic. This is the same class of bypass as the direct path; it was tracked separately as finding **F8.16** in the compliance review and is closed by the same fix. + +### Downstream impact + +Downstream reads via `list_certificates` and selective-disclosure via `prove_certificate` treat stored records as valid without re-verifying, so any forgery that slips past `acquire_certificate` is trusted permanently. + +## Impact + +Any caller that can invoke `acquire_certificate` — via either acquisition protocol — can forge a certificate attributed to an arbitrary certifier identity key, containing arbitrary fields, and have it persisted as authentic. Applications and downstream gems that rely on the wallet's certificate store as a source of truth for identity attributes (e.g. KYC assertions, role claims, attestations) are subject to credential forgery. + +This is a credential-forgery primitive, not merely a spec divergence from BRC-52. + +## CVSS rationale + +`AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N` → **8.1 (High)** + +- **AV:N** — network-reachable in any wallet context that exposes `acquire_certificate` to callers. +- **AC:L** — low attack complexity: pass arbitrary bytes as `signature:`. +- **PR:L** — low privileges: any caller authorised to invoke `acquire_certificate`. +- **UI:N** — no user interaction required. +- **C:H** — forged credentials via `prove_certificate` can assert attributes about the subject. +- **I:H** — the wallet's credential store is polluted with attacker-controlled data. +- **A:N** — availability unaffected. + +## Proof of concept + +```ruby +client = BSV::Wallet::WalletClient.new(key, storage: BSV::Wallet::MemoryStore.new) + +client.acquire_certificate( + type: 'age-over-18', + acquisition_protocol: 'direct', + certifier: claimed_trusted_pubkey_hex, + serial_number: 'any-serial', + revocation_outpoint: ('00' * 32) + '.0', + signature: 'deadbeef' * 16, # arbitrary bytes — never verified + fields: { 'verified' => 'true' }, + keyring_for_subject: {} +) + +client.list_certificates( + certifiers: [claimed_trusted_pubkey_hex], + types: ['age-over-18'] +) +# => returns the forged record as if it were a real certificate from that certifier +``` + +## Affected versions + +The vulnerable direct-path code was introduced in commit `d14dd19` ("feat(wallet): implement BRC-100 identity certificate methods (Phase 5)") on 2026-03-27 20:35 UTC. The vulnerable issuance-path code was added one day later in `6a4d898` ("feat(wallet): implement certificate issuance protocol", 2026-03-28 04:38 UTC), which removed an earlier `raise UnsupportedActionError` and replaced it with an unverified HTTP POST. + +**`bsv-sdk`:** the v0.3.1 chore bump (`89de3a2`) was committed 28 minutes after `d14dd19`, so the direct-path bypass shipped in the **v0.3.1** tag. The v0.3.1 release raised `UnsupportedActionError` for the issuance path, so the issuance-path bypass first shipped in **v0.3.2** (`5a335de`). Every subsequent release up to and including **v0.8.1** is affected by at least one path, and every release from v0.3.2 onwards is affected by both. Combined affected range: `>= 0.3.1, < 0.8.2`. + +**`bsv-wallet`:** at the time both commits landed, the wallet gem was at version 0.1.1. The first wallet release containing any of the vulnerable code was **v0.1.2** (`5a335de`, 2026-03-30), which shipped both paths simultaneously. Every subsequent release up to and including **v0.3.3** is affected on both paths. Affected range: `>= 0.1.2, < 0.3.4`. + +## Patches + +Upgrade to `bsv-sdk >= 0.8.2` **and/or** `bsv-wallet >= 0.3.4`. Both releases ship the same fix: a new module `BSV::Wallet::CertificateSignature` (`lib/bsv/wallet_interface/certificate_signature.rb`), which builds the BRC-52 canonical preimage (`type`, `serial_number`, `subject`, `certifier`, `revocation_outpoint`, lexicographically-sorted `fields`) and verifies the certifier's signature against it via `ProtoWallet#verify_signature` with protocol ID `[2, 'certificate signature']` and counterparty = the claimed certifier's public key. Both `acquire_via_direct` and `acquire_via_issuance` now call `CertificateSignature.verify!` before returning the certificate to `acquire_certificate`, so invalid certificates raise `BSV::Wallet::CertificateSignature::InvalidError` (a subclass of `InvalidSignatureError`) and are never written to storage. + +Consumers should upgrade whichever gem they depend on directly; they do not need both. `bsv-wallet 0.3.4` additionally tightens its dependency on `bsv-sdk` from the stale `~> 0.4` to `>= 0.8.2, < 1.0`, which forces the known-good pairing and pulls in the sibling advisory fixes (F1.3, F5.13) tracked separately. + +The issuance-path fix also partially closes finding **F8.16** from the same compliance review. F8.16's second aspect — switching the issuance transport from ad-hoc JSON POST to BRC-104 AuthFetch — is not addressed here and remains deferred to a future release. + +Fixed in sgbett/bsv-ruby-sdk#306. + +## Workarounds + +If upgrading is not immediately possible: + +- Do not expose `acquire_certificate` (either acquisition protocol) to untrusted callers. +- Do not invoke `acquire_certificate` with `acquisition_protocol: 'issuance'` against a certifier URL you do not fully trust, and require TLS for any such request. +- Treat any record returned by `list_certificates` / `prove_certificate` as unverified and perform an out-of-band BRC-52 verification against the certifier's public key before acting on it. + +## Credit + +Identified during the 2026-04-08 cross-SDK compliance review, tracked as findings F8.15 (direct path) and F8.16 (issuance path, partial). + +## References + +- HLR: sgbett/bsv-ruby-sdk#305 +- Fix PR: sgbett/bsv-ruby-sdk#306 +- Compliance review: [`.architecture/reviews/20260408-cross-sdk-compliance-review.md`](../../.architecture/reviews/20260408-cross-sdk-compliance-review.md) +- BRC-52 specification: https://brc.dev/52 +- TypeScript reference: `Certificate.verify()` in `@bsv/sdk`
.security/advisories/2026-0002-arc-broadcaster-failure-statuses.md+101 −0 added@@ -0,0 +1,101 @@ +--- +id: 2026-0002 +ghsa_id: GHSA-9hfr-gw99-8rhx +ghsa_url: https://github.com/sgbett/bsv-ruby-sdk/security/advisories/GHSA-9hfr-gw99-8rhx +cve_id: TBD +status: draft +title: bsv-sdk ARC broadcaster treats INVALID/MALFORMED/ORPHAN responses as successful broadcasts +package: bsv-sdk +ecosystem: RubyGems +severity: HIGH +cwe: CWE-754 +cvss_v3: AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:N +cvss_score: 7.5 +affected: ">= 0.1.0, < 0.8.2" +patched: "0.8.2" +finding: F5.13 +review: .architecture/reviews/20260408-cross-sdk-compliance-review.md +hlr: sgbett/bsv-ruby-sdk#305 +reported: 2026-04-08 +published: TBD +--- + +# ARC broadcaster treats failure statuses as successful broadcasts + +## Summary + +`BSV::Network::ARC`'s failure detection only recognises `REJECTED` and `DOUBLE_SPEND_ATTEMPTED`. ARC responses with `txStatus` values of `INVALID`, `MALFORMED`, `MINED_IN_STALE_BLOCK`, or any `ORPHAN`-containing `extraInfo` / `txStatus` are silently treated as successful broadcasts. Applications that gate actions on broadcaster success are tricked into trusting transactions that were never accepted by the network. + +## Details + +`lib/bsv/network/arc.rb` (lines ~74-100 in the affected code) uses a narrow failure predicate compared to the TypeScript reference SDK. The TS broadcaster additionally recognises: + +- `INVALID` +- `MALFORMED` +- `MINED_IN_STALE_BLOCK` +- Any response containing `ORPHAN` in `extraInfo` or `txStatus` + +The Ruby implementation omits all of these, so ARC responses carrying any of these statuses are returned to the caller as successful broadcasts. + +Additional divergences in the same module compound the risk: + +- `Content-Type` is sent as `application/octet-stream`; the TS reference sends `application/json` with a `{ rawTx: <hex> }` body (EF form where source transactions are available). +- The headers `XDeployment-ID`, `X-CallbackUrl`, and `X-CallbackToken` are not sent. + +The immediate security-relevant defect is the missing failure statuses; the other divergences are fixed in the same patch for protocol compliance. + +## Impact + +Integrity: callers receive a success response for broadcasts that were actually rejected by the ARC endpoint. Applications and downstream gems that gate actions on broadcaster success — releasing goods, marking invoices paid, treating a token as minted, progressing a workflow — are tricked into trusting transactions that were never broadcast. + +This is an integrity bug with security consequences. It does not disclose information (confidentiality unaffected) and does not affect availability. + +## CVSS rationale + +`AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:N` → **7.5 (High)** + +- **AV:N** — network-reachable. +- **AC:L** — no specialised access conditions are required. Triggering any of the unhandled failure statuses is not meaningfully harder than broadcasting a transaction at all: a malformed or invalid transaction, an orphan condition from a transient fork, or a hostile/misbehaving ARC endpoint returning one of these statuses is sufficient. The attacker does not need to defeat any mitigation or race a specific window — the bug is that the code path doesn't exist at all. +- **PR:N** — no privileges required. +- **UI:N** — no user interaction. +- **C:N** — no confidentiality impact. +- **I:H** — downstream integrity decisions are taken on non-broadcast transactions. +- **A:N** — no availability impact. + +## Affected versions + +The ARC broadcaster was introduced in commit `a1f2e62` ("feat(network): add ARC broadcaster with injectable HTTP client") on 2026-02-08 and first released in **v0.1.0**. The narrow failure predicate has been present since introduction. Every release up to and including **v0.8.1** is affected. + +Affected range: `>= 0.1.0, < 0.8.2`. + +## Patches + +Upgrade to `bsv-sdk >= 0.8.2`. The fix: + +- Expands the failure predicate (`REJECTED_STATUSES` + `ORPHAN` substring check on both `txStatus` and `extraInfo`) to include `INVALID`, `MALFORMED`, `MINED_IN_STALE_BLOCK`, and any orphan-containing response, matching the TypeScript reference. +- Switches `Content-Type` to `application/json` with a `{ rawTx: <hex> }` body, preferring Extended Format (BRC-30) hex when every input has `source_satoshis` and `source_locking_script` populated and falling back to plain raw-tx hex otherwise. +- Adds support for the `XDeployment-ID` (default: random `bsv-ruby-sdk-<hex>`), `X-CallbackUrl`, and `X-CallbackToken` headers via new constructor keyword arguments. + +Fixed in sgbett/bsv-ruby-sdk#306. + +### Note for `bsv-wallet` consumers + +The sibling gem `bsv-wallet` (published from the same repository) is not independently vulnerable — `lib/bsv/network/arc.rb` is not bundled into the wallet gem's `files` list. However, `bsv-wallet` runtime-depends on `bsv-sdk`, so a consumer of `bsv-wallet` that also invokes the ARC broadcaster is transitively exposed whenever `Gemfile.lock` resolves to a vulnerable `bsv-sdk` version. `bsv-wallet >= 0.3.4` tightens its `bsv-sdk` constraint to `>= 0.8.2, < 1.0`, so upgrading either gem is sufficient to pull in the fix. + +## Workarounds + +If upgrading is not immediately possible: + +- Verify broadcast results out-of-band (e.g. query a block explorer or WhatsOnChain) before treating a transaction as broadcast. +- Do not gate integrity-critical actions solely on the ARC broadcaster's success response. + +## Credit + +Identified during the 2026-04-08 cross-SDK compliance review, tracked as finding F5.13. + +## References + +- HLR: sgbett/bsv-ruby-sdk#305 +- Fix PR: sgbett/bsv-ruby-sdk#306 +- Compliance review: [`.architecture/reviews/20260408-cross-sdk-compliance-review.md`](../../.architecture/reviews/20260408-cross-sdk-compliance-review.md) +- TypeScript reference: `ARC` class in `@bsv/sdk`
.security/README.md+22 −0 added@@ -0,0 +1,22 @@ +# Security + +This directory holds security-related artefacts for the gems published from this repository (`bsv-sdk` and `bsv-wallet`): + +- `advisories/` — draft and published security advisories. Each file corresponds to a GitHub Security Advisory (GHSA) and, where assigned, a CVE ID. Drafts live here before publication and are updated in-place when the advisory is published. + +Security reports should be made via GitHub's private vulnerability reporting (Security tab → Report a vulnerability) rather than public issues. + +## Advisory lifecycle + +1. Draft the advisory as a Markdown file in `advisories/` using the existing files as a template. +2. File a GitHub Security Advisory (Security → Advisories → New draft) and request a CVE ID. +3. Keep the draft private until the patched release is tagged and pushed to RubyGems. +4. Publish the GHSA on release day. +5. Update the Markdown file with the final GHSA ID, CVE ID, and publication date. + +## Index + +| GHSA | Finding | Packages | Severity | Status | +| --- | --- | --- | --- | --- | +| [GHSA-hc36-c89j-5f4j](advisories/2026-0001-acquire-certificate-signature-bypass.md) | F8.15 / F8.16 partial — `acquire_certificate` persists unverified certifier signatures | `bsv-sdk`, `bsv-wallet` | HIGH (8.1) | Draft | +| [GHSA-9hfr-gw99-8rhx](advisories/2026-0002-arc-broadcaster-failure-statuses.md) | F5.13 — ARC broadcaster treats failure statuses as success | `bsv-sdk` | HIGH (7.5) | Draft |
spec/bsv/network/arc_spec.rb+200 −5 modified@@ -19,9 +19,24 @@ def request(uri, req) end end - # Minimal transaction double with #to_binary + # Minimal transaction double supporting the Extended Format (BRC-30) + # preferred by ARC, plus the plain raw-tx hex fallback used when any + # input lacks source_satoshis / source_locking_script. let(:tx) do - instance_double(BSV::Transaction::Transaction, to_binary: "\x01\x00\x00\x00".b) + instance_double( + BSV::Transaction::Transaction, + to_ef_hex: '010000000000ef0000', + to_hex: '01000000' + ) + end + + # Alternate transaction double that cannot produce EF form (matches the + # real Transaction#to_ef behaviour of raising when inputs are missing + # source_satoshis or source_locking_script). + let(:tx_no_source) do + dbl = instance_double(BSV::Transaction::Transaction, to_hex: '01000000') + allow(dbl).to receive(:to_ef_hex).and_raise(ArgumentError, 'inputs must have source_satoshis for EF') + dbl end let(:success_body) do @@ -51,14 +66,68 @@ def request(uri, req) expect(response.success?).to be true end - it 'sends binary transaction body with correct content type' do + it 'sends JSON body with Extended Format hex raw tx' do + http = mock_http.new(200, success_body) + arc = described_class.new('https://arc.example.com', http_client: http) + + arc.broadcast(tx) + + expect(http.last_request['Content-Type']).to eq('application/json') + body = JSON.parse(http.last_request.body) + expect(body).to eq('rawTx' => '010000000000ef0000') + end + + it 'falls back to plain raw-tx hex when EF encoding is unavailable' do + http = mock_http.new(200, success_body) + arc = described_class.new('https://arc.example.com', http_client: http) + + arc.broadcast(tx_no_source) + + body = JSON.parse(http.last_request.body) + expect(body).to eq('rawTx' => '01000000') + end + + it 'sends the XDeployment-ID header (default random identifier)' do http = mock_http.new(200, success_body) arc = described_class.new('https://arc.example.com', http_client: http) arc.broadcast(tx) - expect(http.last_request['Content-Type']).to eq('application/octet-stream') - expect(http.last_request.body).to eq("\x01\x00\x00\x00".b) + expect(http.last_request['XDeployment-ID']).to start_with('bsv-ruby-sdk-') + end + + it 'sends an explicit XDeployment-ID when configured' do + http = mock_http.new(200, success_body) + arc = described_class.new('https://arc.example.com', deployment_id: 'acme-prod-42', http_client: http) + + arc.broadcast(tx) + + expect(http.last_request['XDeployment-ID']).to eq('acme-prod-42') + end + + it 'sends X-CallbackUrl and X-CallbackToken headers when configured' do + http = mock_http.new(200, success_body) + arc = described_class.new( + 'https://arc.example.com', + callback_url: 'https://example.com/arc-cb', + callback_token: 'secret-token', + http_client: http + ) + + arc.broadcast(tx) + + expect(http.last_request['X-CallbackUrl']).to eq('https://example.com/arc-cb') + expect(http.last_request['X-CallbackToken']).to eq('secret-token') + end + + it 'omits X-CallbackUrl and X-CallbackToken when not configured' do + http = mock_http.new(200, success_body) + arc = described_class.new('https://arc.example.com', http_client: http) + + arc.broadcast(tx) + + expect(http.last_request['X-CallbackUrl']).to be_nil + expect(http.last_request['X-CallbackToken']).to be_nil end it 'posts to /v1/tx' do @@ -118,6 +187,132 @@ def request(uri, req) expect { arc.broadcast(tx) }.to raise_error(BSV::Network::BroadcastError, 'Double spend') end + # Regression for https://github.com/sgbett/bsv-ruby-sdk/issues/305 (F5.13) + # + # Prior behaviour: `REJECTED_STATUSES` only contained REJECTED and + # DOUBLE_SPEND_ATTEMPTED. ARC responses with txStatus INVALID, MALFORMED, + # MINED_IN_STALE_BLOCK, or any ORPHAN-containing status / extraInfo were + # silently treated as successful broadcasts — callers relying on + # broadcast() to signal failure would trust un-broadcast transactions. + describe 'failure status recognition (issue #305)' do + %w[INVALID MALFORMED MINED_IN_STALE_BLOCK].each do |failure_status| + it "raises BroadcastError when txStatus is #{failure_status}" do + body = { + 'txid' => 'abc123', + 'txStatus' => failure_status, + 'title' => "Transaction #{failure_status.downcase}" + }.to_json + http = mock_http.new(200, body) + arc = described_class.new('https://arc.example.com', http_client: http) + + expect { arc.broadcast(tx) } + .to raise_error(BSV::Network::BroadcastError) + end + end + + it 'raises BroadcastError when txStatus contains ORPHAN' do + body = { + 'txid' => 'abc123', + 'txStatus' => 'ORPHAN_MISSING_PARENT', + 'title' => 'Orphan transaction' + }.to_json + http = mock_http.new(200, body) + arc = described_class.new('https://arc.example.com', http_client: http) + + expect { arc.broadcast(tx) } + .to raise_error(BSV::Network::BroadcastError, 'Orphan transaction') + end + + it 'raises BroadcastError when extraInfo contains ORPHAN' do + body = { + 'txid' => 'abc123', + 'txStatus' => 'SEEN_ON_NETWORK', + 'title' => 'Orphan', + 'extraInfo' => 'transaction is an ORPHAN — missing parent tx' + }.to_json + http = mock_http.new(200, body) + arc = described_class.new('https://arc.example.com', http_client: http) + + expect { arc.broadcast(tx) } + .to raise_error(BSV::Network::BroadcastError) + end + end + + # Follow-up hardening from PR #306 review — match TS's case-insensitive + # detection at `ts-sdk/src/transaction/broadcasters/ARC.ts:155-166`. + # ARC's OpenAPI pins txStatus as an UPPERCASE enum, but ARC has a + # documented history of emitting values outside its own contract + # (TS issue #105), so normalisation is the defensive choice. + describe 'case-insensitive failure status detection (#306 review)' do + %w[rejected invalid malformed mined_in_stale_block REJECTED Invalid].each do |status| + it "raises BroadcastError on txStatus #{status.inspect} (case-insensitive)" do + body = { 'txid' => 'abc123', 'txStatus' => status, 'title' => 'rejected' }.to_json + http = mock_http.new(200, body) + arc = described_class.new('https://arc.example.com', http_client: http) + + expect { arc.broadcast(tx) } + .to raise_error(BSV::Network::BroadcastError) + end + end + + it 'raises BroadcastError when extraInfo contains lowercase "orphan"' do + body = { + 'txid' => 'abc123', + 'txStatus' => 'SEEN_ON_NETWORK', + 'title' => 'Orphan', + 'extraInfo' => 'transaction is an orphan — missing parent tx' + }.to_json + http = mock_http.new(200, body) + arc = described_class.new('https://arc.example.com', http_client: http) + + expect { arc.broadcast(tx) } + .to raise_error(BSV::Network::BroadcastError) + end + + it 'raises BroadcastError when txStatus contains mixed-case "Orphan"' do + body = { + 'txid' => 'abc123', + 'txStatus' => 'seen_in_Orphan_mempool' + }.to_json + http = mock_http.new(200, body) + arc = described_class.new('https://arc.example.com', http_client: http) + + expect { arc.broadcast(tx) } + .to raise_error(BSV::Network::BroadcastError) + end + end + + # Follow-up hardening from PR #306 review — guard against malformed + # 2xx responses that would otherwise produce a silent "success" + # BroadcastResponse full of nils. + describe 'malformed 2xx response rejection (#306 review)' do + it 'raises BroadcastError on a 2xx response that is not JSON' do + http = mock_http.new(200, 'an ARC proxy wrote this plaintext response') + arc = described_class.new('https://arc.example.com', http_client: http) + + expect { arc.broadcast(tx) } + .to raise_error(BSV::Network::BroadcastError, /malformed/) + end + + it 'raises BroadcastError on a 2xx JSON response without a txid' do + body = { 'txStatus' => 'SEEN_ON_NETWORK' }.to_json # no txid + http = mock_http.new(200, body) + arc = described_class.new('https://arc.example.com', http_client: http) + + expect { arc.broadcast(tx) } + .to raise_error(BSV::Network::BroadcastError, /malformed/) + end + + it 'raises BroadcastError on a 2xx JSON response with explicit null txid' do + body = { 'txid' => nil, 'txStatus' => 'SEEN_ON_NETWORK' }.to_json + http = mock_http.new(200, body) + arc = described_class.new('https://arc.example.com', http_client: http) + + expect { arc.broadcast(tx) } + .to raise_error(BSV::Network::BroadcastError, /malformed/) + end + end + it 'handles non-JSON response body gracefully' do http = mock_http.new(500, 'Internal Server Error') arc = described_class.new('https://arc.example.com', http_client: http)
spec/bsv/transaction/var_int_spec.rb+35 −0 modified@@ -99,4 +99,39 @@ expect(described_class.encode(0x100000000).getbyte(0)).to eq(0xFF) # 9-byte end end + + # Regression for https://github.com/sgbett/bsv-ruby-sdk/issues/305 (F1.3) + # + # Prior behaviour: `VarInt.encode(-1)` fell into the `value < 0xFD` branch and + # emitted `[value].pack('C')`, which masks the two's-complement low byte — + # producing 0xFF, the marker byte for a 9-byte encoding. A downstream reader + # would then try to consume 8 more bytes from whatever followed, silently + # corrupting the transaction stream with no exception raised. The docstring + # required a non-negative integer but the implementation did not enforce it. + describe '.encode input validation (issue #305)' do + it 'raises ArgumentError on -1 (silent 0xFF marker corruption)' do + expect { described_class.encode(-1) } + .to raise_error(ArgumentError, /non-negative/) + end + + it 'raises ArgumentError on a large negative integer' do + expect { described_class.encode(-0xFFFFFFFFFFFFFFFF) } + .to raise_error(ArgumentError, /non-negative/) + end + + it 'accepts the uint64 maximum (2^64 - 1)' do + expected = "\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF".b + expect(described_class.encode(0xFFFFFFFFFFFFFFFF)).to eq(expected) + end + + it 'raises ArgumentError on 2^64 (just past uint64 max)' do + expect { described_class.encode(0x10000000000000000) } + .to raise_error(ArgumentError, /exceeds uint64/) + end + + it 'raises ArgumentError on a very large value far above uint64' do + expect { described_class.encode(2**128) } + .to raise_error(ArgumentError, /exceeds uint64/) + end + end end
spec/bsv/wallet_interface/certificate_spec.rb+170 −1 modified@@ -10,15 +10,36 @@ let(:wallet) { BSV::Wallet::WalletClient.new(private_key, storage: BSV::Wallet::MemoryStore.new) } let(:certifier_key) { BSV::Primitives::PrivateKey.generate } let(:certifier_hex) { certifier_key.public_key.to_hex } + let(:certifier_wallet) { BSV::Wallet::ProtoWallet.new(certifier_key) } let(:cert_type) { Base64.strict_encode64(SecureRandom.random_bytes(32)) } let(:serial_number) { Base64.strict_encode64(SecureRandom.random_bytes(32)) } let(:revocation_outpoint) { "#{'ab' * 32}.0" } - let(:signature) { 'deadbeef' * 8 } let(:fields) { { 'name' => 'Alice', 'email' => 'alice@example.com' } } let(:keyring) { { 'name' => Base64.strict_encode64('key1'), 'email' => Base64.strict_encode64('key2') } } + # Compute a valid BRC-52 certifier signature over the canonical preimage. + # The certifier signs with counterparty 'anyone' so any verifier deriving + # against counterparty=certifier_hex can reconstruct the same public key. + let(:signature) do + preimage = BSV::Wallet::CertificateSignature.serialise_preimage( + type: cert_type, + serial_number: serial_number, + subject: wallet.key_deriver.identity_key, + certifier: certifier_hex, + revocation_outpoint: revocation_outpoint, + fields: fields + ) + result = certifier_wallet.create_signature({ + data: preimage.unpack('C*'), + protocol_id: [2, 'certificate signature'], + key_id: "#{cert_type} #{serial_number}", + counterparty: 'anyone' + }) + result[:signature].pack('C*').unpack1('H*') + end + let(:direct_args) do { type: cert_type, @@ -51,6 +72,154 @@ expect(result).not_to have_key(:keyring) end + # Regression for https://github.com/sgbett/bsv-ruby-sdk/issues/305 (F8.15) + # + # Prior to this fix, `acquire_via_direct` wrote user-supplied certificate + # fields to storage without verifying the certifier's signature. A caller + # could pass any value as `args[:signature]` and it would be persisted as + # authentic. `list_certificates` and `prove_certificate` then treated the + # record as valid, producing a credential forgery primitive. + describe 'BRC-52 certifier signature verification (issue #305)' do + it 'rejects a certificate with an invalid signature' do + tampered = direct_args.merge(signature: 'ff' * 70) + + expect { wallet.acquire_certificate(tampered) } + .to raise_error(BSV::Wallet::CertificateSignature::InvalidError) + end + + it 'rejects a certificate whose fields have been tampered with after signing' do + tampered = direct_args.merge(fields: fields.merge('email' => 'attacker@example.com')) + + expect { wallet.acquire_certificate(tampered) } + .to raise_error(BSV::Wallet::CertificateSignature::InvalidError) + end + + it 'rejects a certificate whose subject is overridden' do + # The wallet always sets subject to its own identity key, so changing + # the subject in args has no effect — but if the signature was made + # over a different subject, the preimage hash won't match. + other_wallet_signature = begin + other_key = BSV::Primitives::PrivateKey.generate + preimage = BSV::Wallet::CertificateSignature.serialise_preimage( + type: cert_type, + serial_number: serial_number, + subject: other_key.public_key.to_hex, + certifier: certifier_hex, + revocation_outpoint: revocation_outpoint, + fields: fields + ) + result = certifier_wallet.create_signature({ + data: preimage.unpack('C*'), + protocol_id: [2, 'certificate signature'], + key_id: "#{cert_type} #{serial_number}", + counterparty: 'anyone' + }) + result[:signature].pack('C*').unpack1('H*') + end + + tampered = direct_args.merge(signature: other_wallet_signature) + + expect { wallet.acquire_certificate(tampered) } + .to raise_error(BSV::Wallet::CertificateSignature::InvalidError) + end + + it 'rejects a certificate signed by a different certifier' do + imposter_key = BSV::Primitives::PrivateKey.generate + imposter_wallet = BSV::Wallet::ProtoWallet.new(imposter_key) + preimage = BSV::Wallet::CertificateSignature.serialise_preimage( + type: cert_type, + serial_number: serial_number, + subject: wallet.key_deriver.identity_key, + certifier: certifier_hex, # claims to be from the real certifier + revocation_outpoint: revocation_outpoint, + fields: fields + ) + imposter_sig = imposter_wallet.create_signature({ + data: preimage.unpack('C*'), + protocol_id: [2, 'certificate signature'], + key_id: "#{cert_type} #{serial_number}", + counterparty: 'anyone' + })[:signature].pack('C*').unpack1('H*') + + tampered = direct_args.merge(signature: imposter_sig) + + expect { wallet.acquire_certificate(tampered) } + .to raise_error(BSV::Wallet::CertificateSignature::InvalidError) + end + + it 'rejects a certificate with a malformed (non-hex) signature' do + tampered = direct_args.merge(signature: 'not hex at all') + + expect { wallet.acquire_certificate(tampered) } + .to raise_error(BSV::Wallet::CertificateSignature::InvalidError) + end + + it 'does not persist an unverified certificate to storage' do + tampered = direct_args.merge(signature: 'ff' * 70) + + expect { wallet.acquire_certificate(tampered) } + .to raise_error(BSV::Wallet::CertificateSignature::InvalidError) + + certs = wallet.list_certificates({ certifiers: [certifier_hex], types: [cert_type] }) + expect(certs[:total_certificates]).to eq(0) + end + end + + # Follow-up hardening from PR #306 review. + describe 'CertificateSignature input validation (#306 review)' do + # #2 — strict base64 decode + it 'rejects a certificate whose type has whitespace-injected base64' do + # strict_decode64 rejects whitespace; decode64 would have accepted it. + # The decoded length would still be 32 bytes here, which is exactly + # the silent-corruption case strict_decode64 closes. + raw32 = "\x01".b * 32 + valid_type = Base64.strict_encode64(raw32) + whitespace_injected = valid_type.chars.each_slice(8).map(&:join).join("\n") + + tampered = direct_args.merge(type: whitespace_injected) + + expect { wallet.acquire_certificate(tampered) } + .to raise_error(BSV::Wallet::CertificateSignature::InvalidError, /base64/) + end + + it 'rejects a certificate whose serial_number has non-base64 characters' do + tampered = direct_args.merge(serial_number: 'not valid base64!!@#$%^&*()_+|') + + expect { wallet.acquire_certificate(tampered) } + .to raise_error(BSV::Wallet::CertificateSignature::InvalidError) + end + + # #3 — EncodingError → InvalidError + it 'rejects a certificate with non-UTF-8 bytes in a field value as InvalidError' do + # A lone 0x80 byte is invalid UTF-8 (continuation byte with no lead). + bad_field_value = "\x80".b + tampered = direct_args.merge(fields: fields.merge('email' => bad_field_value)) + + # Without the EncodingError rescue, this would leak + # Encoding::InvalidByteSequenceError / UndefinedConversionError. + expect { wallet.acquire_certificate(tampered) } + .to raise_error(BSV::Wallet::CertificateSignature::InvalidError) + end + + # #4 — duplicate field names + it 'rejects fields containing both symbol and string forms of the same name' do + ambiguous = { 'email' => 'one@example.com', email: 'two@example.com' } + tampered = direct_args.merge(fields: ambiguous) + + expect { wallet.acquire_certificate(tampered) } + .to raise_error(BSV::Wallet::CertificateSignature::InvalidError, /duplicate field name/) + end + + # #6 — hex_to_bytes even-length guard + it 'rejects an odd-length signature hex' do + # Chop one hex nibble off an otherwise valid signature. + tampered = direct_args.merge(signature: signature[0...-1]) + + expect { wallet.acquire_certificate(tampered) } + .to raise_error(BSV::Wallet::CertificateSignature::InvalidError, /even/) + end + end + it 'raises InvalidParameterError for issuance without certifier_url' do args = direct_args.merge(acquisition_protocol: 'issuance') args.delete(:serial_number)
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
9- github.com/sgbett/bsv-ruby-sdk/security/advisories/GHSA-hc36-c89j-5f4jnvdExploitPatchVendor Advisory
- github.com/advisories/GHSA-hc36-c89j-5f4jghsaADVISORY
- brc.dev/52nvdNot Applicable
- github.com/sgbett/bsv-ruby-sdk/commit/4992e8a265fd914a7eeb0405c69d1ff0122a84ccnvdRelease Notes
- github.com/sgbett/bsv-ruby-sdk/issues/305nvdIssue Tracking
- github.com/sgbett/bsv-ruby-sdk/pull/306nvdIssue Tracking
- github.com/rubysec/ruby-advisory-db/blob/master/gems/bsv-sdk/CVE-2026-40070.ymlghsa
- github.com/rubysec/ruby-advisory-db/blob/master/gems/bsv-wallet/CVE-2026-40070.ymlghsa
- nvd.nist.gov/vuln/detail/CVE-2026-40070ghsa
News mentions
0No linked articles in our index yet.