|
| 1 | +--- |
| 2 | +gem: bsv-sdk |
| 3 | +cve: 2026-40070 |
| 4 | +ghsa: hc36-c89j-5f4j |
| 5 | +url: https://github.com/sgbett/bsv-ruby-sdk/security/advisories/GHSA-hc36-c89j-5f4j |
| 6 | +title: bsv-sdk and bsv-wallet persist unverified certifier signatures |
| 7 | + in acquire_certificate (direct and issuance paths) |
| 8 | +date: 2026-04-09 |
| 9 | +description: | |
| 10 | + # Unverified certifier signatures persisted by `acquire_certificate` |
| 11 | +
|
| 12 | + ## Affected packages |
| 13 | +
|
| 14 | + Both `bsv-sdk` and `bsv-wallet` are published from the |
| 15 | + [sgbett/bsv-ruby-sdk](https://github.com/sgbett/bsv-ruby-sdk) |
| 16 | + repository. The vulnerable code lives in |
| 17 | + `lib/bsv/wallet_interface/wallet_client.rb`, which is **physically |
| 18 | + shipped inside both gems** (the `bsv-wallet.gemspec` `files` list |
| 19 | + bundles the entire `lib/bsv/wallet_interface/` tree). Consumers of |
| 20 | + either gem are independently vulnerable; the two packages are |
| 21 | + versioned separately, so each has its own affected range. |
| 22 | +
|
| 23 | + | Package | Affected | Patched | |
| 24 | + | --- | --- | --- | |
| 25 | + | `bsv-sdk` | `>= 0.3.1, < 0.8.2` | `0.8.2` | |
| 26 | + | `bsv-wallet` | `>= 0.1.2, < 0.3.4` | `0.3.4` | |
| 27 | +
|
| 28 | + ## Summary |
| 29 | +
|
| 30 | + `BSV::Wallet::WalletClient#acquire_certificate` persists certificate |
| 31 | + records to storage **without verifying the certifier's signature** |
| 32 | + over the certificate contents. Both acquisition paths are affected: |
| 33 | +
|
| 34 | + - `acquisition_protocol: 'direct'` — the caller supplies all certificate |
| 35 | + fields (including `signature:`) and the record is written to storage verbatim. |
| 36 | + - `acquisition_protocol: 'issuance'` — the client POSTs to a certifier |
| 37 | + URL and writes whatever signature the response body contains, also |
| 38 | + without verification. |
| 39 | +
|
| 40 | + An attacker who can reach either API (or who controls a certifier |
| 41 | + endpoint targeted by the issuance path) can forge identity certificates |
| 42 | + that subsequently appear authentic to `list_certificates` and `prove_certificate`. |
| 43 | +
|
| 44 | + ## Details |
| 45 | +
|
| 46 | + BRC-52 requires a certificate's `signature` field to be verified against |
| 47 | + the claimed certifier's public key over a canonical hashing of |
| 48 | + `(type, subject, serialNumber, revocationOutpoint, fields)` before |
| 49 | + the certificate is trusted. The reference TypeScript SDK enforces |
| 50 | + this in `Certificate.verify()`. |
| 51 | +
|
| 52 | + ### Direct path |
| 53 | +
|
| 54 | + The Ruby implementation's `acquire_via_direct` path |
| 55 | + (`lib/bsv/wallet_interface/wallet_client.rb`) constructs the certificate |
| 56 | + record directly from caller-supplied fields: |
| 57 | +
|
| 58 | + ```ruby |
| 59 | + def acquire_via_direct(args) |
| 60 | + { |
| 61 | + type: args[:type], |
| 62 | + subject: @key_deriver.identity_key, |
| 63 | + serial_number: args[:serial_number], |
| 64 | + certifier: args[:certifier], |
| 65 | + revocation_outpoint: args[:revocation_outpoint], |
| 66 | + signature: args[:signature], |
| 67 | + fields: args[:fields], |
| 68 | + keyring: args[:keyring_for_subject] |
| 69 | + } |
| 70 | + end |
| 71 | + ``` |
| 72 | +
|
| 73 | + The returned record is then written to the storage adapter by |
| 74 | + `acquire_certificate`. No verification of `args[:signature]` against |
| 75 | + `args[:certifier]`'s public key occurs at any point in this path. |
| 76 | +
|
| 77 | + ### Issuance path |
| 78 | +
|
| 79 | + `acquire_via_issuance` POSTs to a certifier-supplied URL and parses |
| 80 | + the response body into a certificate record, which is then written |
| 81 | + to storage without verifying the returned signature. A hostile or |
| 82 | + compromised certifier endpoint — or anyone able to redirect/MITM the |
| 83 | + plain HTTP request — can therefore return an arbitrary `signature` |
| 84 | + value for any subject and have it stored as authentic. This is the |
| 85 | + same class of bypass as the direct path; it was tracked separately |
| 86 | + as finding **F8.16** in the compliance review and is closed by the same fix. |
| 87 | +
|
| 88 | + ### Downstream impact |
| 89 | +
|
| 90 | + Downstream reads via `list_certificates` and selective-disclosure via |
| 91 | + `prove_certificate` treat stored records as valid without re-verifying, |
| 92 | + so any forgery that slips past `acquire_certificate` is trusted permanently. |
| 93 | +
|
| 94 | + ## Impact |
| 95 | +
|
| 96 | + Any caller that can invoke `acquire_certificate` — via either |
| 97 | + acquisition protocol — can forge a certificate attributed to an |
| 98 | + arbitrary certifier identity key, containing arbitrary fields, and |
| 99 | + have it persisted as authentic. Applications and downstream gems |
| 100 | + that rely on the wallet's certificate store as a source of truth |
| 101 | + for identity attributes (e.g. KYC assertions, role claims, |
| 102 | + attestations) are subject to credential forgery. |
| 103 | +
|
| 104 | + This is a credential-forgery primitive, not merely a spec |
| 105 | + divergence from BRC-52. |
| 106 | +
|
| 107 | + ## CVSS rationale |
| 108 | +
|
| 109 | + `AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N` → **8.1 (High)** |
| 110 | +
|
| 111 | + - **AV:N** — network-reachable in any wallet context that |
| 112 | + exposes `acquire_certificate` to callers. |
| 113 | + - **AC:L** — low attack complexity: pass arbitrary bytes as `signature:`. |
| 114 | + - **PR:L** — low privileges: any caller authorised to invoke |
| 115 | + `acquire_certificate`. |
| 116 | + - **UI:N** — no user interaction required. |
| 117 | + - **C:H** — forged credentials via `prove_certificate` can assert |
| 118 | + attributes about the subject. |
| 119 | + - **I:H** — the wallet's credential store is polluted with |
| 120 | + attacker-controlled data. |
| 121 | + - **A:N** — availability unaffected. |
| 122 | +
|
| 123 | + ## Proof of concept |
| 124 | +
|
| 125 | + ```ruby |
| 126 | + client = BSV::Wallet::WalletClient.new(key, |
| 127 | + storage: BSV::Wallet::MemoryStore.new) |
| 128 | +
|
| 129 | + client.acquire_certificate( |
| 130 | + type: 'age-over-18', |
| 131 | + acquisition_protocol: 'direct', |
| 132 | + certifier: claimed_trusted_pubkey_hex, |
| 133 | + serial_number: 'any-serial', |
| 134 | + revocation_outpoint: ('00' * 32) + '.0', |
| 135 | + signature: 'deadbeef' * 16, # arbitrary bytes — never verified |
| 136 | + fields: { 'verified' => 'true' }, |
| 137 | + keyring_for_subject: {} |
| 138 | + ) |
| 139 | +
|
| 140 | + client.list_certificates( |
| 141 | + certifiers: [claimed_trusted_pubkey_hex], |
| 142 | + types: ['age-over-18'] |
| 143 | + ) |
| 144 | + # => returns the forged record as if it were a real |
| 145 | + # certificate from that certifier |
| 146 | + ``` |
| 147 | +
|
| 148 | + ## Affected versions |
| 149 | +
|
| 150 | + The vulnerable direct-path code was introduced in commit `d14dd19` |
| 151 | + ("feat(wallet): implement BRC-100 identity certificate methods |
| 152 | + (Phase 5)") on 2026-03-27 20:35 UTC. The vulnerable issuance-path |
| 153 | + code was added one day later in `6a4d898` ("feat(wallet): implement |
| 154 | + certificate issuance protocol", 2026-03-28 04:38 UTC), which removed |
| 155 | + an earlier `raise UnsupportedActionError` and replaced it with an |
| 156 | + unverified HTTP POST. |
| 157 | +
|
| 158 | + **`bsv-sdk`:** the v0.3.1 chore bump (`89de3a2`) was committed |
| 159 | + 28 minutes after `d14dd19`, so the direct-path bypass shipped in |
| 160 | + the **v0.3.1** tag. The v0.3.1 release raised `UnsupportedActionError` |
| 161 | + for the issuance path, so the issuance-path bypass first shipped in |
| 162 | + **v0.3.2** (`5a335de`). Every subsequent release up to and including |
| 163 | + **v0.8.1** is affected by at least one path, and every release |
| 164 | + from v0.3.2 onwards is affected by both. |
| 165 | + Combined affected range: `>= 0.3.1, < 0.8.2`. |
| 166 | +
|
| 167 | + **`bsv-wallet`:** at the time both commits landed, the wallet gem |
| 168 | + was at version 0.1.1. The first wallet release containing any of |
| 169 | + the vulnerable code was **v0.1.2** (`5a335de`, 2026-03-30), which |
| 170 | + shipped both paths simultaneously. Every subsequent release up to |
| 171 | + and including **v0.3.3** is affected on both paths. |
| 172 | + Affected range: `>= 0.1.2, < 0.3.4`. |
| 173 | +
|
| 174 | + ## Patches |
| 175 | +
|
| 176 | + Upgrade to `bsv-sdk >= 0.8.2` **and/or** `bsv-wallet >= 0.3.4`. |
| 177 | + Both releases ship the same fix: a new module |
| 178 | + `BSV::Wallet::CertificateSignature` |
| 179 | + (`lib/bsv/wallet_interface/certificate_signature.rb`), which builds |
| 180 | + the BRC-52 canonical preimage (`type`, `serial_number`, `subject`, |
| 181 | + `certifier`, `revocation_outpoint`, lexicographically-sorted `fields`) |
| 182 | + and verifies the certifier's signature against it via |
| 183 | + `ProtoWallet#verify_signature` with protocol ID `[2, 'certificate signature']` |
| 184 | + and counterparty = the claimed certifier's public key. Both |
| 185 | + `acquire_via_direct` and `acquire_via_issuance` now call |
| 186 | + `CertificateSignature.verify!` before returning the certificate to |
| 187 | + `acquire_certificate`, so invalid certificates raise |
| 188 | + `BSV::Wallet::CertificateSignature::InvalidError` (a subclass of |
| 189 | + `InvalidSignatureError`) and are never written to storage. |
| 190 | +
|
| 191 | + Consumers should upgrade whichever gem they depend on directly; they |
| 192 | + do not need both. `bsv-wallet 0.3.4` additionally tightens its |
| 193 | + dependency on `bsv-sdk` from the stale `~> 0.4` to `>= 0.8.2, < 1.0`, |
| 194 | + which forces the known-good pairing and pulls in the sibling |
| 195 | + advisory fixes (F1.3, F5.13) tracked separately. |
| 196 | +
|
| 197 | + The issuance-path fix also partially closes finding **F8.16** |
| 198 | + from the same compliance review. F8.16's second aspect — switching |
| 199 | + the issuance transport from ad-hoc JSON POST to BRC-104 AuthFetch — |
| 200 | + is not addressed here and remains deferred to a future release. |
| 201 | +
|
| 202 | + Fixed in sgbett/bsv-ruby-sdk#306. |
| 203 | +
|
| 204 | + ## Workarounds |
| 205 | +
|
| 206 | + If upgrading is not immediately possible: |
| 207 | +
|
| 208 | + - Do not expose `acquire_certificate` (either acquisition |
| 209 | + protocol) to untrusted callers. |
| 210 | + - Do not invoke `acquire_certificate` with `acquisition_protocol: |
| 211 | + 'issuance'` against a certifier URL you do not fully trust, and |
| 212 | + require TLS for any such request. |
| 213 | + - Treat any record returned by `list_certificates` / `prove_certificate` |
| 214 | + as unverified and perform an out-of-band BRC-52 verification against |
| 215 | + the certifier's public key before acting on it. |
| 216 | +
|
| 217 | + ## Credit |
| 218 | +
|
| 219 | + Identified during the 2026-04-08 cross-SDK compliance review, tracked |
| 220 | + as findings F8.15 (direct path) and F8.16 (issuance path, partial). |
| 221 | +
|
| 222 | +cvss_v3: 8.1 |
| 223 | +unaffected_versions: |
| 224 | + - "< 0.3.1" |
| 225 | +patched_versions: |
| 226 | + - ">= 0.8.2" |
| 227 | +related: |
| 228 | + url: |
| 229 | + - https://nvd.nist.gov/vuln/detail/CVE-2026-40070 |
| 230 | + - https://github.com/sgbett/bsv-ruby-sdk/security/advisories/GHSA-hc36-c89j-5f4j |
| 231 | + - https://github.com/sgbett/bsv-ruby-sdk/pull/306 |
| 232 | + - https://github.com/sgbett/bsv-ruby-sdk/commit/4992e8a265fd914a7eeb0405c69d1ff0122a84cc |
| 233 | + - https://github.com/sgbett/bsv-ruby-sdk/issues/305 |
| 234 | + - https://bsv.brc.dev/peer-to-peer/0052 |
| 235 | + - https://advisories.gitlab.com/pkg/gem/bsv-sdk/CVE-2026-40070/ |
| 236 | + - https://github.com/advisories/GHSA-hc36-c89j-5f4j |
0 commit comments