Skip to content

fix(vm): accept DER signature length boundaries - #165

Open
dagurval wants to merge 2 commits into
bitauth:nextfrom
dagurval:der-sig
Open

dagurval wants to merge 2 commits into
bitauth:nextfrom
dagurval:der-sig

Conversation

@dagurval

Copy link
Copy Markdown

@changeset-bot

changeset-bot Bot commented Sep 16, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: dbd73ba

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@bitauth/libauth Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@mr-zwets

Copy link
Copy Markdown
Contributor

On behalf of Mathieu G. (mr-zwets), written by Claude Opus 5.5:

Independent confirmation that this boundary affects consensus, not just error codes. Test done against @bitauth/libauth@3.1.0-next.8 and BCHN v29.1.0 (regtest, upgrade 12 active).

8 bytes: changes accept/reject. 30 06 02 01 01 02 01 01 (r = 1, s = 1) is a valid low-S ECDSA signature for a public key recovered from it, Q = r⁻¹(sR − zG). Nobody knows that key's private key, but nothing in consensus requires one. It works:

  • via OP_CHECKDATASIG, where z = SHA-256(message);
  • via OP_CHECKSIG, with the public key supplied by the unlocking script, so it is not covered by the scriptCode.
case libauth 3.1.0-next.8 BCHN v29.1.0
<Q> OP_CHECKDATASIG, unlocking <3006020101020101> <msg> reject: Encountered an improperly encoded signature testmempoolaccept allowed, mined
bare OP_CHECKSIG, unlocking <300602010102010141> <Q> reject: Encountered an improperly encoded signature allowed, mined
same two with a 9-byte DER signature (s = 00 80) as control accept allowed, mined

With this PR's change applied to a local copy of the 3.1.0-next.8 build, all four accept. The BCH 2026 VMB vectors shipped with BCHN (29,731 vectors, consensus mode) still all pass.

72 bytes: only the error differs. A DER signature can only reach 72 bytes with two 33-byte integers. A 33-byte S is ≥ 2^255 > n/2, so it fails LOW_S, which is a mandatory consensus flag. BCHN then fails with SIG_HIGH_S and libauth fails the encoding check. A longer integer encodes a value ≥ n, which fails verification on both sides and then NULLFAIL. So the upper bound changes only which error is reported, never the verdict. The lower bound is the one that changes verdicts.

An honest signer with a fixed key cannot produce r < 128 (about 2^-249 per signature). The difference is reachable only with an attacker-chosen key, for example a contract that takes the public key from unlocking data. A libauth-based validator, wallet or indexer then rejects a transaction the network accepts.

Reproduction script (Node, only @bitauth/libauth@3.1.0-next.8):

import { createVirtualMachineBch2026, encodeDataPush, flattenBinArray, generateSigningSerializationBch,
  hexToBin, secp256k1, sha256 } from '@bitauth/libauth';

const vm = createVirtualMachineBch2026(false); // consensus mode
const b = (...x) => Uint8Array.of(...x);
const cat = (...x) => flattenBinArray(x);
const be32 = (n) => hexToBin(n.toString(16).padStart(64, '0'));
// Public key for which (r, s) is a valid ECDSA signature of `digest` (key recovery; r = 1 is a valid x-coordinate).
const keyFor = (digest, r, s) => secp256k1.recoverPublicKeyCompressed(cat(be32(r), be32(s)), 0, digest);
const der8 = hexToBin('3006020101020101');   // r = 1, s = 1
const der9 = hexToBin('300702010102020080'); // r = 1, s = 0x80 (control)
const p2pkh = b(0x76, 0xa9, 0x14, ...new Uint8Array(20), 0x88, 0xac);

const verify = (locking, makeUnlocking) => {
  const sourceOutputs = [{ lockingBytecode: locking, valueSatoshis: 100_000n }];
  const transaction = { version: 2, locktime: 0,
    inputs: [{ outpointTransactionHash: hexToBin('11'.repeat(32)), outpointIndex: 0, sequenceNumber: 0xffffffff, unlockingBytecode: b() }],
    outputs: [{ lockingBytecode: p2pkh, valueSatoshis: 99_000n }] };
  transaction.inputs[0].unlockingBytecode = makeUnlocking(transaction, sourceOutputs);
  const result = vm.verify({ sourceOutputs, transaction });
  return result === true ? 'accept' : `reject (${result})`;
};

const msg = new TextEncoder().encode('oracle message');
for (const [name, der, s] of [['8-byte', der8, 1n], ['9-byte', der9, 0x80n]]) {
  // OP_CHECKDATASIG: <pubkey> OP_CHECKDATASIG, unlocked by <sig> <msg>
  const q = keyFor(sha256.hash(msg), 1n, s);
  console.log(`${name} OP_CHECKDATASIG:`, verify(cat(encodeDataPush(q), b(0xba)), () => cat(encodeDataPush(der), encodeDataPush(msg))));
  // OP_CHECKSIG: bare OP_CHECKSIG, unlocked by <sig+ALL|FORKID> <pubkey>
  console.log(`${name} OP_CHECKSIG:    `, verify(b(0xac), (transaction, sourceOutputs) => {
    const ser = generateSigningSerializationBch({ inputIndex: 0, sourceOutputs, transaction },
      { coveredBytecode: b(0xac), signingSerializationType: b(0x41) }, sha256);
    const qs = keyFor(sha256.hash(sha256.hash(ser)), 1n, s);
    return cat(encodeDataPush(cat(der, b(0x41))), encodeDataPush(qs));
  }));
}
8-byte OP_CHECKDATASIG: reject (Unable to verify transaction: error in evaluating input index 0: Encountered an improperly encoded signature. Data signature: 3006020101020101)
8-byte OP_CHECKSIG:     reject (Unable to verify transaction: error in evaluating input index 0: Encountered an improperly encoded signature. Transaction signature (including signing serialization): 300602010102010141)
9-byte OP_CHECKDATASIG: accept
9-byte OP_CHECKSIG:     accept

The same scripts and signature construction, spending real outputs on a BCHN v29.1.0 regtest node: all four pass testmempoolaccept and were mined. A VMB vector with the 8-byte OP_CHECKDATASIG case would guard the fix at the consensus level, alongside the unit test.

This branch has not been deployed

No deployments
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.

2 participants

⌂ Proxy