lnwire: generalize pure TLV signed-range filtering
What changed, and why it matters
This commit is a code cleanup and generalization in LND's TLV (Type-Length-Value) message handling. It introduces flexible helper functions so that future protocols like BOLT 12 can define which message fields are covered by a digital signature, instead of hardcoding only the BOLT 7 v2 rules. The existing behavior is preserved by default. There is no direct security fix or vulnerability patch in this change.
No immediate security action required. Treat as normal code maintenance. Reviewers should ensure callers using the new Fn variants supply correct predicates to avoid accidental signature-coverage mistakes in future protocols.
Security signals we found
Refactors signature-coverage boundary logic to be configurable per protocol
Adds tests demonstrating BOLT 12-style unsigned range handling
No removal of bounds checks, no change to default signing behavior
No mention of vulnerability, CVE, bug, or security issue in commit message
Evidence from the diff
The change refactors lnwire/pure_tlv.go to add predicate-based variants SerialiseFieldsToSignFn and ExtraSignedFieldsFromTypeMapFn, accepting an UnsignedRangeFunc. The original SerialiseFieldsToSign and ExtraSignedFieldsFromTypeMap now delegate to these with the existing InUnsignedRange predicate, preserving BOLT 7 v2 behavior. Tests are added covering a BOLT 12-style unsigned range (240-1000). This is architectural groundwork, not a bug fix.
Changed components
lnwire/pure_tlv.golnwire/pure_tlv_test.goInspect captured patch +136 / −26
diff --git a/lnwire/pure_tlv.go b/lnwire/pure_tlv.go
index 8e6f7bd..6692ac2 100644
--- a/lnwire/pure_tlv.go
+++ b/lnwire/pure_tlv.go
@@ -23,12 +23,12 @@ const (
)
// PureTLVMessage describes an LN message that is a pure TLV stream. If the
-// message includes a signature, it will sign all the TLV records in the
-// inclusive ranges: 0 to 159 and 1000000000 to 2999999999.
+// message includes a signature, the signature covers a subset of the records,
+// which subset is determined by the protocol's signed/unsigned range (see
+// SerialiseFieldsToSignFn).
type PureTLVMessage interface {
- // AllRecords returns all the TLV records for the message. This will
- // include all the records we know about along with any that we don't
- // know about but that fall in the signed TLV range.
+ // AllRecords returns all the TLV records for the message, including
+ // both records we know about and unknown records that we preserve.
AllRecords() []tlv.Record
}
@@ -37,13 +37,27 @@ func EncodePureTLVMessage(msg PureTLVMessage, buf *bytes.Buffer) error {
return EncodeRecordsTo(buf, msg.AllRecords())
}
+// UnsignedRangeFunc returns true when a TLV type is in the unsigned range of a
+// pure-TLV message (i.e., excluded from the signature). Each protocol supplies
+// its own predicate to encode the boundary between signed and unsigned types.
+type UnsignedRangeFunc func(tlv.Type) bool
+
// SerialiseFieldsToSign serialises all the records from the given
-// PureTLVMessage that fall within the signed TLV range.
+// PureTLVMessage that fall within the BOLT 7 v2 signed TLV range. Use
+// SerialiseFieldsToSignFn for a protocol with a different boundary.
func SerialiseFieldsToSign(msg PureTLVMessage) ([]byte, error) {
- // Filter out all the fields not in the signed ranges.
+ return SerialiseFieldsToSignFn(msg, InUnsignedRange)
+}
+
+// SerialiseFieldsToSignFn serialises all the records from the given
+// PureTLVMessage that the supplied predicate keeps in the signed range. A type
+// for which isUnsigned returns true is excluded from the digest.
+func SerialiseFieldsToSignFn(msg PureTLVMessage,
+ isUnsigned UnsignedRangeFunc) ([]byte, error) {
+
var signedRecords []tlv.Record
for _, record := range msg.AllRecords() {
- if InUnsignedRange(record.Type()) {
+ if isUnsigned(record.Type()) {
continue
}
@@ -58,8 +72,9 @@ func SerialiseFieldsToSign(msg PureTLVMessage) ([]byte, error) {
return buf.Bytes(), nil
}
-// InUnsignedRange returns true if the given TLV type falls outside the TLV
-// ranges that the signature of a pure TLV message will cover.
+// InUnsignedRange is the BOLT 7 v2 UnsignedRangeFunc: it returns true for types
+// in 160-999_999_999 or 3_000_000_000+, which sit outside the BOLT 7 v2 signed
+// ranges (0-159 and 1_000_000_000-2_999_999_999).
func InUnsignedRange(t tlv.Type) bool {
return (t >= pureTLVUnsignedRangeOneStart &&
t < pureTLVSignedSecondRangeStart) ||
@@ -72,32 +87,41 @@ func InUnsignedRange(t tlv.Type) bool {
// for re-composing the wire message since the signature covers these fields.
type ExtraSignedFields map[uint64][]byte
-// ExtraSignedFieldsFromTypeMap is a helper that can be used alongside calls to
-// the tlv.Stream DecodeWithParsedTypesP2P or DecodeWithParsedTypes methods to
-// extract the tlv type and value pairs in the defined PureTLVMessage signed
-// range which we have not handled with any of our defined Records. These
-// methods will return a tlv.TypeMap containing the records that were extracted
-// from an io.Reader. If the record was know and handled by a defined record,
-// then the value accompanying the record's type in the map will be nil.
-// Otherwise, if the record was unhandled, it will be non-nil.
+// ExtraSignedFieldsFromTypeMap returns the unhandled signed-range entries from
+// a tlv.TypeMap (as returned by DecodeWithParsedTypes(P2P)) so the caller can
+// re-emit them and keep the message signature valid. It uses the BOLT 7 v2
+// signed range; use ExtraSignedFieldsFromTypeMapFn for a different boundary.
func ExtraSignedFieldsFromTypeMap(m tlv.TypeMap) ExtraSignedFields {
+ return ExtraSignedFieldsFromTypeMapFn(m, InUnsignedRange)
+}
+
+// ExtraSignedFieldsFromTypeMapFn returns the unhandled entries from a
+// tlv.TypeMap that the supplied predicate keeps in the signed range, so the
+// caller can re-emit them and keep the message signature valid. Entries for
+// which isUnsigned returns true are dropped.
+func ExtraSignedFieldsFromTypeMapFn(m tlv.TypeMap,
+ isUnsigned UnsignedRangeFunc) ExtraSignedFields {
+
extraFields := make(ExtraSignedFields)
for t, v := range m {
- // If the value in the type map is nil, then it indicates that
- // we know this type, and it was handled by one of the records
- // we passed to the decode function vai the TLV stream.
+ // A nil value signals that this type was consumed by one of the
+ // typed records passed to the TLV stream decoder, so its bytes
+ // are already represented elsewhere and do not need to be
+ // tracked here.
if v == nil {
continue
}
- // No need to keep this field if it is unknown to us and is not
- // in the sign range.
- if InUnsignedRange(t) {
+ // Types the predicate places outside the signed range fall
+ // outside the signature's coverage, so they do not need to
+ // survive into re-encoding.
+ if isUnsigned(t) {
continue
}
- // Otherwise, this is an un-handled type, so we keep track of
- // it for signature validation and re-encoding later on.
+ // The remaining types are unhandled but within the signed
+ // range; preserve their raw bytes so the message can re-emit
+ // them verbatim and the signature stays valid.
extraFields[uint64(t)] = v
}
diff --git a/lnwire/pure_tlv_test.go b/lnwire/pure_tlv_test.go
index a81a89e..9148678 100644
--- a/lnwire/pure_tlv_test.go
+++ b/lnwire/pure_tlv_test.go
@@ -387,3 +387,89 @@ func (g *MsgV2) AllRecords() []tlv.Record {
return ProduceRecordsSorted(recordProducers...)
}
+
+// mockPureTLVMessage is a minimal PureTLVMessage backed by a fixed record
+// slice, used to exercise the predicate-driven helpers.
+type mockPureTLVMessage struct {
+ records []tlv.Record
+}
+
+func (m *mockPureTLVMessage) AllRecords() []tlv.Record {
+ return m.records
+}
+
+// TestSerialiseFieldsToSignFn verifies that the serialiser correctly filters
+// records based on the provided predicate before encoding.
+func TestSerialiseFieldsToSignFn(t *testing.T) {
+ t.Parallel()
+
+ var (
+ signedVal uint16 = 11
+ unsignedVal uint16 = 22
+ )
+
+ msg := &mockPureTLVMessage{
+ records: []tlv.Record{
+ tlv.MakePrimitiveRecord(5, &signedVal),
+ tlv.MakePrimitiveRecord(10, &unsignedVal),
+ },
+ }
+
+ // Predicate that defines type 10 as unsigned (excluded).
+ isUnsigned := func(typ tlv.Type) bool {
+ return typ == 10
+ }
+
+ encoded, err := SerialiseFieldsToSignFn(msg, isUnsigned)
+ require.NoError(t, err)
+
+ // Only type 5 should be encoded (type 5, length 2, value 11).
+ require.Equal(t, []byte{0x05, 0x02, 0x00, 0x0b}, encoded)
+}
+
+// TestExtraSignedFieldsFromTypeMapFn confirms the predicate-driven variant
+// keeps and drops the right type ranges for callers whose signed range is not
+// the BOLT 7 v2 default. It also locks in the round-trip identity with the
+// convenience wrapper.
+func TestExtraSignedFieldsFromTypeMapFn(t *testing.T) {
+ t.Parallel()
+
+ // Bolt12 signature TLVs sit at 240-1000 and are excluded from the
+ // signed Merkle tree. Everything else is signed.
+ bolt12Unsigned := func(typ tlv.Type) bool {
+ return typ >= 240 && typ <= 1000
+ }
+
+ typeMap := tlv.TypeMap{
+ // Handled by a typed record on the receiver.
+ tlv.Type(2): nil,
+
+ // Unknown type in the bolt12 signed range — must survive.
+ tlv.Type(99): {
+ 0x01,
+ },
+
+ // Bolt12 signature TLV — must be dropped.
+ tlv.Type(240): {
+ 0x02,
+ },
+
+ // Bolt12 second-range type — signed for bolt12, signed for the
+ // BOLT 7 v2 default too.
+ tlv.Type(1_500_000_000): {
+ 0x03,
+ },
+ }
+
+ gotBolt12 := ExtraSignedFieldsFromTypeMapFn(typeMap, bolt12Unsigned)
+ require.Len(t, gotBolt12, 2)
+ require.Equal(t, []byte{0x01}, gotBolt12[99])
+ require.Equal(t, []byte{0x03}, gotBolt12[1_500_000_000])
+
+ gotDefault := ExtraSignedFieldsFromTypeMap(typeMap)
+ // In the BOLT 7 v2 range, type 99 is signed but type 240 is unsigned.
+ require.Len(t, gotDefault, 2)
+ require.Equal(t, []byte{0x01}, gotDefault[99])
+ require.Equal(t, []byte{0x03}, gotDefault[1_500_000_000])
+ require.NotContains(t, gotDefault, uint64(240))
+}
Why this scored 17/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.