Root cause
Validation & fail-open logic introduced
feat(wallet): implement BRC-100 identity certificate methods (Phase 5)
How AI contributed: Direct introductionPublished Apr 9, 2026 in
sgbett/bsv-ruby-sdkAll 2 findings in this repository
Unverified certifier signatures persisted by acquire_certificate Affected packages Both bsv-sdk and bsv-wallet are published from the 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: 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 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 · BRC-52 specification: https://brc.dev/52 · TypeScript reference: Certificate.verify() in @bsv/sdk
The bsv-ruby-sdk wallet stored an acquired identity certificate without checking the certifier's signature, both when the caller supplied the certificate directly and when it came back from a certifier over the issuance protocol. Listing and proving certificates then trusted those stored records, so anyone who could reach the wallet's acquisition call, or answer for the certifier, could plant a certificate attributed to a certifier that never signed it. The repair verifies the certifier signature on both paths before anything is stored.
Root cause
feat(wallet): implement BRC-100 identity certificate methods (Phase 5)
Fix
fix(security): A1 security hotfixes from 2026-04-08 compliance review
AI-assisted fix: Claude Code · Simon Bettison
Case facts
@@ -233,6 +233,167 @@ def wait_for_authentication(_args = {}, _originator: nil) { authenticated: true } end + # --- Identity and Certificate Management ---++ # Acquires an identity certificate via direct storage.+ #+ # The 'issuance' protocol (which requires HTTP to a certifier URL) is+ # not yet supported and raises {UnsupportedActionError}.+ #+ # @param args [Hash]+ # @option args [String] :type certificate type (base64)+ # @option args [String] :certifier certifier public key hex+ # @option args [String] :acquisition_protocol 'direct' or 'issuance'+ # @option args [Hash] :fields certificate fields (field_name => value)+ # @option args [String] :serial_number serial number (required for direct)+ # @option args [String] :revocation_outpoint outpoint string (required for direct)+ # @option args [String] :signature certifier signature hex (required for direct)+ # @option args [String] :keyring_revealer pubkey hex or 'certifier' (required for direct)+ # @option args [Hash] :keyring_for_subject field_name => base64 key (required for direct)+ # @return [Hash] the stored certificate+ def acquire_certificate(args, _originator: nil)+ validate_acquire_certificate!(args)++ raise UnsupportedActionError, 'acquire_certificate with issuance protocol' if args[:acquisition_protocol] == 'issuance'++ cert = {+ 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]+ }++ @storage.store_certificate(cert)+ cert_without_keyring(cert)+ end++ # Lists identity certificates filtered by certifier and type.+ #+ # @param args [Hash]+ # @option args [Array<String>] :certifiers certifier public keys+ # @option args [Array<String>] :types certificate types+ # @option args [Integer] :limit max results (default 10)+ # @option args [Integer] :offset number to skip (default 0)+ # @return [Hash] { total_certificates:, certificates: [...] }+ def list_certificates(args, _originator: nil)+ raise InvalidParameterError.new('certifiers', 'a non-empty Array') unless args[:certifiers].is_a?(Array) && !args[:certifiers].empty?+ raise InvalidParameterError.new('types', 'a non-empty Array') unless args[:types].is_a?(Array) && !args[:types].empty?++ query = {+ certifiers: args[:certifiers],+ types: args[:types],+ limit: args[:limit] || 10,+ offset: args[:offset] || 0+ }+ total = @storage.count_certificates(query)+ certs = @storage.find_certificates(query)+ { total_certificates: total, certificates: certs.map { |c| cert_without_keyring(c) } }+ end++ # Proves select fields of an identity certificate to a verifier.+ #+ # Encrypts each requested field's keyring entry for the verifier using+ # protocol-derived encryption (BRC-2), allowing the verifier to decrypt+ # only the revealed fields.+ #+ # @param args [Hash]+ # @option args [Hash] :certificate the certificate to prove+ # @option args [Array<String>] :fields_to_reveal field names to reveal+ # @option args [String] :verifier verifier public key hex+ # @return [Hash] { keyring_for_verifier: { field_name => Array<Integer> } }+ def prove_certificate(args, _originator: nil)+ cert_arg = args[:certificate]+ fields_to_reveal = args[:fields_to_reveal]+ verifier = args[:verifier]++ raise InvalidParameterError.new('certificate', 'a Hash') unless cert_arg.is_a?(Hash)+ raise InvalidParameterError.new('fields_to_reveal', 'a non-empty Array') unless fields_to_reveal.is_a?(Array) && !fields_to_reveal.empty?++ Validators.validate_pub_key_hex!(verifier, 'verifier')++ # Look up the full certificate (with keyring) from storage+ stored = find_stored_certificate(cert_arg)+ raise WalletError, 'Certificate not found in wallet' unless stored+ raise WalletError, 'Certificate has no keyring' unless stored[:keyring]@@ -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' endAI-assisted change 84122218d39f05d34456d7ea0bd3411d23a696e4d8f8a2204b9f076a921a3010 · Fix 9f04663b9520998bd1d2ff7fd0e337d809ccb38b94666f6511e3afc0aaccb831