Skip to content

feat: Verify BOLT 12 Signatures With BIP-340 Schnorr - #28

Merged
Xtrimmer merged 1 commit into
masterfrom
xtrimmer/bolt12-schnorr
Aug 5, 2026
Merged

Xtrimmer merged 1 commit into
masterfrom
xtrimmer/bolt12-schnorr

Conversation

@Xtrimmer

@Xtrimmer Xtrimmer commented Aug 5, 2026

Copy link
Copy Markdown
Owner

M8 of the BOLT 12 signed-forms plan. Depends on #27.

Problem

M7 built the merkle tree but nothing turns a root into a verdict. Both remaining BOLT 12 forms need that before they can be decoded.

While wiring it up, a latent defect surfaced. The vendored library ships:

const hashes = {
    hmacSha256: undefined,
    sha256Async: async (msg) => u8n(await subtle().digest(_sha, msg)),
    sha256: undefined,          // <-- never assigned anywhere in js/
};

Nothing in the repo ever set hashes.sha256. The BOLT 11 paths pass { prehash: false } and supply their own digest, so they never reach it — which is why #23 shipped green. Schnorr hashes internally and does reach it, and noble catches the resulting error and returns false rather than throwing. So every Schnorr verification silently failed. That failure mode is worth calling out: a signature checker that answers false unconditionally looks like working code that rejects everything.

Solution

js/bolt12sig.js:

secp256k1.hashes.sha256 = message => new Uint8Array(sha256(message));

plus xOnlyPoint (33-byte compressed → 32-byte x coordinate), verifyBolt12Signature, signedRecords, and verifySignedStream(messageName, records, signature, point) which rebuilds the root over every record except type 240, tags it, and verifies.

Verification

BIP-340's own suite is now vendored from bitcoin/bips as a sixth source, since BOLT 12 defines its signatures against it. This is much stronger than the single BOLT 12 signature the plan called for — 19 vectors, 9 valid and 10 invalid:

idx  5: public key not on the curve
idx  6: has_even_y(R) is false
idx  7: negated message
idx  8: negated s value
idx  9: sG - eP is infinite
idx 10: sG - eP is infinite
idx 11: sig[0:32] is not an X coordinate on the curve
idx 12: sig[0:32] is equal to field size
idx 13: sig[32:64] is equal to curve order
idx 14: public key is not a valid X coordinate because it exceeds the field size

All 19 match, including messages of 0, 1, 17, 32 and 100 bytes. The csv is CRLF, so the extractor splits on /\r?\n/ — otherwise every comment field keeps a trailing carriage return and the column lookup fails.

The invoice_request vector verifies against invreq_payer_id, not offer_issuer_id. My plan text said issuer id; that was wrong. Both keys are present in the stream, so the wrong-key case is asserted to fail rather than left implicit. Confirmed independently by deriving both keys from the privkeys the vector names in its comment:

Alice (0x4141...) x-only: eec7245d...  == offer_issuer_id  (type 22)
Bob   (0x4242...) x-only: 24653eac...  == invreq_payer_id  (type 88)

Also asserted: a tampered signature fails, a tampered record fails, the wrong messagename fails, the signature record is excluded from the tree it signs, a 63-byte signature is an error, and a stream containing only a signature is an error.

node --test test/schnorr.test.js  →  41 tests, 41 pass
npm test                          →  367 tests, 367 pass, 0 fail, 0 todo
npm run vectors                   →  7 suites, existing six byte-identical

No behaviour change: decoded output for all 16 valid invoices and 20 valid offers is byte-identical to master, and the page still renders 16/16 and 20/20 with the error banner intact. Setting the hook cannot regress BOLT 11 because those paths bypass it.

M9 is payer proofs, gated on 5 valid and 23 invalid vectors.

The merkle root from M7 becomes a verdict: bolt12sig.js tags the root, derives
the sighash, and verifies a 64-byte signature against a 33-byte compressed
point reduced to its x coordinate.

The vendored secp256k1 ships hashes.sha256 unset and expects one supplied. The
bolt11 paths pass prehash: false and supply their own digest, so nothing had
reached the hook before now; Schnorr hashes internally and every verification
returned false until it was wired.

BIP-340's own suite is vendored from bitcoin/bips as a sixth source, since
BOLT 12 defines its signatures against it. Nineteen vectors, nine valid and ten
invalid, covering an off-curve public key, wrong R parity, negated message and
s values, an infinite sG - eP, and field-size and curve-order boundaries. The
csv is CRLF, which would otherwise leave a carriage return on every comment.

An invoice_request is signed by invreq_payer_id. Both that key and
offer_issuer_id are present in the stream, so the wrong-key case is asserted to
fail rather than left implicit.
@Xtrimmer
Xtrimmer merged commit 7329f20 into master Aug 5, 2026
2 checks passed
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.

1 participant