bolt12: verify invoice_request and invoice signatures on read
What changed, and why it matters
This change makes LND's BOLT 12 invoice and invoice-request reader actually check that the digital signature is valid, instead of only checking that a signature field exists. Before, an attacker could craft or modify a BOLT 12 message and include any 64-byte placeholder as a 'signature'; the reader would accept it as long as the field was present. Now the reader verifies the signature cryptographically against the claimed sender public key, rejecting forgeries and tampered messages. This is a security-hardening fix that closes a real authentication gap.
Review any callers that previously relied on ValidateInvoiceRequestRead/ValidateInvoiceRead only for structural checks, because these functions now return ErrInvalidSignature for bad signatures. Ensure production signing keys match invoice_node_id/invreq_payer_id. Backport to release branches supporting BOLT 12. No immediate incident response is indicated unless malformed BOLT 12 messages were already accepted in the wild.
Security signals we found
Missing cryptographic verification on parsed BOLT 12 messages replaced with BIP-340 Schnorr signature verification
Reader accepted any 64-byte placeholder signature before the patch
New negative tests verify rejection of wrong-signer and tampered Merkle-root fields
Fixes a TODO that explicitly deferred signature verification to callers
BOLT 12 spec reader-side MUST for signature correctness now enforced
Evidence from the diff
The commit updates ValidateInvoiceRequestRead and ValidateInvoiceRead in bolt12/validate.go to call VerifyInvoiceRequest(ir) and VerifyInvoice(inv) respectively as their final step, replacing prior code that only required Signature.IsSome(). The docstrings and ErrMissingSignature comment are updated to reflect that the wire-string layer and reader now enforce signature validity. Tests are updated so fixtures sign with Bob’s key, and new tests reject wrong-key signatures and post-signing byte mutations. This implements the BOLT 12 reader-side MUSTs for signature verification on invoice_request (invreq_payer_id) and invoice (invoice_node_id).
Changed components
bolt12/validate.go:ValidateInvoiceRequestReadbolt12/validate.go:ValidateInvoiceReadbolt12/invoice_test.gobolt12/validate_test.goInspect captured patch +272 / −72
### bolt12/invoice_test.go
@@ -185,8 +185,14 @@ func TestInvoiceRoundTripPreservesAllTypes(t *testing.T) {
t.Parallel()
inv := validInvoice(t)
+
+ // Sign with the fixture's node id (Bob) so the read path's signature
+ // check accepts the invoice.
+ priv, _ := bobKey()
+ sig, err := SignInvoice(inv, priv)
+ require.NoError(t, err)
inv.Signature = tlv.SomeRecordT(
- tlv.NewPrimitiveRecord[tlv.TlvType240, [64]byte]([64]byte{}),
+ tlv.NewPrimitiveRecord[tlv.TlvType240](sig),
)
encoded, err := inv.Encode()
### bolt12/validate.go
@@ -132,11 +132,11 @@ var (
// structurally malformed or contains a non-alphabet byte.
ErrInvalidBip353Name = errors.New("invalid invreq_bip_353_name")
- // ErrMissingSignature is returned when a wire-form invoice or
- // invoice_request is emitted without a populated signature TLV.
- // Pre-sign Encode (used to compute the Merkle root) is permitted to run
- // without a signature; the bech32 string-codec layer is where the
- // signature becomes mandatory.
+ // ErrMissingSignature is returned when an invoice or invoice_request
+ // is encoded to its wire string or verified without a populated
+ // signature TLV. Pre-sign Encode (used to compute the Merkle root)
+ // is permitted to run without a signature; the wire-string layer is
+ // where the signature becomes mandatory.
ErrMissingSignature = errors.New("missing signature")
// ErrOfferFieldsOnSpontaneous is returned when an invoice request
@@ -401,8 +401,9 @@ func ValidateInvoiceRequestWrite(ir *InvoiceRequest) error {
// - MUST set signature.sig using the invreq_payer_id.
// NOT CHECKED HERE: signing happens after this validator runs;
- // the string encoder rejects an unsigned request and the reader
- // verifies signature correctness.
+ // pre-sign Encode is permitted, so an unsigned request passes
+ // this validator and Encode. The wire-string layer rejects an
+ // unsigned request, and the reader verifies correctness.
// - MUST set invreq_payer_id to a transient public key.
// NOT CHECKED HERE: only presence is checked below; the caller
@@ -615,11 +616,8 @@ func getInvreqChain(ir *InvoiceRequest) [32]byte {
// Stateful or contextual checks (offer matching, path verification, unit-price
// calculations) must be handled externally by the caller.
//
-// Signature verification is NOT performed yet: the reader MUST also reject a
-// request whose Schnorr signature does not verify against invreq_payer_id, but
-// that check is deferred until the merkle/signing primitives land with the
-// Invoice message (see the TODO at the end of this function). Until then, a
-// caller wiring this into a handler MUST verify the signature itself.
+// The final check is cryptographic: the reader rejects a request whose
+// BIP-340 Schnorr signature does not verify against invreq_payer_id.
func ValidateInvoiceRequestRead(ir *InvoiceRequest,
activeChain [32]byte,
knownFeatures map[lnwire.FeatureBit]string) error {
@@ -773,12 +771,7 @@ func ValidateInvoiceRequestRead(ir *InvoiceRequest,
// - MUST reject the invoice request if signature is not correct as
// detailed in Signature Calculation using the invreq_payer_id.
- // TODO(bolt12): implement signature verification.
- if !ir.Signature.IsSome() {
- return ErrMissingSignature
- }
-
- return nil
+ return VerifyInvoiceRequest(ir)
}
// getInvoiceRequestOfferChains returns the chains an invoice request's mirrored
@@ -1377,8 +1370,10 @@ func ValidateInvoiceWrite(inv *Invoice) error {
// - MUST specify exactly one signature TLV element: signature.
// - MUST set sig to the signature using invoice_node_id as described
// in Signature Calculation.
- // NOT CHECKED HERE: signing happens after this validator runs. The
- // string-codec layer rejects an unsigned invoice, mirroring
+ // NOT CHECKED HERE: signing happens after this validator runs;
+ // pre-sign Encode is permitted, so an unsigned invoice passes this
+ // validator and Encode. The wire-string layer rejects an unsigned
+ // invoice, and the reader verifies correctness, mirroring
// ValidateInvoiceRequestWrite.
// - if the expiry for accepting payment is not 7200 seconds after
@@ -1671,14 +1666,14 @@ type InvoiceFeatureCatalogues struct {
// ValidateInvoiceRead validates an invoice against the BOLT 12 reader
// requirements, running the stateless structural checks against activeChain
-// (the chain the reader supports).
+// (the chain the reader supports). The final check is cryptographic: the
+// reader rejects an invoice whose BIP-340 Schnorr signature does not verify
+// against invoice_node_id.
//
-// Note: This only performs stateless structural checks. Cryptographic Schnorr
-// signature verification and identity-path binding are deferred to the caller
-// (see the TODO at the end of this function). Additionally, while it verifies
-// that at least one usable path is present, downstream callers must re-apply
-// the same features.Blinded filter at path selection time (via
-// Invoice.UsablePaths) to avoid selecting paths with unknown required features.
+// Note: while it verifies that at least one usable path is present,
+// downstream callers must re-apply the same features.Blinded filter at path
+// selection time (via Invoice.UsablePaths) to avoid selecting paths with
+// unknown required features.
func ValidateInvoiceRead(inv *Invoice, activeChain [32]byte,
features InvoiceFeatureCatalogues) error {
// - MUST reject the invoice if invoice_amount is not present.
@@ -1815,14 +1810,6 @@ func ValidateInvoiceRead(inv *Invoice, activeChain [32]byte,
return err
}
- // - MUST reject the invoice if signature is not a valid signature using
- // invoice_node_id as described in Signature Calculation.
- // TODO(bolt12): implement signature verification. For now only
- // presence is enforced, mirroring ValidateInvoiceRequestRead.
- if !inv.Signature.IsSome() {
- return ErrMissingSignature
- }
-
// - SHOULD prefer to use earlier invoice_paths over later ones if it
// has no other reason for preference.
// - if invoice_features contains the MPP/compulsory bit: MUST pay
@@ -1841,5 +1828,7 @@ func ValidateInvoiceRead(inv *Invoice, activeChain [32]byte,
// ValidateInvoiceAgainstRequest; the fallback ignore rules by
// UsableFallbackAddresses.
- return nil
+ // - MUST reject the invoice if signature is not a valid signature using
+ // invoice_node_id as described in Signature Calculation.
+ return VerifyInvoice(inv)
}
### bolt12/validate_test.go
@@ -705,34 +705,217 @@ func addAmountAndDescription(o *Offer) {
}
// validInvoiceRequest is the spec-minimal happy-path invoice request that
-// each table row mutates to isolate the rule under test.
+// each table row mutates to isolate the rule under test. The request is
+// encoded, decoded, and signed with Bob's key, so reader validation sees
+// the same wire form a peer would send.
func validInvoiceRequest(t *testing.T) *InvoiceRequest {
t.Helper()
- ir := &InvoiceRequest{}
+ priv, _ := bobKey()
- privKey, err := btcec.NewPrivateKey()
+ return signedInvoiceRequest(t, priv)
+}
+
+// signedInvoiceRequest builds the spec-minimal invoice request, round-trips it
+// through the wire codec, and signs the decoded copy with signer.
+// invreq_payer_id always names Bob, so a signer other than Bob yields a
+// well-formed signature over the correct Merkle root under the wrong key.
+func signedInvoiceRequest(t testing.TB,
+ signer *btcec.PrivateKey) *InvoiceRequest {
+
+ t.Helper()
+
+ _, pub := bobKey()
+
+ ir := &InvoiceRequest{
+ OfferDescription: tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType10](
+ tlv.Blob("description"),
+ ),
+ ),
+ InvreqPayerID: tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType88](pub),
+ ),
+ InvreqMetadata: tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType0](
+ tlv.Blob("metadata"),
+ ),
+ ),
+ InvreqAmount: tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType82, TUint64](
+ TUint64(1000),
+ ),
+ ),
+ }
+
+ encoded, err := ir.Encode()
+ require.NoError(t, err)
+
+ decoded, err := DecodeInvoiceRequest(encoded)
require.NoError(t, err)
- ir.InvreqPayerID = tlv.SomeRecordT(
- tlv.NewPrimitiveRecord[tlv.TlvType88](privKey.PubKey()),
+ sig, err := SignInvoiceRequest(decoded, signer)
+ require.NoError(t, err)
+ decoded.Signature = tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType240](sig),
)
- ir.InvreqMetadata = tlv.SomeRecordT(
- tlv.NewPrimitiveRecord[tlv.TlvType0](
- []byte("metadata"),
- ),
+ return decoded
+}
+
+// signedInvoice signs the spec-minimal invoice with signer. invoice_node_id
+// always names Bob, so a signer other than Bob yields a well-formed signature
+// over the correct Merkle root under the wrong key.
+func signedInvoice(t *testing.T, signer *btcec.PrivateKey) *Invoice {
+ t.Helper()
+
+ inv := validInvoice(t)
+
+ sig, err := SignInvoice(inv, signer)
+ require.NoError(t, err)
+ inv.Signature = tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType240, [64]byte](sig),
)
- ir.InvreqAmount = tlv.SomeRecordT(
- tlv.NewRecordT[tlv.TlvType82, TUint64](1000),
+ return inv
+}
+
+// TestValidateInvoiceRequestRead verifies that a freshly signed, decoded
+// invoice request passes reader validation.
+func TestValidateInvoiceRequestRead(t *testing.T) {
+ t.Parallel()
+
+ ir := validInvoiceRequest(t)
+
+ err := ValidateInvoiceRequestRead(ir, bitcoinMainnetGenesisHash, nil)
+ require.NoError(t, err)
+
+ // A request without a signature must be rejected.
+ irNoSig := *ir
+ irNoSig.Signature = tlv.OptionalRecordT[tlv.TlvType240, [64]byte]{}
+ err = ValidateInvoiceRequestRead(
+ &irNoSig, bitcoinMainnetGenesisHash, nil,
)
+ require.ErrorIs(t, err, ErrMissingSignature)
+}
- ir.Signature = tlv.SomeRecordT(
- tlv.NewPrimitiveRecord[tlv.TlvType240]([64]byte{0x01}),
+// flipValueByte returns a copy of encoded with the first byte of needle
+// inverted. needle must be the value of a TLV inside the signed range, so the
+// mutation moves the Merkle root instead of a field the signature does not
+// commit to.
+//
+// The needle must occur exactly once. A second occurrence would mean the
+// caller cannot tell which field the flip lands on, and a flip that strayed
+// into the signature TLV would still produce ErrInvalidSignature while no
+// longer testing that a signed field is bound to the root.
+func flipValueByte(t *testing.T, encoded, needle []byte) []byte {
+ t.Helper()
+
+ require.Equal(
+ t, 1, bytes.Count(encoded, needle),
+ "needle must identify exactly one field",
)
- return ir
+ out := bytes.Clone(encoded)
+ out[bytes.Index(encoded, needle)] ^= 0xff
+
+ return out
+}
+
+// TestValidateReadRejectsBadSignature pins the reader-side signature gate on
+// both message types. ValidateInvoiceRequestRead and ValidateInvoiceRead key
+// the check on different public keys, so covering one does not cover the
+// other.
+//
+// The mutated-bytes rows also guard the decision to derive the Merkle root
+// from re-encoded records: a decode-then-encode divergence on a signed field
+// would surface here as a rejection of the untouched message.
+func TestValidateReadRejectsBadSignature(t *testing.T) {
+ t.Parallel()
+
+ bobPriv, _ := bobKey()
+ alicePriv, _ := aliceKey()
+
+ tests := []struct {
+ name string
+ validate func(*testing.T) error
+ }{
+ {
+ name: "invoice_request wrong key",
+ validate: func(t *testing.T) error {
+ ir := signedInvoiceRequest(t, alicePriv)
+
+ return ValidateInvoiceRequestRead(
+ ir, bitcoinMainnetGenesisHash, nil,
+ )
+ },
+ },
+ {
+ name: "invoice_request mutated after signing",
+ validate: func(t *testing.T) error {
+ encoded, err := signedInvoiceRequest(
+ t, bobPriv,
+ ).Encode()
+ require.NoError(t, err)
+
+ // invreq_metadata is a signed opaque blob, so
+ // flipping a byte of its value moves the root
+ // and still decodes.
+ ir, err := DecodeInvoiceRequest(flipValueByte(
+ t, encoded, []byte("metadata"),
+ ))
+ require.NoError(t, err)
+
+ return ValidateInvoiceRequestRead(
+ ir, bitcoinMainnetGenesisHash, nil,
+ )
+ },
+ },
+ {
+ name: "invoice wrong key",
+ validate: func(t *testing.T) error {
+ inv := signedInvoice(t, alicePriv)
+
+ return ValidateInvoiceRead(
+ inv, bitcoinMainnetGenesisHash,
+ InvoiceFeatureCatalogues{},
+ )
+ },
+ },
+ {
+ name: "invoice mutated after signing",
+ validate: func(t *testing.T) error {
+ signed := signedInvoice(t, bobPriv)
+
+ encoded, err := signed.Encode()
+ require.NoError(t, err)
+
+ // invoice_payment_hash is a signed fixed-width
+ // opaque field, so flipping a byte of its
+ // value moves the root and still decodes.
+ hash := signed.InvoicePaymentHash.ValOpt().
+ UnwrapOrFail(t)
+
+ inv, err := DecodeInvoice(flipValueByte(
+ t, encoded, hash[:],
+ ))
+ require.NoError(t, err)
+
+ return ValidateInvoiceRead(
+ inv, bitcoinMainnetGenesisHash,
+ InvoiceFeatureCatalogues{},
+ )
+ },
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ require.ErrorIs(t, tc.validate(t), ErrInvalidSignature)
+ })
+ }
}
// TestValidateInvoiceRequestWrite pins the BOLT 12 writer-side MUSTs so a
@@ -1234,6 +1417,11 @@ func TestValidateInvoiceRequestReadSentinels(t *testing.T) {
mutate func(*InvoiceRequest)
known map[lnwire.FeatureBit]string
wantErr error
+
+ // resign re-signs the mutated request before validation.
+ // Rows that mutate a signed field and still expect success
+ // need a fresh signature over the mutated records.
+ resign bool
}{
{
name: "missing payer id",
@@ -1461,6 +1649,7 @@ func TestValidateInvoiceRequestReadSentinels(t *testing.T) {
0: "test_feature",
},
wantErr: nil,
+ resign: true,
},
}
@@ -1471,10 +1660,13 @@ func TestValidateInvoiceRequestReadSentinels(t *testing.T) {
ir := validInvoiceRequest(t)
tc.mutate(ir)
- if tc.name == "known even feature bit accepted" {
+ if tc.resign {
+ priv, _ := bobKey()
+ sig, err := SignInvoiceRequest(ir, priv)
+ require.NoError(t, err)
ir.Signature = tlv.SomeRecordT(
tlv.NewPrimitiveRecord[tlv.TlvType240](
- [64]byte{0x01},
+ sig,
),
)
}
@@ -1957,7 +2149,7 @@ func TestValidateInvoiceRead(t *testing.T) {
func TestValidateInvoiceReadAcceptsSignatureRange(t *testing.T) {
t.Parallel()
- _, pub := bobKey()
+ priv, pub := bobKey()
_, intro := aliceKey()
_, blinding := bobKey()
@@ -1998,17 +2190,22 @@ func TestValidateInvoiceReadAcceptsSignatureRange(t *testing.T) {
BlindedPayInfos{Infos: []BlindedPayInfo{{}}},
),
),
- Signature: tlv.SomeRecordT(
- tlv.NewPrimitiveRecord[tlv.TlvType240, [64]byte](
- [64]byte{},
- ),
- ),
}
// An unknown odd type at 241 sits inside the signature range and must
- // be ignored, not rejected as out-of-range or unknown-even.
+ // be ignored, not rejected as out-of-range or unknown-even. It is
+ // excluded from the signature's Merkle root, so signing is unaffected
+ // by it.
inv.decodedTLVs = tlv.TypeMap{241: nil}
+ // Sign with the fixture's node id (Bob) so the read path's signature
+ // check accepts the invoice.
+ sig, err := SignInvoice(inv, priv)
+ require.NoError(t, err)
+ inv.Signature = tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType240, [64]byte](sig),
+ )
+
err = ValidateInvoiceRead(
inv, bitcoinMainnetGenesisHash,
InvoiceFeatureCatalogues{},
@@ -2587,20 +2784,24 @@ func TestValidateFeaturesWithCatalogue(t *testing.T) {
t.Parallel()
inv := validInvoice(t)
- inv.Signature = tlv.SomeRecordT(
- tlv.NewPrimitiveRecord[tlv.TlvType240](
- [64]byte{},
- ),
- )
// Set MPP required (bit 16, even/required)
fv := *lnwire.NewRawFeatureVector(lnwire.MPPRequired)
inv.InvoiceFeatures = tlv.SomeRecordT(
tlv.NewRecordT[tlv.TlvType174](fv),
)
+ // Sign with the fixture's node id (Bob) so the read
+ // path's signature check accepts the invoice.
+ priv, _ := bobKey()
+ sig, err := SignInvoice(inv, priv)
+ require.NoError(t, err)
+ inv.Signature = tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType240, [64]byte](sig),
+ )
+
// An unknown required bit must be rejected.
- err := ValidateInvoiceRead(
+ err = ValidateInvoiceRead(
inv, bitcoinMainnetGenesisHash,
InvoiceFeatureCatalogues{},
)
@@ -2623,11 +2824,6 @@ func TestValidateFeaturesWithCatalogue(t *testing.T) {
t.Parallel()
inv := validInvoice(t)
- inv.Signature = tlv.SomeRecordT(
- tlv.NewPrimitiveRecord[tlv.TlvType240](
- [64]byte{},
- ),
- )
// Set an even required feature bit on the path's features (e.g.
// bit 16).
@@ -2640,9 +2836,18 @@ func TestValidateFeaturesWithCatalogue(t *testing.T) {
}),
)
+ // Sign with the fixture's node id (Bob) so the read
+ // path's signature check accepts the invoice.
+ priv, _ := bobKey()
+ sig, err := SignInvoice(inv, priv)
+ require.NoError(t, err)
+ inv.Signature = tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType240, [64]byte](sig),
+ )
+
// If there are no known features in the catalogue, there are
// zero usable paths and we expect ErrNoUsablePaths.
- err := ValidateInvoiceRead(
+ err = ValidateInvoiceRead(
inv, bitcoinMainnetGenesisHash,
InvoiceFeatureCatalogues{},
)Why this scored 60/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.