VYPR
High severity8.1NVD Advisory· Published Apr 9, 2026· Updated Apr 24, 2026

CVE-2026-40070

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

2
  • cpe:2.3:a:sgbett:bsv_ruby_sdk:*:*:*:*:*:ruby:*:*
    Range: >=0.3.1,<0.8.2
  • cpe:2.3:a:sgbett:bsv-wallet:*:*:*:*:*:ruby:*:*
    Range: >=0.1.2,<0.3.4

Patches

1
4992e8a265fd

Merge pull request #306 from sgbett/fix/305-security-hotfixes

https://github.com/sgbett/bsv-ruby-sdkSimon BettisonApr 8, 2026via ghsa
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

News mentions

0

No linked articles in our index yet.