bolt12: add ErrNilPublicKey and type the offer_issuer_id guard
What changed, and why it matters
This commit hardens LND's BOLT 12 offer handling by replacing a one-off panic-prevention check with a reusable, typed error. A malformed offer where a public-key field is technically 'present' but actually contains a nil pointer would previously crash during encoding; now it is cleanly rejected with a detectable error. The change is defensive and improves code quality, but it does not appear to fix an actively exploitable remote crash on its own because the nil path was already guarded on the write path and is now also guarded on the read path.
Treat as a low-risk hardening commit. Review whether other public-key TLV fields (e.g., in invoice_request) are also guarded by checkPubKeyNotNil, as the commit message suggests that is the intended follow-up. No urgent security response appears required.
Security signals we found
panic-prevention for nil public-key TLV encoding
typed sentinel error improves caller recoverability
reader-side guard added where only writer-side guard existed before
BOLT 12 offer validation hardening
Evidence from the diff
The patch introduces a new sentinel error ErrNilPublicKey in bolt12/validate.go and a helper checkPubKeyNotNil that wraps fn.MapOptionZ. It applies this helper to both ValidateOfferRead and ValidateOfferWrite for the offer_issuer_id field. Previously, ValidateOfferWrite contained an inline nil-check that returned fmt.Errorf(“nil issuer public key”); that ad-hoc error is replaced by the typed sentinel. ValidateOfferRead gains the same guard, so a decoded offer with a present-but-nil offer_issuer_id is rejected before encoding rather than potentially panicking in SerializeCompressed. Tests are added for both read and write paths.
Changed components
bolt12/validate.gobolt12/validate_test.goValidateOfferReadValidateOfferWriteoffer_issuer_id handlingInspect captured patch +56 / −15
diff --git a/bolt12/validate.go b/bolt12/validate.go
index d6473b6..8803d95 100644
--- a/bolt12/validate.go
+++ b/bolt12/validate.go
@@ -30,6 +30,10 @@ var (
// bit is set.
ErrUnknownEvenFeature = errors.New("unknown even feature bit set")
+ // ErrNilPublicKey is returned when a public-key TLV is present but
+ // wraps a nil pointer.
+ ErrNilPublicKey = errors.New("public key present but nil")
+
// ErrMissingDescription is returned when offer_amount is set but
// offer_description is absent.
ErrMissingDescription = errors.New(
@@ -110,6 +114,14 @@ func isKnownOfferTLVType(typ tlv.Type) bool {
// list a chain it operates on. Pass the genesis hash of the chain the receiver
// is willing to settle on.
func ValidateOfferRead(o *Offer, now time.Time, activeChain [32]byte) error {
+ // A present-but-nil offer_issuer_id passes IsSome but would panic the
+ // codec on encode, so reject it here.
+ if err := checkPubKeyNotNil(
+ o.OfferIssuerID, "offer_issuer_id",
+ ); err != nil {
+ return err
+ }
+
// Check TLV types are in allowed range and that unknown even types are
// rejected (even = must-understand).
for _, t := range sortedTypes(o.decodedTLVs) {
@@ -236,6 +248,14 @@ func getOfferChains(o *Offer) [][32]byte {
// ValidateOfferWrite validates an offer per the BOLT 12 offer writer
// requirements.
func ValidateOfferWrite(o *Offer) error {
+ // A present-but-nil offer_issuer_id passes IsSome but would panic the
+ // codec on encode, so reject it here.
+ if err := checkPubKeyNotNil(
+ o.OfferIssuerID, "offer_issuer_id",
+ ); err != nil {
+ return err
+ }
+
// Writer MUST NOT set TLV fields outside allowed ranges. This check
// catches a decoded-then-mutated offer: a freshly-built struct has no
// decodedTLVs (Decode is the only writer of that field). The typed
@@ -268,21 +288,6 @@ func ValidateOfferWrite(o *Offer) error {
return ErrNoIssuerIdentity
}
- // A present-but-nil offer_issuer_id would panic in SerializeCompressed
- // at encode time, so reject it here.
- if err := fn.MapOptionZ(
- o.OfferIssuerID.ValOpt(),
- func(pk *btcec.PublicKey) error {
- if pk == nil {
- return fmt.Errorf("nil issuer public key")
- }
-
- return nil
- },
- ); err != nil {
- return err
- }
-
// Defense in depth: writer-side mirrors of reader rejections for
// present-but-empty offer_chains and offer_paths.
var chainsEmpty bool
@@ -411,3 +416,16 @@ func checkUTF8[T tlv.TlvType](opt tlv.OptionalRecordT[T, tlv.Blob],
return nil
})
}
+
+// checkPubKeyNotNil returns an error if a public key TLV is present but nil.
+func checkPubKeyNotNil[T tlv.TlvType](
+ opt tlv.OptionalRecordT[T, *btcec.PublicKey], name string) error {
+
+ return fn.MapOptionZ(opt.ValOpt(), func(pk *btcec.PublicKey) error {
+ if pk == nil {
+ return fmt.Errorf("%w: %s", ErrNilPublicKey, name)
+ }
+
+ return nil
+ })
+}
diff --git a/bolt12/validate_test.go b/bolt12/validate_test.go
index 2bf476d..5e907bc 100644
--- a/bolt12/validate_test.go
+++ b/bolt12/validate_test.go
@@ -85,6 +85,17 @@ func TestValidateOfferWrite(t *testing.T) {
},
wantErr: ErrNoIssuerIdentity,
},
+ {
+ name: "present-but-nil issuer_id",
+ mutate: func(o *Offer) {
+ o.OfferIssuerID = tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType22](
+ (*btcec.PublicKey)(nil),
+ ),
+ )
+ },
+ wantErr: ErrNilPublicKey,
+ },
{
name: "empty offer_chains",
mutate: func(o *Offer) {
@@ -238,6 +249,18 @@ func TestValidateOfferRead(t *testing.T) {
mutate: func(*Offer) {},
activeChain: bitcoinMainnetGenesisHash,
},
+ {
+ name: "present-but-nil offer_issuer_id",
+ mutate: func(o *Offer) {
+ o.OfferIssuerID = tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType22](
+ (*btcec.PublicKey)(nil),
+ ),
+ )
+ },
+ activeChain: bitcoinMainnetGenesisHash,
+ wantErr: ErrNilPublicKey,
+ },
{
name: "out-of-range TLV in decoded extras",
mutate: func(o *Offer) {
Why this scored 37/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.