What changed, and why it matters
This commit refactors how a Lightning network message (ChannelUpdate2) is encoded and decoded. Previously, the digital signature was a separate fixed field; now it is treated as just another TLV (Type-Length-Value) record inside the message. The change also preserves unknown fields that fall within the signed portion of the message so that signatures can still be validated correctly. There is no direct evidence in the commit that this fixes an active security bug, but it is a protocol-correctness change that could prevent signature-validation failures or message-malleability issues when future unknown fields are present.
Treat as a protocol-correctness refactor. Review that the new signed TLV range boundaries and signature type placement match the current Lightning specification, and verify that signature verification still covers exactly the intended fields. Run the new round-trip test and fuzzing tests to ensure no regressions in encoding or signature validation.
Security signals we found
Signature field moved into signed TLV range (type 0xa0), changing what bytes are covered by the signature
Unknown fields inside the signed TLV range are now preserved for signature validation
Encoding/decoding logic changed from hybrid to pure TLV
Caller in netann updated to use new serialization helper for digest computation
Evidence from the diff
The patch converts ChannelUpdate2 from a hybrid layout (fixed signature + TLV body) to a pure TLV message where the signature is TLV type 0xa0. It introduces ExtraSignedFields to retain unknown records inside the signed TLV range, replaces DataToSign with AllRecords, and updates callers in netann to use lnwire.SerialiseFieldsToSign. The test verifies round-trip encoding with both known and unknown signed-range TLV records. This is a structural/protocol refactor rather than a clear-cut vulnerability fix.
Changed components
lnwire/channel_update_2.golnwire/channel_update_2_test.golnwire/test_message.gonetann/channel_update.goInspect captured patch +159 / −59
diff --git a/lnwire/channel_update_2.go b/lnwire/channel_update_2.go
index 343af6b..b832bc5 100644
--- a/lnwire/channel_update_2.go
+++ b/lnwire/channel_update_2.go
@@ -22,10 +22,6 @@ const (
// HTLCs and other parameters. This message is also used to redeclare initially
// set channel parameters.
type ChannelUpdate2 struct {
- // Signature is used to validate the announced data and prove the
- // ownership of node id.
- Signature Sig
-
// ChainHash denotes the target chain that this channel was opened
// within. This value should be the genesis hash of the target chain.
// Along with the short channel ID, this uniquely identifies the
@@ -74,10 +70,22 @@ type ChannelUpdate2 struct {
// millionth of a satoshi.
FeeProportionalMillionths tlv.RecordT[tlv.TlvType18, uint32]
- // ExtraOpaqueData is the set of data that was appended to this message
- // to fill out the full maximum transport message size. These fields can
- // be used to specify optional data such as custom TLV fields.
- ExtraOpaqueData ExtraOpaqueData
+ // Signature is used to validate the announced data and prove the
+ // ownership of node id.
+ 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
+}
+
+// Encode serializes the target ChannelUpdate2 into the passed io.Writer
+// observing the protocol version specified.
+//
+// This is part of the lnwire.Message interface.
+func (c *ChannelUpdate2) Encode(w *bytes.Buffer, _ uint32) error {
+ return EncodePureTLVMessage(c, w)
}
// Decode deserializes a serialized ChannelUpdate2 stored in the passed
@@ -85,17 +93,6 @@ type ChannelUpdate2 struct {
//
// This is part of the lnwire.Message interface.
func (c *ChannelUpdate2) Decode(r io.Reader, _ uint32) error {
- err := ReadElement(r, &c.Signature)
- if err != nil {
- return err
- }
- c.Signature.ForceSchnorr()
-
- return c.DecodeTLVRecords(r)
-}
-
-// DecodeTLVRecords decodes only the TLV section of the message.
-func (c *ChannelUpdate2) DecodeTLVRecords(r io.Reader) error {
// First extract into extra opaque data.
var tlvRecords ExtraOpaqueData
if err := ReadElements(r, &tlvRecords); err != nil {
@@ -111,10 +108,12 @@ func (c *ChannelUpdate2) DecodeTLVRecords(r io.Reader) error {
&secondPeer, &c.CLTVExpiryDelta, &c.HTLCMinimumMsat,
&c.HTLCMaximumMsat, &c.FeeBaseMsat,
&c.FeeProportionalMillionths,
+ &c.Signature,
)
if err != nil {
return err
}
+ c.Signature.Val.ForceSchnorr()
// By default, the chain-hash is the bitcoin mainnet genesis block hash.
c.ChainHash.Val = *chaincfg.MainNetParams.GenesisHash
@@ -150,38 +149,21 @@ func (c *ChannelUpdate2) DecodeTLVRecords(r io.Reader) error {
c.FeeProportionalMillionths.Val = defaultFeeProportionalMillionths //nolint:ll
}
- if len(tlvRecords) != 0 {
- c.ExtraOpaqueData = tlvRecords
- }
+ c.ExtraSignedFields = ExtraSignedFieldsFromTypeMap(typeMap)
- return c.ExtraOpaqueData.ValidateTLV()
+ return nil
}
-// Encode serializes the target ChannelUpdate2 into the passed io.Writer
-// observing the protocol version specified.
+// 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 lnwire.Message interface.
-func (c *ChannelUpdate2) Encode(w *bytes.Buffer, _ uint32) error {
- _, err := w.Write(c.Signature.RawBytes())
- if err != nil {
- return err
- }
-
- _, err = c.DataToSign()
- if err != nil {
- return err
- }
-
- return WriteBytes(w, c.ExtraOpaqueData)
-}
+// NOTE: this is part of the PureTLVMessage interface.
+func (c *ChannelUpdate2) AllRecords() []tlv.Record {
+ var recordProducers []tlv.RecordProducer
-// DataToSign is used to retrieve part of the announcement message which should
-// be signed. For the ChannelUpdate2 message, this includes the serialised TLV
-// records.
-func (c *ChannelUpdate2) DataToSign() ([]byte, error) {
// The chain-hash record is only included if it is _not_ equal to the
// bitcoin mainnet genisis block hash.
- var recordProducers []tlv.RecordProducer
if !c.ChainHash.Val.IsEqual(chaincfg.MainNetParams.GenesisHash) {
hash := tlv.ZeroRecordT[tlv.TlvType0, [32]byte]()
hash.Val = c.ChainHash.Val
@@ -190,7 +172,7 @@ func (c *ChannelUpdate2) DataToSign() ([]byte, error) {
}
recordProducers = append(recordProducers,
- &c.ShortChannelID, &c.BlockHeight,
+ &c.ShortChannelID, &c.BlockHeight, &c.Signature,
)
// Only include the disable flags if any bit is set.
@@ -225,12 +207,11 @@ func (c *ChannelUpdate2) DataToSign() ([]byte, error) {
)
}
- err := EncodeMessageExtraData(&c.ExtraOpaqueData, recordProducers...)
- if err != nil {
- return nil, err
- }
+ recordProducers = append(recordProducers, RecordsAsProducers(
+ tlv.MapToRecords(c.ExtraSignedFields),
+ )...)
- return c.ExtraOpaqueData, nil
+ return ProduceRecordsSorted(recordProducers...)
}
// MsgType returns the integer uniquely identifying this message type on the
@@ -248,14 +229,14 @@ func (c *ChannelUpdate2) SerializedSize() (uint32, error) {
return MessageSerializedSize(c)
}
-func (c *ChannelUpdate2) ExtraData() ExtraOpaqueData {
- return c.ExtraOpaqueData
-}
-
// A compile time check to ensure ChannelUpdate2 implements the
// lnwire.Message interface.
var _ Message = (*ChannelUpdate2)(nil)
+// A compile time check to ensure ChannelUpdate2 implements the
+// lnwire.PureTLVMessage interface.
+var _ PureTLVMessage = (*ChannelUpdate2)(nil)
+
// SCID returns the ShortChannelID of the channel that the update applies to.
//
// NOTE: this is part of the ChannelUpdate interface.
diff --git a/lnwire/channel_update_2_test.go b/lnwire/channel_update_2_test.go
new file mode 100644
index 0000000..4e771d8
--- /dev/null
+++ b/lnwire/channel_update_2_test.go
@@ -0,0 +1,119 @@
+package lnwire
+
+import (
+ "bytes"
+ "testing"
+
+ "github.com/lightningnetwork/lnd/tlv"
+ "github.com/stretchr/testify/require"
+)
+
+// TestChanUpdate2EncodeDecode tests the encoding and decoding of the
+// ChannelUpdate2 message using hardcoded byte slices.
+func TestChanUpdate2EncodeDecode(t *testing.T) {
+ t.Parallel()
+
+ // We'll create a raw byte stream that represents a valid ChannelUpdate2
+ // message. This includes the signature and a TLV stream with both known
+ // and unknown records.
+ rawBytes := []byte{
+ // ChainHash record (optional, not mainnet).
+ 0x0, // type.
+ 0x20, // length.
+ 0x1, 0x1, 0x1, 0x1, 0x1, 0x1, 0x1, 0x1, 0x1, 0x1, 0x1, 0x1,
+ 0x1, 0x1, 0x1, 0x1, 0x1, 0x1, 0x1, 0x1, 0x1, 0x1, 0x1, 0x1,
+ 0x1, 0x1, 0x1, 0x1, 0x1, 0x1, 0x1, 0x1,
+
+ // ShortChannelID record.
+ 0x2, // type.
+ 0x8, // length.
+ 0x0, 0x0, 0x1, 0x0, 0x0, 0x2, 0x0, 0x3, // value.
+
+ // BlockHeight record.
+ 0x4, // type.
+ 0x4, // length.
+ 0x0, 0x0, 0x1, 0x0, // value.
+
+ // DisabledFlags record.
+ 0x6, // type.
+ 0x1, // length.
+ 0x1, // value.
+
+ // SecondPeer record.
+ 0x8, // type.
+ 0x0, // length.
+
+ // Unknown odd-type TLV record.
+ 0x9, // type.
+ 0x2, // length.
+ 0xab, 0xcd, // value.
+
+ // CLTVExpiryDelta record.
+ 0xa, // type.
+ 0x2, // length.
+ 0x0, 0x10, // value.
+
+ // HTLCMinimumMsat record.
+ 0xc, // type.
+ 0x5, // length.
+ 0xfe, 0x0, 0xf, 0x42, 0x40, // value (BigSize: 1_000_000).
+
+ // HTLCMaximumMsat record.
+ 0xe, // type.
+ 0x5, // length.
+ 0xfe, 0x0, 0xf, 0x42, 0x40, // value (BigSize: 1_000_000).
+
+ // FeeBaseMsat record.
+ 0x10, // type.
+ 0x4, // length.
+ 0x0, 0x0, 0x1, 0x0, // value.
+
+ // FeeProportionalMillionths record.
+ 0x12, // type.
+ 0x4, // length.
+ 0x0, 0x0, 0x1, 0x0, // value.
+
+ // Extra Opaque Data - Unknown Record.
+ 0x14, // type.
+ 0x2, // length.
+ 0x79, 0x79, // value.
+
+ // Signature.
+ 0xa0, // type.
+ 0x40, // length.
+ 0x0, 0x1, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0x8, 0x9, 0xa, 0xb,
+ 0xc, 0xd, 0xe, 0xf, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16,
+ 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20,
+ 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2a,
+ 0x2b, 0x2c, 0x2d, 0x2e, 0x2f, 0x30, 0x31, 0x32, 0x33, 0x34,
+ 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x3b, 0x3c, 0x3d, 0x3e,
+ 0x3f, // value
+ }
+
+ secondSignedRangeType := new(bytes.Buffer)
+ var buf [8]byte
+ err := tlv.WriteVarInt(
+ secondSignedRangeType, pureTLVSignedSecondRangeStart+1, &buf,
+ )
+ require.NoError(t, err)
+ rawBytes = append(rawBytes, secondSignedRangeType.Bytes()...) // type.
+ rawBytes = append(rawBytes, []byte{
+ 0x02, // length.
+ 0x79, 0x79, // value.
+ }...)
+
+ // Now, create a new empty message and decode the raw bytes into it.
+ msg := &ChannelUpdate2{}
+ r := bytes.NewReader(rawBytes)
+ err = msg.Decode(r, 0)
+ require.NoError(t, err)
+
+ // Next, encode the message back into a new byte buffer.
+ var b bytes.Buffer
+ err = msg.Encode(&b, 0)
+ require.NoError(t, err)
+
+ // The re-encoded bytes should be exactly the same as the original raw
+ // bytes.
+ require.Equal(t, rawBytes, b.Bytes())
+}
diff --git a/lnwire/test_message.go b/lnwire/test_message.go
index 8e2e46b..77432fd 100644
--- a/lnwire/test_message.go
+++ b/lnwire/test_message.go
@@ -518,7 +518,6 @@ func (c *ChannelUpdate2) RandTestMessage(t *rapid.T) Message {
//nolint:ll
msg := &ChannelUpdate2{
- Signature: RandSignature(t),
ChainHash: tlv.NewPrimitiveRecord[tlv.TlvType0, chainhash.Hash](
chainHashObj,
),
@@ -546,10 +545,11 @@ func (c *ChannelUpdate2) RandTestMessage(t *rapid.T) Message {
FeeProportionalMillionths: tlv.NewPrimitiveRecord[tlv.TlvType18, uint32](
feeProportionalMillionths,
),
- ExtraOpaqueData: RandExtraOpaqueData(t, nil),
+ ExtraSignedFields: make(map[uint64][]byte),
}
- msg.Signature.ForceSchnorr()
+ msg.Signature.Val = RandSignature(t)
+ msg.Signature.Val.ForceSchnorr()
if rapid.Bool().Draw(t, "isSecondPeer") {
msg.SecondPeer = tlv.SomeRecordT(
diff --git a/netann/channel_update.go b/netann/channel_update.go
index d453b53..f262103 100644
--- a/netann/channel_update.go
+++ b/netann/channel_update.go
@@ -242,7 +242,7 @@ func verifyChannelUpdate2Signature(c *lnwire.ChannelUpdate2,
return fmt.Errorf("unable to reconstruct message data: %w", err)
}
- nodeSig, err := c.Signature.ToSignature()
+ nodeSig, err := c.Signature.Val.ToSignature()
if err != nil {
return err
}
@@ -330,7 +330,7 @@ func ChanUpdate2DigestTag() []byte {
// chanUpdate2DigestToSign computes the digest of the ChannelUpdate2 message to
// be signed.
func chanUpdate2DigestToSign(c *lnwire.ChannelUpdate2) ([]byte, error) {
- data, err := c.DataToSign()
+ data, err := lnwire.SerialiseFieldsToSign(c)
if err != nil {
return nil, err
}
Why this scored 28/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.