Skip to content

SHIP / SLAP identifyAdmissibleOutputs skips signature verification (missing await) #68

Description

@Calgooon

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions