Skip to content

policy: reject fake 1-of-N multisig data carriers (bpub/bitfiles) - #435

Closed
MarcanoFilms wants to merge 1 commit into
bitcoinknots:29.x-knotsfrom
MarcanoFilms:knots-reject-fakemultisig
Closed

MarcanoFilms wants to merge 1 commit into
bitcoinknots:29.x-knotsfrom
MarcanoFilms:knots-reject-fakemultisig

Conversation

@MarcanoFilms

@MarcanoFilms MarcanoFilms commented Sep 28, 2026 •

Copy link
Copy Markdown

Problem

File-storage overlays such as bitfiles (via the bpub library) embed arbitrary files on chain by hiding payload bytes inside the "public keys" of a 1-of-N CHECKMULTISIG script, wrapped in P2SH/P2WSH:

  1. A funding tx creates many dust P2WSH outputs — ordinary P2WSH in isolation.
  2. A reveal tx later spends them. Each input's witness exposes a redeem script OP_1 <pk1> ... <pk15> OP_15 OP_CHECKMULTISIG, where 14 of the 15 "public keys" are ~31 bytes of file data each and only one is a real signer.

Why existing filters miss the reveal

  • -permitbaremultisig=0 only inspects output scripts; here the multisig is a P2WSH witnessScript revealed on spend.
  • P2WSH standardness — the witnessScript (≤3600 B) and stack items are within limits.
  • Keys are on the curve — bpub brute-forces a trailing nonce byte so every fake key is a valid secp256k1 point; a key-validity filter does not catch it.
  • CScript::DatacarrierBytes() recognizes OP_RETURN, inscription envelopes, <data> OP_DROP, OLGA and OPNet — but not the bare-multisig data pattern.

Fix

AreInputsStandard now solves the executed script of every P2SH / P2WSH / P2SH-P2WSH input (via GetScriptForTransactionInput) and, when -rejectfakemultisig is set, rejects the tx as non-standard if it is a 1-of-N multisig with N ≥ MULTISIG_DATACARRIER_MIN_KEYS (3) — reason datacarrier-fakemultisig.

The check has its own option (-rejectfakemultisig, default on, reset by -corepolicy, like -rejectparasites) so it is independent of -permitbaremultisig. It runs in PreChecks (before signature validation), so it applies to relay and block-template construction. Threshold multisig (2-of-3, 3-of-5, …) and 1-of-2 scripts are untouched.

Accuracy — not a clean separator

