Skip to content

bolt12: add string-codec wrappers and fuzz harnesses - #11146

Merged
yyforyongyu merged 4 commits into
lightningnetwork:masterfrom
bitromortac:2604-bolt12-1g
Sep 21, 2026
Merged

yyforyongyu merged 4 commits into
lightningnetwork:masterfrom
bitromortac:2604-bolt12-1g

Conversation

@bitromortac

@bitromortac bitromortac commented Aug 31, 2026 •

Copy link
Copy Markdown
Collaborator

Based on #11061, part of #10736.

Adds Decode/EncodeOfferString, Decode/EncodeInvoiceRequestString, and Decode/EncodeInvoiceString, composing bech32, the per-message TLV codec, and the spec reader gates into one call as the package's consumer entry point. Gates run against Bolt12Features, defined here. Wire-form encodes require and verify a populated signature, while pre-sign Encode stays permitted on (*X).Encode for Merkle-root computation.

We add a ValidateInvoiceForPayment method that is a validator that should be used to validate a received invoice against a sent invoice request.

Also adds fuzz harnesses for the codec decoders and wires bolt12 into make fuzz. Byte-level targets pin no-panic decode and byte-identical round-trips, string-level targets pin no-panic through the reader gates, and two more pin the bech32 bijection and Merkle-root determinism. Seeds come from the spec test vectors.

@github-actions github-actions Bot added the severity-high Requires knowledgeable engineer review label Aug 31, 2026
@github-actions

Copy link
Copy Markdown

🟠 PR Severity: HIGH

gh pr view | 20 files | 2880 lines changed

🟡 Medium (10 files, bumped to HIGH)
  • bolt12/doc.go - package doc update for new BOLT12 package
  • bolt12/invoice.go - BOLT12 invoice encode/decode logic
  • bolt12/invoice_request.go - BOLT12 invoice-request encode/decode logic
  • bolt12/merkle.go - new Merkle-root computation for BOLT12 messages
  • bolt12/offer.go - BOLT12 offer encode/decode logic
  • bolt12/signature.go - new BIP-340 signature creation/verification for BOLT12
  • bolt12/subtypes.go - BOLT12 TLV subtype definitions
  • bolt12/validate.go - BOLT12 message validation gates/features
  • docs/release-notes/release-notes-0.22.0.md - release notes
  • make/fuzz_flags.mk - wires bolt12 into fuzz build
🟢 Low (10 files, tests/fuzz/vectors, excluded from bump calculation)
  • bolt12/decode_test.go, bolt12/fuzz_test.go, bolt12/helpers_test.go, bolt12/invoice_request_test.go, bolt12/invoice_test.go, bolt12/merkle_test.go, bolt12/offer_test.go, bolt12/signature_test.go, bolt12/validate_test.go, bolt12/test-vectors/signature-test.json

Analysis

