CVE-2026-40069
Description
BSV Ruby SDK is the Ruby SDK for the BSV blockchain. From 0.1.0 to before 0.8.2, 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. This vulnerability is fixed in 0.8.2.
Affected products
1Patches
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
8- github.com/sgbett/bsv-ruby-sdk/commit/4992e8a265fd914a7eeb0405c69d1ff0122a84ccnvdPatch
- github.com/sgbett/bsv-ruby-sdk/security/advisories/GHSA-9hfr-gw99-8rhxnvdPatchVendor Advisory
- github.com/advisories/GHSA-9hfr-gw99-8rhxghsaADVISORY
- github.com/sgbett/bsv-ruby-sdk/issues/305nvdIssue Tracking
- github.com/sgbett/bsv-ruby-sdk/pull/306nvdIssue Tracking
- github.com/sgbett/bsv-ruby-sdk/releases/tag/v0.8.2nvdRelease Notes
- github.com/rubysec/ruby-advisory-db/blob/master/gems/bsv-sdk/CVE-2026-40069.ymlghsa
- nvd.nist.gov/vuln/detail/CVE-2026-40069ghsa
News mentions
0No linked articles in our index yet.