A 1-of-N multisig lets any single one of its N keys spend, which is strictly weaker than a plain single-key output, so N ≥ 3 is a strong hint of data carriage — but not exclusive to it. Running this over the fork chain (961640–974573) rejects all known bpub reveals and ~105 spends of genuine 1-of-3…1-of-8 wallet scripts (some reused across many txs, which bpub never does). An embedder could also migrate to m ≥ 2 to dodge the m == 1 test. This is therefore best seen as the floor of a general unproven-key allowance (cf. #422), and it is complementary to the funding-side dust filter (#389). Operators who need those spends can set -rejectfakemultisig=0.

Testing

Functional test feature_datacarrier_fakemultisig.py:

Case Result
1-of-15 P2WSH (real bpub shape) rejected datacarrier-fakemultisig
1-of-3 P2WSH (threshold boundary) rejected
1-of-15 P2SH-P2WSH rejected
1-of-2 P2WSH not flagged
2-of-3, 3-of-5 threshold multisig not flagged

Also verified against the real witnessScript of an observed reveal tx (be667d76425d48df0ea891176fd8ef0a3a4329775cecc5afaf4558500c9e84e9).

Docs

@MarcanoFilms
MarcanoFilms force-pushed the knots-reject-fakemultisig branch 2 times, most recently from 2f0290a to ae19d19 Compare September 28, 2026 20:28
@jasonsopko

Copy link
Copy Markdown

The shape test rejects live wallets, and the encoder gets past it with one more signature, so I would fold this into #422 rather than take it as a rule of its own.

I ran ae19d19 over every transaction since the fork, 961640 to 974573 (3,103,397 transactions). It rejects all 19 bpub reveals: the 14 through 974259 and five more today at 974541 to 974573, 1,793 inputs and 1.13 MB between them. It also rejects 105 other transactions: 64 spend a 1-of-3, 33 a 1-of-4, 4 a 1-of-5, 3 a 1-of-6 and 1 a 1-of-8. Those are wallets, not data. One P2WSH 1-of-3 script has been spent in 12 transactions from 961852 (4d11a45481bb35be47b39d44ce5000fb5f4c2687e4f8145efe32e6c063b2d969) to 974315 (9ca0432bcca0bac3feed8f72b8bca10c462a12340def9bb144d44e228407fcb9), and two 1-of-4 scripts in 6 and 5 transactions across the same span, one or two inputs at a time. bpub never reuses a script. For comparison, #422 as posted rejects 507 of the same set, and 3 with a per-script allowance of 8 spare keys.

The rule is a subset of #422. An encoder that moves to 2-of-15 gives up one data key per input and adds one signature, and this check no longer sees it, while #422 still counts the 13 unproven keys. Could the m == 1 shape be the floor under #422's allowance instead of a separate check?

Two smaller things:

  • It rides on -permitbaremultisig, so an operator who allows bare multisig outputs loses this too, and the option changes meaning. Could it have its own option, default on and reset by -corepolicy, like -rejectparasites?
  • The description says the funding transactions cannot be filtered. policy: Count data in output hashes and keys #389 rejects the funding side of all 19: each is a run of hundreds of P2WSH outputs at 546 sat, for example f7634f94cbe0013561b82ddefe27b04a8f6072e9f722be59126c1cbc45bfac70 funding be667d76425d48df0ea891176fd8ef0a3a4329775cecc5afaf4558500c9e84e9. The two are complementary.

feature_datacarrier_fakemultisig.py passes here when run through the interpreter. The file is added as 644 with a shebang, so lint-files.py fails and the script is not runnable on its own; chmod 755 fixes it.

Spends that reveal a 1-of-N CHECKMULTISIG (N >= MULTISIG_DATACARRIER_MIN_KEYS)
wrapped in P2SH/P2WSH are a common on-chain shape for file-embedding overlays
(bitfiles/bpub): 14 of 15 'pubkeys' are payload, only one signs. AreInputsStandard
solves the executed script of every P2SH/P2WSH/P2SH-P2WSH input and rejects this
shape as datacarrier-fakemultisig, in PreChecks, so it covers relay and block
templates.

Gated by its own -rejectfakemultisig option (default on, reset by -corepolicy),
independent of -permitbaremultisig. Threshold multisig (m >= 2) and 1-of-2 are
untouched.

The m == 1 shape is a strong but not exclusive signal: a survey of the fork
chain rejects all known bpub reveals plus a small number of genuine 1-of-N
wallet spends, and an embedder could migrate to m >= 2. Documented as
relay/mempool policy (not consensus), complementary to the funding-side dust
filter, and reconcilable with a general unproven-key allowance.
@MarcanoFilms
MarcanoFilms force-pushed the knots-reject-fakemultisig branch from ae19d19 to 7d176d0 Compare September 30, 2026 06:25
@MarcanoFilms

Copy link
Copy Markdown
Author

Thanks for running it over the whole chain — that data is exactly what this needed, and it changes my framing.

You're right that it's not false-positive-free. The 105 genuine 1-of-3…1-of-8 spends (some scripts reused across many txs, which bpub never does) disprove the claim in my description. I've dropped that wording; the m == 1, n >= 3 shape is a strong signal, not a clean separator.

On folding into #422: yes — I agree the m == 1 shape is best understood as the floor of #422's unproven-key allowance rather than a rule of its own. #422 as posted already generalises it (and doesn't lose the 2-of-15 migration), so if you'd rather carry the floor there I'm happy to close this in favour of it. What I think is worth preserving from here is the wrapped-input path (GetScriptForTransactionInput over P2SH/P2WSH/P2SH-P2WSH reveals) and the functional test.

Meanwhile I've addressed the smaller points so this branch stands on its own if useful:

  • Own option: it no longer rides on -permitbaremultisig. New -rejectfakemultisig, default on, reset by -corepolicy, matching -rejectparasites.
  • chmod 755 on the test — lint-files.py passes now.
  • Funding side: you're right, policy: Count data in output hashes and keys #389 rejects the funding runs; I've corrected the description to call them complementary rather than unfilterable.

Rebuilt and the functional test passes. Let me know if you'd prefer the floor to live in #422 and I'll close this.

@dev1ns

This comment was marked as abuse.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants