Severity: critical — signature-link verification is effectively disabled on the receiving side.
The bug
SHIPTopicManager.ts (and the identical construct in SLAPTopicManager.ts):
if (!isTokenSignatureCorrectlyLinked(result.lockingPublicKey, result.fields))
continue
outputsToAdmit.push(i)
isTokenSignatureCorrectlyLinked is declared async and returns a Promise<boolean>. !<Promise> is always false (a pending Promise is a truthy object). The continue branch is unreachable. Every PushDrop output that passes the synchronous field-shape gates (5 fields, field[0] === 'SHIP' | 'SLAP', valid URI, tm_ / ls_ prefix, valid topic/service name) admits — regardless of whether the signature is valid, valid for the claimed identity key, or even present.
Verified against the shipped npm build @bsv/overlay-discovery-services@2.0.2 at dist/esm/src/SHIP/SHIPTopicManager.js:40 and dist/esm/src/SLAP/SLAPTopicManager.js:40. Same bug in both.
Impact
- Anyone can publish SHIP records impersonating any identity key on any mainline-2.x overlay.
ls_ship queries return the impostor records; GASP sync propagates them.
- The cryptographic binding between the advertised identity key and the output's locking key — which exists to make SHIP/SLAP discovery trustworthy — is not enforced on the receiving side.
- Practical exploit: a malicious publisher can flood
ls_ship with records advertising any tm_* topic at any URI, under any identity key they please.
Reproducer
import { Transaction, PushDrop, ProtoWallet, Utils } from '@bsv/sdk'
import { SHIPTopicManager } from '@bsv/overlay-discovery-services'
// Any real SHIP BEEF whose identity/locking-key pair doesn't match the
// BRC-42 derivation under protocolID=[2,'service host interconnect'],keyID='1'.
// (In practice: records produced by older SDKs, or otherwise malformed.)
const beef = /* <BEEF bytes> */
// Step 1: confirm the signature link check would fail if awaited.
const tx = Transaction.fromBEEF(beef)
const pd = PushDrop.decode(tx.outputs[0].lockingScript)
const anyoneWallet = new ProtoWallet('anyone')
const { publicKey: derived } = await anyoneWallet.getPublicKey({
counterparty: Utils.toHex(pd.fields[1]),
protocolID: [2, 'service host interconnect'],
keyID: '1'
})
console.log('derived =', derived)
console.log('locking_key =', pd.lockingPublicKey.toString())
console.log('derived===lock?=', derived === pd.lockingPublicKey.toString())
// Output: false — the record fails the identity→locking-key check.
// Step 2: run the shipped TopicManager on the same BEEF.
const result = await new SHIPTopicManager().identifyAdmissibleOutputs(beef, [])
console.log(result)
// { outputsToAdmit: [0, ...], coinsToRetain: [] } ← admitted anyway
Patch
Trivial one-line fix in each file. Against src/SHIP/SHIPTopicManager.ts:
@@ identifyAdmissibleOutputs ...
- if (!isTokenSignatureCorrectlyLinked(result.lockingPublicKey, result.fields))
+ if (!(await isTokenSignatureCorrectlyLinked(result.lockingPublicKey, result.fields)))
continue
outputsToAdmit.push(i)
Same patch in src/SLAP/SLAPTopicManager.ts.
Unit test to add (pseudocode):
it('rejects a SHIP output whose identity key does not match the locking key', async () => {
const beef = makeShipRecord({ mismatchedIdentityKey: true })
const res = await new SHIPTopicManager().identifyAdmissibleOutputs(beef, [])
expect(res.outputsToAdmit).toHaveLength(0)
})
How this was found
Differential parity harness between @bsv/overlay-express@2.2.0 and a Rust port (github.com/Calhooon/rust-overlay, pre-release). Same BEEF submitted to both; mainline admitted, Rust rejected. Rust's rejection was traced to the signature-link check failing; I then ran the same check directly against @bsv/sdk's ProtoWallet in Node against the same inputs and got the same derived !== locking result — confirming the check should have rejected. Ran SHIPTopicManager.identifyAdmissibleOutputs directly — it admits. The compiled dist/esm/.../SHIPTopicManager.js shows the missing await.
Happy to open a PR if you want.
Severity: critical — signature-link verification is effectively disabled on the receiving side.
The bug
SHIPTopicManager.ts(and the identical construct inSLAPTopicManager.ts):isTokenSignatureCorrectlyLinkedis declaredasyncand returns aPromise<boolean>.!<Promise>is alwaysfalse(a pending Promise is a truthy object). Thecontinuebranch is unreachable. Every PushDrop output that passes the synchronous field-shape gates (5 fields,field[0] === 'SHIP' | 'SLAP', valid URI,tm_/ls_prefix, valid topic/service name) admits — regardless of whether the signature is valid, valid for the claimed identity key, or even present.Verified against the shipped npm build
@bsv/overlay-discovery-services@2.0.2atdist/esm/src/SHIP/SHIPTopicManager.js:40anddist/esm/src/SLAP/SLAPTopicManager.js:40. Same bug in both.Impact
ls_shipqueries return the impostor records; GASP sync propagates them.ls_shipwith records advertising anytm_*topic at any URI, under any identity key they please.Reproducer
Patch
Trivial one-line fix in each file. Against
src/SHIP/SHIPTopicManager.ts:Same patch in
src/SLAP/SLAPTopicManager.ts.Unit test to add (pseudocode):
How this was found
Differential parity harness between
@bsv/overlay-express@2.2.0and a Rust port (github.com/Calhooon/rust-overlay, pre-release). Same BEEF submitted to both; mainline admitted, Rust rejected. Rust's rejection was traced to the signature-link check failing; I then ran the same check directly against@bsv/sdk'sProtoWalletin Node against the same inputs and got the samederived !== lockingresult — confirming the check should have rejected. RanSHIPTopicManager.identifyAdmissibleOutputsdirectly — it admits. The compileddist/esm/.../SHIPTopicManager.jsshows the missingawait.Happy to open a PR if you want.