The bolt12/* package isn't explicitly listed in the severity tiers, so it was treated as "other Go files" (MEDIUM baseline). However, non-test/non-generated changes total ~639 lines across 10 files (new merkle.go and signature.go implement Merkle-root computation and BIP-340 signature creation/verification for BOLT12 offers/invoices — cryptographic and consensus-relevant logic), exceeding the 500-line bump threshold. Severity is therefore bumped one level to HIGH. This package underlies BOLT12 wire-form encode/decode and signature verification, so correctness here has security implications similar to lnwire.


To override, add a severity-override-{critical,high,medium,low} label.

@ViktorT-11 ViktorT-11 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did a first pass of this PR, and in general I think this looks great. I don't have that much useful feedback to provide, and think this should be pretty much ready to go once its dependant PR has been merged.

Potentially the fuzzing commit could be broken out into a separate PR as well.

Comment thread bolt12/validate_test.go Outdated
Comment on lines +988 to +1006
// The gap this closes: a validly Alice-signed invoice passes the
// reader, so only validateInvoiceNodeID separates it from a genuine
// one.
inv := validInvoice(t)
inv.InvoiceNodeID = tlv.SomeRecordT(
tlv.NewPrimitiveRecord[tlv.TlvType176](alicePub),
)
sig, err := SignInvoice(inv, alicePriv)
require.NoError(t, err)
inv.Signature = tlv.SomeRecordT(
tlv.NewPrimitiveRecord[tlv.TlvType240](sig),
)

ir.InvreqPayerID = tlv.SomeRecordT(
tlv.NewPrimitiveRecord[tlv.TlvType88](privKey.PubKey()),
require.NoError(t, ValidateInvoiceRead(
inv, bitcoinMainnetGenesisHash, InvoiceFeatureCatalogues{},
))
require.ErrorIs(
t, validateInvoiceNodeID(inv, bobPub),
ErrInvoicePathNodeIDMismatch,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this case here is better suited for the TestValidateInvoiceForPayment test. However, as this already covered in that test to some extent, it's IMO more confusing to include it here than to just remove this part from the TestValidateInvoiceNodeID test.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I shifted it into TestValidateInvoiceForPayment

Comment thread bolt12/invoice.go
// representation (lni1...). The spec reader gates (chain, features, signature)
// are folded in via ValidateInvoiceRead, and the expiry gate is enforced via
// ValidateInvoiceExpiry.
func DecodeInvoiceString(s string, now time.Time,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Building on top of the commit prior to this one: Could it potentially also be useful to also export a "DecodeInvoiceStringForPayment" function that decodes an invoice string, but which additionally also takes an invoice request string param, and then calls the ValidateInvoiceForPayment function?

Given that this exported function will be vulnerable to the issue described in the previous commit, in case it's the response from an invoice request.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would postpone this until we have specific call sites. This helper is only intended for invoices that have been received out of band (which may not have an invoice request?). The usual bolt12 branch only receives an invoice embedded in an onion message in tlv-byte form, which then is decoded and validated via ValidateInvoiceForPayment, which could be called after this. But I see the problem that this could be forgotten.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gotcha :)! Yeah I think what makes it harder for me to determine what's useful and not is that at the state of this PR the ValidateInvoiceForPayment function is never used outside of tests :).

@GeorgeTsagk GeorgeTsagk left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR description does not mention the ValidateInvoiceForPayment commit. Worth adding a line since it is the new exported payer entry point.

Comment thread bolt12/validate.go
return VerifyInvoice(inv)
}

// ValidateInvoiceForPayment runs the full set of payer-side invoice checks in

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This runs everything except the expiry gate, so the full set claim is off. ValidateInvoiceRead skips expiry by design and only DecodeInvoiceString folds ValidateInvoiceExpiry in. In the main payment flow invoices arrive as raw TLV over onion messages, not as lni strings, so a caller trusting this composite never hits the expiry MUST and can pay an expired invoice. Same argument as for the path binding: take a now time.Time and fold ValidateInvoiceExpiry in so the caller cannot forget it.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, good call, added that.

Comment thread bolt12/validate.go Outdated
return nil
}

return validateInvoiceNodeID(inv, finalBlindedNodeID)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doc suggestion only, behavior is fine. An offerless request lands here too, where the spec says MAY reject if invoice_node_id cannot be confirmed out-of-band. Rejecting fail-closed is stricter than the spec and that is good, but the doc and param name only speak of blinded paths. Worth a sentence that an offerless caller passes the node id it confirmed out-of-band.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's true, I reworded the parameter to be a bit more neutral and updated the function's docstring to note what one should pass in.

@github-actions github-actions Bot added severity-medium Focused review required and removed severity-high Requires knowledgeable engineer review labels Sep 10, 2026
@github-actions

Copy link
Copy Markdown

⚠️ Severity changed: HIGH → MEDIUM (files changed since last classification)

🟡 PR Severity: MEDIUM

gh pr view | 13 files | 1225 lines changed

🟡 Medium (5 files)
  • bolt12/doc.go - package doc update, not a categorized package
  • bolt12/invoice.go - BOLT12 invoice encode/decode logic (uncategorized package, treated as other Go file)
  • bolt12/invoice_request.go - BOLT12 invoice-request encode/decode logic
  • bolt12/offer.go - BOLT12 offer encode/decode logic
  • bolt12/validate.go - BOLT12 message validation gates/features
🟢 Low (8 files)
  • docs/release-notes/release-notes-0.22.0.md - release notes
  • make/fuzz_flags.mk - build config for fuzz targets
  • bolt12/fuzz_test.go, bolt12/helpers_test.go, bolt12/invoice_request_test.go, bolt12/invoice_test.go, bolt12/offer_test.go, bolt12/validate_test.go - test-only changes

Analysis

The bolt12/* package is not explicitly listed in any severity tier, so it falls back to "other Go files not categorized above" (MEDIUM baseline). Excluding test files, docs, and build config, the non-test Go changes total ~248 lines across 5 files (doc.go, invoice.go, invoice_request.go, offer.go, validate.go) — well under the 20-file / 500-line bump thresholds, and no critical packages are touched. This PR no longer includes the merkle.go and signature.go files (Merkle-root computation and BIP-340 signature logic) that drove the prior HIGH classification, so severity drops back to MEDIUM.


To override, add a severity-override-{critical,high,medium,low} label.

@GeorgeTsagk GeorgeTsagk left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@ViktorT-11 ViktorT-11 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice, LGTM 🔥

Comment thread bolt12/validate_test.go Outdated
Comment on lines +991 to +993
// cannot forget any of them. expectedNodeID is only consulted when
// offer_issuer_id is absent. Otherwise the expected signer is derived from
// the request.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: The doc mentions: "expectedNodeID is only consulted when offer_issuer_id is absent.", but this function then tests that it's still consulted after this comment in the test:

// The check is unconditional, so a wrong expectation is caught even 
// though offer_issuer_id is present and the readers already bound it.

So I think this is a stale comment which should be removed?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Improved the comments around this.

Comment thread bolt12/validate_test.go
// cannot forget any of them. expectedNodeID is only consulted when
// offer_issuer_id is absent. Otherwise the expected signer is derived from
// the request.
func TestValidateInvoiceForPayment(t *testing.T) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Could also include test coverage for when there's a missmatch in the amount between the invoice and the invoice request, hence failing the ValidateInvoiceAgainstRequest part of the ValidateInvoiceForPayment function.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea, addressed!

Comment thread bolt12/invoice.go
// representation (lni1...). The spec reader gates (chain, features, signature)
// are folded in via ValidateInvoiceRead, and the expiry gate is enforced via
// ValidateInvoiceExpiry.
func DecodeInvoiceString(s string, now time.Time,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gotcha :)! Yeah I think what makes it harder for me to determine what's useful and not is that at the state of this PR the ValidateInvoiceForPayment function is never used outside of tests :).

Bundle the payer-side invoice checks into one call so the sender flow
cannot forget any of them: the reader gates, the expiry gate, the
byte-for-byte mirror match against the originating request, and the
binding of invoice_node_id to the node the payer expected to answer.
Decode/EncodeOfferString, Decode/EncodeInvoiceRequestString, and
Decode/EncodeInvoiceString compose bech32, the per-message TLV codec,
and the previously-landed validators into a single reader/writer API
that consumers should use. Reader gates run against Bolt12Features, the
package's known feature-bit set, defined in this commit. The string
codecs that emit a wire-form message also gate Signature validity, since
pre-sign Encode (used to compute the Merkle root) is permitted on
(*X).Encode but not at the bech32 boundary.
The bolt12 codec parses bytes that arrive untrusted inside onion
messages, so every decoder gets a property-based check that no input
pattern drives a panic or breaks the encode/decode bijection. A
further harness asserts merkleRoot is a pure function of its record
set, since non-determinism there would silently break signature
reproducibility. Wiring bolt12 into FUZZPKG makes 'make fuzz'
exercise the package alongside the existing entries.
Add changes for the string-codec wrappers, the combined payment
validator, and the fuzz harnesses in bolt12.
@yyforyongyu
yyforyongyu merged commit f02cf4a into lightningnetwork:master Sep 21, 2026
40 of 46 checks passed
@github-project-automation github-project-automation Bot moved this from Backlog to Done in lnd v0.22 Sep 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

severity-medium Focused review required

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

5 participants