What changed, and why it matters
This change fixes how Lightning channel-update messages handle optional extra data. Previously, encoding could silently rewrite the message's internal byte slice, which could cause signature mismatches, lost unknown fields, and race conditions when the same message was encoded from multiple threads. The patch now builds a fresh canonical copy of the extra data for each encoding/signing operation without changing the original message. The tests explicitly verify that signatures still verify after a full encode-decode round trip, that unknown fields survive, and that concurrent encoders no longer race.
Treat this as a hardening/reliability fix and include it in the next maintenance release. Review any persisted ChannelUpdate1 records that relied on the previous in-place mutation behavior, and run the new tests (especially the concurrent-encoding and signature round-trip tests) in CI. No immediate emergency response is indicated by the supplied materials, but the signature-consistency fix is security-relevant for Lightning protocol correctness.
Security signals we found
Signature malleability / mismatch risk: DataToSign and Encode now produce the same canonical bytes without mutating the message, preventing cases where signing and wire encoding could diverge.
Race condition: concurrent Encode calls no longer share and rewrite the receiver's ExtraOpaqueData slice.
Data loss / downgrade: unknown TLV records are preserved instead of being overwritten by the inbound-fee merge.
Legacy compatibility: persisted updates with inbound fee stored only in ExtraOpaqueData continue to round-trip correctly.
No explicit CVE, advisory, or vendor security disclosure is present in the supplied materials.
Evidence from the diff
ChannelUpdate1 previously mutated ExtraOpaqueData in place during Encode and DataToSign via EncodeMessageExtraData, merging the typed InboundFee record into the opaque stream. This commit introduces canonicalExtraData, which parses the retained stream, extracts any duplicate inbound-fee record, re-encodes the typed field into a fresh stream, and returns it without modifying the receiver. Encode and DataToSign now use this non-mutating helper. The change preserves legacy rows that store the fee only in ExtraOpaqueData, keeps unknown TLV records intact, and makes the bytes signed identical to the bytes emitted on the wire. Tests cover receiver immutability, unknown-TLV preservation, opaque-only legacy shape, concurrent encoding, and signature verification across a round trip.
Changed components
lnwire.ChannelUpdate1lnwire/channel_update.gonetann channel update signing/verificationpersisted channel-update graph records with legacy inbound-fee encodingInspect captured patch +247 / −12
### lnwire/channel_update.go
@@ -177,13 +177,50 @@ func (a *ChannelUpdate1) Decode(r io.Reader, _ uint32) error {
a.InboundFee = tlv.SomeRecordT(inboundFee)
}
- if len(tlvRecords) != 0 {
- a.ExtraOpaqueData = tlvRecords
- }
+ // Retain the complete stream, including its canonical empty-slice form,
+ // because legacy rows may store the inbound fee in both the opaque and
+ // typed fields.
+ a.ExtraOpaqueData = tlvRecords
return nil
}
+// canonicalExtraData returns the encoded TLV stream without changing the
+// receiver. Decode deliberately retains the complete stream for compatibility
+// with persisted updates, so this method removes a retained inbound-fee record
+// before merging the typed field back into a fresh stream. The typed field wins
+// duplicate representations, while opaque-only bytes are cloned verbatim for
+// legacy compatibility.
+func (a *ChannelUpdate1) canonicalExtraData() ([]byte, error) {
+ // Only parse when replacing a retained fee. Cloning otherwise preserves
+ // arbitrary legacy extensions without sharing the receiver's storage.
+ if !a.InboundFee.IsSome() {
+ return bytes.Clone(a.ExtraOpaqueData), nil
+ }
+
+ inboundFee := a.InboundFee.Zero()
+ _, extraData, err := ParseAndExtractExtraData(
+ a.ExtraOpaqueData, &inboundFee,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("parse update extra data: %w", err)
+ }
+
+ recordProducers := make([]tlv.RecordProducer, 0, 1)
+ a.InboundFee.WhenSome(func(
+ fee tlv.RecordT[tlv.TlvType55555, Fee]) {
+
+ recordProducers = append(recordProducers, &fee)
+ })
+
+ encoded, err := MergeAndEncode(recordProducers, extraData, nil)
+ if err != nil {
+ return nil, fmt.Errorf("encode update extra data: %w", err)
+ }
+
+ return encoded, nil
+}
+
// Encode serializes the target ChannelUpdate into the passed io.Writer
// observing the protocol version specified.
//
@@ -238,18 +275,13 @@ func (a *ChannelUpdate1) Encode(w *bytes.Buffer, pver uint32) error {
}
}
- recordProducers := make([]tlv.RecordProducer, 0, 1)
- a.InboundFee.WhenSome(func(fee tlv.RecordT[tlv.TlvType55555, Fee]) {
- recordProducers = append(recordProducers, &fee)
- })
-
- err := EncodeMessageExtraData(&a.ExtraOpaqueData, recordProducers...)
+ extraData, err := a.canonicalExtraData()
if err != nil {
return err
}
// Finally, append any extra opaque data.
- return WriteBytes(w, a.ExtraOpaqueData)
+ return WriteBytes(w, extraData)
}
// MsgType returns the integer uniquely identifying this message type on the
@@ -311,8 +343,15 @@ func (a *ChannelUpdate1) DataToSign() ([]byte, error) {
}
}
+ // Use Encode's canonical form so signatures cover the emitted bytes
+ // without modifying the caller's update.
+ extraData, err := a.canonicalExtraData()
+ if err != nil {
+ return nil, err
+ }
+
// Finally, append any extra opaque data.
- if err := WriteBytes(buf, a.ExtraOpaqueData); err != nil {
+ if err := WriteBytes(buf, extraData); err != nil {
return nil, err
}
### lnwire/message_test.go
@@ -183,6 +183,153 @@ func TestWriteMessage(t *testing.T) {
}
}
+// makeChannelUpdateWithExtraData constructs an update whose known inbound-fee
+// record is represented by its typed field while an unknown record remains in
+// the opaque stream. Keeping the representations separate lets callers verify
+// that encoding merges them without taking ownership of the original bytes.
+func makeChannelUpdateWithExtraData(t *testing.T) *lnwire.ChannelUpdate1 {
+ t.Helper()
+
+ unknownValue := []byte{1, 2, 3}
+ unknownRecord := tlv.MakePrimitiveRecord(
+ tlv.Type(9), &unknownValue,
+ )
+ extraData, err := lnwire.EncodeRecords([]tlv.Record{unknownRecord})
+ require.NoError(t, err)
+
+ inboundFee := lnwire.Fee{
+ BaseFee: 11,
+ FeeRate: 22,
+ }
+
+ return &lnwire.ChannelUpdate1{
+ Signature: testNodeSig,
+ InboundFee: tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType55555](inboundFee),
+ ),
+ ExtraOpaqueData: extraData,
+ }
+}
+
+// TestChannelUpdateEncodePreservesReceiver verifies that encoding a channel
+// update does not replace its caller-owned opaque TLV bytes.
+func TestChannelUpdateEncodePreservesReceiver(t *testing.T) {
+ // Arrange an update with typed and opaque records, and snapshot the
+ // opaque bytes to detect both content and length changes.
+ update := makeChannelUpdateWithExtraData(t)
+ originalExtraData := bytes.Clone(update.ExtraOpaqueData)
+
+ // Act by encoding through the public wire-message method that
+ // previously rewrote ExtraOpaqueData in place.
+ var encoded bytes.Buffer
+ err := update.Encode(&encoded, 0)
+
+ // Assert that encoding succeeds and leaves the receiver-owned slice
+ // byte-for-byte unchanged for reuse by other peers or goroutines.
+ require.NoError(t, err)
+ require.Equal(t, originalExtraData, []byte(update.ExtraOpaqueData))
+}
+
+// TestChannelUpdateEncodePreservesUnknownTLV verifies that decoding and then
+// re-encoding an update retains unknown TLVs without duplicating known ones.
+func TestChannelUpdateEncodePreservesUnknownTLV(t *testing.T) {
+ // Arrange a canonical wire encoding containing a typed inbound fee and
+ // an unknown record, then decode it into the legacy typed-plus-opaque
+ // representation retained for persisted graph compatibility.
+ update := makeChannelUpdateWithExtraData(t)
+ var original bytes.Buffer
+ require.NoError(t, update.Encode(&original, 0))
+
+ var decoded lnwire.ChannelUpdate1
+ require.NoError(t, decoded.Decode(bytes.NewReader(original.Bytes()), 0))
+ require.True(t, decoded.InboundFee.IsSome())
+ require.NotEmpty(t, decoded.ExtraOpaqueData)
+
+ // Act by re-encoding the decoded update, which must reconcile the known
+ // record in both representations before constructing the TLV stream.
+ var reencoded bytes.Buffer
+ err := decoded.Encode(&reencoded, 0)
+
+ // Assert that reconciliation succeeds and produces the exact original
+ // wire bytes, proving the unknown record survived without duplication.
+ require.NoError(t, err)
+ require.Equal(t, original.Bytes(), reencoded.Bytes())
+}
+
+// TestChannelUpdateEncodePreservesOpaqueFee verifies compatibility with
+// callers that still represent the known inbound fee only as opaque data.
+func TestChannelUpdateEncodePreservesOpaqueFee(t *testing.T) {
+ // Arrange a canonical update, decode its complete stream, and clear the
+ // typed field to reproduce the legacy opaque-only construction shape.
+ update := makeChannelUpdateWithExtraData(t)
+ var original bytes.Buffer
+ require.NoError(t, update.Encode(&original, 0))
+
+ var opaqueOnly lnwire.ChannelUpdate1
+ require.NoError(t, opaqueOnly.Decode(
+ bytes.NewReader(original.Bytes()), 0,
+ ))
+ opaqueOnly.InboundFee = tlv.OptionalRecordT[
+ tlv.TlvType55555, lnwire.Fee,
+ ]{}
+
+ // Act by encoding without a typed fee, which clones the retained opaque
+ // stream unchanged so both record forms keep their original wire bytes.
+ var reencoded bytes.Buffer
+ err := opaqueOnly.Encode(&reencoded, 0)
+
+ // Assert the pass-through retains both the known fee and unknown record
+ // by producing the exact original wire bytes.
+ require.NoError(t, err)
+ require.Equal(t, original.Bytes(), reencoded.Bytes())
+}
+
+// TestChannelUpdateEncodeConcurrent verifies that one channel update can be
+// encoded concurrently without racing through receiver mutation.
+func TestChannelUpdateEncodeConcurrent(t *testing.T) {
+ // Arrange a shared update and canonical expected result. Each worker
+ // gets its own buffer so the only shared data is the message receiver.
+ update := makeChannelUpdateWithExtraData(t)
+ var expected bytes.Buffer
+ require.NoError(t, update.Encode(&expected, 0))
+
+ type encodeResult struct {
+ data []byte
+ err error
+ }
+
+ const workerCount = 8
+ results := make(chan encodeResult, workerCount)
+ var workers sync.WaitGroup
+ workers.Add(workerCount)
+
+ // Act by encoding the shared receiver from independent goroutines. The
+ // buffered result channel gives every worker a shutdown path even if an
+ // encoding fails before the owner begins collecting results.
+ for range workerCount {
+ go func() {
+ defer workers.Done()
+
+ var encoded bytes.Buffer
+ err := update.Encode(&encoded, 0)
+ results <- encodeResult{
+ data: encoded.Bytes(),
+ err: err,
+ }
+ }()
+ }
+
+ workers.Wait()
+ close(results)
+
+ // Assert in the owning test goroutine that every concurrent encoding
+ // succeeded and matched the same canonical bytes.
+ for result := range results {
+ require.NoError(t, result.err)
+ require.Equal(t, expected.Bytes(), result.data)
+ }
+}
+
// BenchmarkWriteMessage benchmarks the performance of lnwire.WriteMessage. It
// generates a test message for each of the lnwire.Message, calls the
// WriteMessage method and benchmark it.
### lnwire/test_message.go
@@ -520,7 +520,8 @@ func (a *ChannelUpdate1) RandTestMessage(t *rapid.T) Message {
),
HtlcMaximumMsat: maxHtlc,
InboundFee: inboundFee,
- ExtraOpaqueData: extraBytes,
+ // Match Decode's empty shape for stable round-trip equality.
+ ExtraOpaqueData: append(ExtraOpaqueData{}, extraBytes...),
}
}
### netann/channel_update_test.go
@@ -1,6 +1,7 @@
package netann_test
import (
+ "bytes"
"errors"
"testing"
"time"
@@ -11,6 +12,8 @@ import (
"github.com/lightningnetwork/lnd/lnwallet"
"github.com/lightningnetwork/lnd/lnwire"
"github.com/lightningnetwork/lnd/netann"
+ "github.com/lightningnetwork/lnd/tlv"
+ "github.com/stretchr/testify/require"
)
type mockSigner struct {
@@ -190,3 +193,48 @@ func TestUpdateDisableFlag(t *testing.T) {
})
}
}
+
+// TestChannelUpdateSignaturePreservesExtraData verifies that signing and wire
+// encoding use the same non-mutating canonical TLV representation.
+func TestChannelUpdateSignaturePreservesExtraData(t *testing.T) {
+ // Arrange a typed inbound fee and a separate unknown TLV for signing.
+ // Signing this pre-encoding shape proves DataToSign includes the same
+ // canonical records that Encode will later put on the wire.
+ unknownValue := []byte{3, 2, 1}
+ unknownRecord := tlv.MakePrimitiveRecord(
+ tlv.Type(9), &unknownValue,
+ )
+ extraData, err := lnwire.EncodeRecords([]tlv.Record{unknownRecord})
+ require.NoError(t, err)
+
+ inboundFee := lnwire.Fee{
+ BaseFee: 33,
+ FeeRate: 44,
+ }
+ update := &lnwire.ChannelUpdate1{
+ InboundFee: tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType55555](inboundFee),
+ ),
+ ExtraOpaqueData: extraData,
+ }
+ require.NoError(t, netann.SignChannelUpdate(
+ netann.NewNodeSigner(privKeySigner), testKeyLoc, update,
+ ))
+
+ var encoded bytes.Buffer
+ require.NoError(t, update.Encode(&encoded, 0))
+
+ var decoded lnwire.ChannelUpdate1
+ require.NoError(t, decoded.Decode(bytes.NewReader(encoded.Bytes()), 0))
+ require.True(t, decoded.InboundFee.IsSome())
+ require.NotEmpty(t, decoded.ExtraOpaqueData)
+
+ // Act by verifying the decoded wire representation. Decode retains the
+ // complete opaque stream, so verification must reconcile it with the
+ // typed fee exactly as signing did before encoding.
+ err = netann.VerifyChannelUpdateSignature(&decoded, pubKey)
+
+ // Assert that the signature remains valid across the full sign, encode,
+ // decode, and verify lifecycle while the unknown TLV is preserved.
+ require.NoError(t, err)
+}Why this scored 59/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.