What changed, and why it matters
This commit adds a new internal framework called PureTLVMessage to the Lightning Network wire protocol code. It is purely a code addition (no existing behavior is changed) and is designed to make future message formats more forward-compatible. There is no indication this commit fixes a security bug or introduces a vulnerability.
No immediate action required. Treat as normal feature/infrastructure code. Reviewers may want to verify that future commits using PureTLVMessage correctly implement AllRecords and do not accidentally place security-critical fields in unsigned ranges.
Security signals we found
No existing code paths are modified
No bug fix or vulnerability remediation language in commit message or diff
New code is defensive: preserves unknown signed TLV fields to prevent signature malleability/forward-compatibility issues
Test-only message types (MsgV1/MsgV2) are introduced; not used in production logic
Evidence from the diff
The commit introduces lnwire.PureTLVMessage, an interface for LN messages encoded entirely as TLV streams, plus helpers to encode them and to compute the exact bytes that a signature will cover. It defines signed ranges (0-159 and 1000000000-2999999999) and unsigned ranges (160-999999999 and >=3000000000). A test demonstrates that an older message version can decode a newer version’s message, preserve unknown signed fields, and reproduce the same signature digest. The change is additive and does not alter any existing message handling.
Changed components
lnwire/pure_tlv.golnwire/pure_tlv_test.goInspect captured patch +494 / −0
diff --git a/lnwire/pure_tlv.go b/lnwire/pure_tlv.go
new file mode 100644
index 0000000..8e6f7bd
--- /dev/null
+++ b/lnwire/pure_tlv.go
@@ -0,0 +1,105 @@
+package lnwire
+
+import (
+ "bytes"
+
+ "github.com/lightningnetwork/lnd/tlv"
+)
+
+const (
+ // pureTLVUnsignedRangeOneStart defines the start of the first unsigned
+ // TLV range used for pure TLV messages. The range is inclusive of this
+ // number.
+ pureTLVUnsignedRangeOneStart = 160
+
+ // pureTLVSignedSecondRangeStart defines the start of the second signed
+ // TLV range used for pure TLV messages. The range is inclusive of this
+ // number. Note that the first range is the inclusive range of 0-159.
+ pureTLVSignedSecondRangeStart = 1000000000
+
+ // pureTLVUnsignedRangeTwoStart defines the start of the second unsigned
+ // TLV range used for pure TLV message.
+ pureTLVUnsignedRangeTwoStart = 3000000000
+)
+
+// 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.
+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() []tlv.Record
+}
+
+// EncodePureTLVMessage encodes the given PureTLVMessage to the given buffer.
+func EncodePureTLVMessage(msg PureTLVMessage, buf *bytes.Buffer) error {
+ return EncodeRecordsTo(buf, msg.AllRecords())
+}
+
+// SerialiseFieldsToSign serialises all the records from the given
+// PureTLVMessage that fall within the signed TLV range.
+func SerialiseFieldsToSign(msg PureTLVMessage) ([]byte, error) {
+ // Filter out all the fields not in the signed ranges.
+ var signedRecords []tlv.Record
+ for _, record := range msg.AllRecords() {
+ if InUnsignedRange(record.Type()) {
+ continue
+ }
+
+ signedRecords = append(signedRecords, record)
+ }
+
+ var buf bytes.Buffer
+ if err := EncodeRecordsTo(&buf, signedRecords); err != nil {
+ return nil, err
+ }
+
+ 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.
+func InUnsignedRange(t tlv.Type) bool {
+ return (t >= pureTLVUnsignedRangeOneStart &&
+ t < pureTLVSignedSecondRangeStart) ||
+ t >= pureTLVUnsignedRangeTwoStart
+}
+
+// ExtraSignedFields is a type that stores a map from TLV types in the signed
+// range (for PureMessages) to their corresponding serialised values. This type
+// can be used to keep around data that we don't yet understand but that we need
+// 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.
+func ExtraSignedFieldsFromTypeMap(m tlv.TypeMap) 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.
+ 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) {
+ continue
+ }
+
+ // Otherwise, this is an un-handled type, so we keep track of
+ // it for signature validation and re-encoding later on.
+ extraFields[uint64(t)] = v
+ }
+
+ return extraFields
+}
diff --git a/lnwire/pure_tlv_test.go b/lnwire/pure_tlv_test.go
new file mode 100644
index 0000000..a81a89e
--- /dev/null
+++ b/lnwire/pure_tlv_test.go
@@ -0,0 +1,389 @@
+package lnwire
+
+import (
+ "bytes"
+ "io"
+ "testing"
+
+ "github.com/btcsuite/btcd/btcec/v2"
+ "github.com/lightningnetwork/lnd/tlv"
+ "github.com/stretchr/testify/require"
+)
+
+// TestPureTLVMessages tests the forwards compatibility of two versions of the
+// same Lightning Network message that uses the Pure TLV format. This in essence
+// tests that and older client is able to verify the signature over relevant
+// data in a newer client's message.
+func TestPureTLVMessage(t *testing.T) {
+ t.Parallel()
+
+ var (
+ _, pkA = btcec.PrivKeyFromBytes([]byte{1})
+ _, pkB = btcec.PrivKeyFromBytes([]byte{2})
+ capacity = MilliSatoshi(100)
+ )
+
+ // Test encode and decode of MsgV1 as is.
+ t.Run("Encode and Decode of MsgV1", func(t *testing.T) {
+ t.Parallel()
+
+ msgOld := newMsgV1(pkA, &capacity)
+
+ buf := bytes.NewBuffer(nil)
+ require.NoError(t, msgOld.Encode(buf, 0))
+
+ var msgOld2 MsgV1
+ require.NoError(t, msgOld2.Decode(buf, 0))
+
+ require.Equal(t, msgOld, &msgOld2)
+ })
+
+ // Test encode and decode of MsgV2 as is.
+ t.Run("Encode and Decode of MsgV2", func(t *testing.T) {
+ t.Parallel()
+
+ msgNew := newMsgV2(
+ pkA, &capacity, pkB, []byte{1, 2, 3, 4}, 90, 100, true,
+ )
+
+ buf := bytes.NewBuffer(nil)
+ require.NoError(t, msgNew.Encode(buf, 0))
+
+ var msgNew2 MsgV2
+ require.NoError(t, msgNew2.Decode(buf, 0))
+
+ require.Equal(t, msgNew, &msgNew2)
+ })
+
+ // Create a MsgV2 and decode it into a MsgV1. Both the new client
+ // (MsgV2) and old client (MsgV1) should be able to generate the same
+ // digest that will be used to create and validate the signture.
+ t.Run("Encode MsgV2 and decode via MsgV1", func(t *testing.T) {
+ t.Parallel()
+
+ var (
+ buf = bytes.NewBuffer(nil)
+ msgV2 = newMsgV2(
+ pkA, &capacity, pkB, []byte{1, 2, 3, 4}, 100,
+ 90, true,
+ )
+ )
+ require.NoError(t, msgV2.Encode(buf, 0))
+
+ // Get the serialised bytes that would be signed for msgV2.
+ signData1, err := SerialiseFieldsToSign(msgV2)
+ require.NoError(t, err)
+
+ // Decoding via the old message should store some of the extra
+ // fields.
+ var msgV1 MsgV1
+ require.NoError(t, msgV1.Decode(buf, 0))
+ require.NotEmpty(t, msgV1.ExtraSignedFields)
+
+ // Show that the extra fields map contains unknown fields in the
+ // signed range but not unknown fields in the unsigned range.
+ _, ok := msgV1.ExtraSignedFields[uint64(msgV2.Num.TlvType())] //nolint:ll
+ require.True(t, ok)
+ _, ok = msgV1.ExtraSignedFields[uint64(msgV2.Other.TlvType())] //nolint:ll
+ require.False(t, ok)
+
+ // The serialised bytes to verify the signature against should
+ // be the same though.
+ signData2, err := SerialiseFieldsToSign(&msgV1)
+ require.NoError(t, err)
+
+ require.Equal(t, signData1, signData2)
+
+ // Re-encoding via the old message should keep the extra fields.
+ buf = bytes.NewBuffer(nil)
+ require.NoError(t, msgV1.Encode(buf, 0))
+
+ var msgV1ReEncoded MsgV1
+ require.NoError(t, msgV1ReEncoded.Decode(buf, 0))
+
+ require.Equal(t, &msgV1, &msgV1ReEncoded)
+ })
+}
+
+// MsgV1 represents a more minimal, first version of a Lightning Network
+// message.
+type MsgV1 struct {
+ // Two known fields in the signed range.
+ NodeKey tlv.RecordT[tlv.TlvType0, *btcec.PublicKey]
+ Capacity tlv.OptionalRecordT[tlv.TlvType1, MilliSatoshi]
+
+ // Signature in the unsigned range.
+ Signature tlv.RecordT[tlv.TlvType160, Sig]
+
+ // Any extra fields in the signed range that we do not yet know about,
+ // but we need to keep them for signature validation and to produce a
+ // valid message.
+ ExtraSignedFields
+}
+
+var _ Message = (*MsgV1)(nil)
+var _ PureTLVMessage = (*MsgV1)(nil)
+
+// newMsgV1 is a constructor for MsgV1.
+func newMsgV1(nodeKey *btcec.PublicKey, capacity *MilliSatoshi) *MsgV1 {
+ newMsg := &MsgV1{
+ NodeKey: tlv.NewPrimitiveRecord[tlv.TlvType0](
+ nodeKey,
+ ),
+ Signature: tlv.NewRecordT[tlv.TlvType160](
+ testSchnorrSig,
+ ),
+ ExtraSignedFields: make(ExtraSignedFields),
+ }
+
+ if capacity != nil {
+ newMsg.Capacity = tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType1](*capacity),
+ )
+ }
+
+ return newMsg
+}
+
+// Decode deserializes a serialized MsgV1 in the passed io.Reader.
+//
+// This is part of the lnwire.Message interface.
+func (g *MsgV1) Decode(r io.Reader, _ uint32) error {
+ var capacity = tlv.ZeroRecordT[tlv.TlvType1, MilliSatoshi]()
+ stream, err := tlv.NewStream(
+ ProduceRecordsSorted(
+ &g.NodeKey,
+ &capacity,
+ &g.Signature,
+ )...,
+ )
+ if err != nil {
+ return err
+ }
+ g.Signature.Val.ForceSchnorr()
+
+ typeMap, err := stream.DecodeWithParsedTypesP2P(r)
+ if err != nil {
+ return err
+ }
+
+ if _, ok := typeMap[g.Capacity.TlvType()]; ok {
+ g.Capacity = tlv.SomeRecordT(capacity)
+ }
+
+ g.ExtraSignedFields = ExtraSignedFieldsFromTypeMap(typeMap)
+
+ return nil
+}
+
+// Encode serializes the target MsgV1 into the passed buffer.
+//
+// This is part of the lnwire.Message interface.
+func (g *MsgV1) Encode(buf *bytes.Buffer, _ uint32) error {
+ return EncodePureTLVMessage(g, buf)
+}
+
+// MsgType returns the integer uniquely identifying this message type on the
+// wire.
+//
+// This is part of the lnwire.Message interface.
+func (g *MsgV1) MsgType() MessageType {
+ return 7777
+}
+
+// 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.
+//
+// This is part of the PureTLVMessage interface.
+func (g *MsgV1) AllRecords() []tlv.Record {
+ recordProducers := []tlv.RecordProducer{
+ &g.NodeKey,
+ &g.Signature,
+ }
+ recordProducers = append(
+ recordProducers,
+ RecordsAsProducers(
+ tlv.MapToRecords(g.ExtraSignedFields),
+ )...,
+ )
+
+ g.Capacity.WhenSome(
+ func(capacity tlv.RecordT[tlv.TlvType1, MilliSatoshi]) {
+ recordProducers = append(recordProducers, &capacity)
+ },
+ )
+
+ return ProduceRecordsSorted(recordProducers...)
+}
+
+// MsgV2 represents a newer version of MsgV1 which contains more fields both in
+// the unsigned and signed TLV ranges.
+type MsgV2 struct {
+ NodeKey tlv.RecordT[tlv.TlvType0, *btcec.PublicKey]
+ Capacity tlv.OptionalRecordT[tlv.TlvType1, MilliSatoshi]
+
+ // An additional fields (optional) in the signed range.
+ BitcoinKey tlv.OptionalRecordT[tlv.TlvType3, *btcec.PublicKey]
+
+ // A zero length TLV in the signed range.
+ SecondPeer tlv.OptionalRecordT[tlv.TlvType5, TrueBoolean]
+
+ // Signature in the unsigned range.
+ Signature tlv.RecordT[tlv.TlvType160, Sig]
+
+ // Another field in the unsigned range. An older node can throw this
+ // away.
+ SPVProof tlv.RecordT[tlv.TlvType161, []byte]
+
+ // A new field in the second signed range. An older node should keep
+ // this since it is part of the serialised message that is signed.
+ Num tlv.RecordT[tlv.TlvType1000000000, uint8]
+
+ // Another field in the second unsigned-range. Older nodes may throw
+ // this away and it won't affect the digest used for signature creation
+ // and validation.
+ Other tlv.RecordT[tlv.TlvType3000000000, uint8]
+
+ // Any extra fields in the signed range that we do not yet know about,
+ // but we need to keep them for signature validation and to produce a
+ // valid message.
+ ExtraSignedFields
+}
+
+// newMsgV2 is a constructor for MsgV2.
+func newMsgV2(nodeKey *btcec.PublicKey, capacity *MilliSatoshi,
+ btcKey *btcec.PublicKey, spvProof []byte, num, other uint8,
+ secondPeer bool) *MsgV2 {
+
+ newMsg := &MsgV2{
+ NodeKey: tlv.NewPrimitiveRecord[tlv.TlvType0](nodeKey),
+ SPVProof: tlv.NewPrimitiveRecord[tlv.TlvType161](spvProof),
+ Num: tlv.NewPrimitiveRecord[tlv.TlvType1000000000](num),
+ Other: tlv.NewPrimitiveRecord[tlv.TlvType3000000000](num),
+ Signature: tlv.NewRecordT[tlv.TlvType160](
+ testSchnorrSig,
+ ),
+ ExtraSignedFields: make(ExtraSignedFields),
+ }
+
+ if secondPeer {
+ newMsg.SecondPeer = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType5](TrueBoolean{}),
+ )
+ }
+
+ if capacity != nil {
+ newMsg.Capacity = tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType1](*capacity),
+ )
+ }
+
+ if btcKey != nil {
+ newMsg.BitcoinKey = tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType3](btcKey),
+ )
+ }
+
+ return newMsg
+}
+
+// Decode deserializes a serialized MsgV2 in the passed io.Reader.
+//
+// This is part of the lnwire.Message interface.
+func (g *MsgV2) Decode(r io.Reader, _ uint32) error {
+ var (
+ capacity = tlv.ZeroRecordT[tlv.TlvType1, MilliSatoshi]()
+ btcKey = tlv.ZeroRecordT[tlv.TlvType3, *btcec.PublicKey]()
+ secondPeer = tlv.ZeroRecordT[tlv.TlvType5, TrueBoolean]()
+ )
+
+ stream, err := tlv.NewStream(
+ ProduceRecordsSorted(
+ &g.NodeKey,
+ &capacity,
+ &btcKey,
+ &secondPeer,
+ &g.Signature,
+ &g.SPVProof,
+ &g.Num,
+ &g.Other,
+ )...,
+ )
+ if err != nil {
+ return err
+ }
+ g.Signature.Val.ForceSchnorr()
+
+ typeMap, err := stream.DecodeWithParsedTypesP2P(r)
+ if err != nil {
+ return err
+ }
+
+ if _, ok := typeMap[g.Capacity.TlvType()]; ok {
+ g.Capacity = tlv.SomeRecordT(capacity)
+ }
+
+ if _, ok := typeMap[g.SecondPeer.TlvType()]; ok {
+ g.SecondPeer = tlv.SomeRecordT(secondPeer)
+ }
+
+ if _, ok := typeMap[g.BitcoinKey.TlvType()]; ok {
+ g.BitcoinKey = tlv.SomeRecordT(btcKey)
+ }
+
+ g.ExtraSignedFields = ExtraSignedFieldsFromTypeMap(typeMap)
+
+ return nil
+}
+
+// Encode serializes the target MsgV2 into the passed buffer.
+//
+// This is part of the lnwire.Message interface.
+func (g *MsgV2) Encode(buf *bytes.Buffer, _ uint32) error {
+ return EncodePureTLVMessage(g, buf)
+}
+
+// MsgType returns the integer uniquely identifying this message type on the
+// wire.
+//
+// This is part of the lnwire.Message interface.
+func (g *MsgV2) MsgType() MessageType {
+ return 7779
+}
+
+// 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.
+//
+// This is part of the PureTLVMessage interface.
+func (g *MsgV2) AllRecords() []tlv.Record {
+ recordProducers := []tlv.RecordProducer{
+ &g.NodeKey,
+ &g.Signature,
+ &g.SPVProof,
+ &g.Num,
+ &g.Other,
+ }
+ recordProducers = append(recordProducers, RecordsAsProducers(
+ tlv.MapToRecords(g.ExtraSignedFields),
+ )...)
+
+ g.Capacity.WhenSome(
+ func(c tlv.RecordT[tlv.TlvType1, MilliSatoshi]) {
+ recordProducers = append(recordProducers, &c)
+ },
+ )
+ g.BitcoinKey.WhenSome(
+ func(key tlv.RecordT[tlv.TlvType3, *btcec.PublicKey]) {
+ recordProducers = append(recordProducers, &key)
+ },
+ )
+ g.SecondPeer.WhenSome(
+ func(second tlv.RecordT[tlv.TlvType5, TrueBoolean]) {
+ recordProducers = append(recordProducers, &second)
+ },
+ )
+
+ return ProduceRecordsSorted(recordProducers...)
+}
Why this scored 12/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.