discovery: enforce non-zero timestamp in gossip messages
What changed, and why it matters
This change tightens validation in LND's gossip message handling. It now rejects network announcements (channel updates and node announcements) that carry a timestamp of zero, which the Lightning BOLT specification forbids for channel updates. Remote peers sending zero-timestamp channel updates will also have their 'ban score' increased, moving them closer to being disconnected. The patch is defensive and does not appear to fix an active exploit, but it closes a spec-compliance gap that could be abused to propagate invalid routing data.
No immediate action required beyond normal patching. Operators should upgrade to LND 0.20.1 when available to benefit from the improved gossip validation. Monitor peer ban-score logs for any unexpected spikes from remote peers sending malformed channel updates.
Security signals we found
Spec-compliance validation added for BOLT 7 timestamp requirement
Peer ban-score increment for invalid channel_update messages
Rejection of zero-timestamp node_announcement as defensive sanity check
Validation placed in gossip handlers rather than wire decoder to avoid breaking stored/onion-embedded messages
Evidence from the diff
The commit adds timestamp != 0 checks in discovery/gossiper.go inside handleNodeAnnouncement and handleChanUpdate. For channel updates, a zero timestamp violates BOLT 7 (‘MUST set timestamp to greater than 0’), so the message is rejected and the remote peer’s ban score is incremented via handleBadPeer. For node announcements, BOLT 7 does not explicitly require non-zero timestamps, but the same sanity check is applied without a ban-score penalty. Tests are added for both paths, and the change is noted in the 0.20.1 release notes. Validation is intentionally kept out of the wire decoder so that stored or onion-embedded messages can still be parsed.
Changed components
discovery/gossiper.godiscovery/gossiper_test.godocs/release-notes/release-notes-0.20.1.mdInspect captured patch +115 / −0
diff --git a/discovery/gossiper.go b/discovery/gossiper.go
index 96a8c04..15e5817 100644
--- a/discovery/gossiper.go
+++ b/discovery/gossiper.go
@@ -2502,6 +2502,22 @@ func (d *AuthenticatedGossiper) handleNodeAnnouncement(ctx context.Context,
"node=%x, source=%x", nMsg.peer, timestamp, nodeAnn.NodeID,
nMsg.source.SerializeCompressed())
+ // Although not explicitly required by BOLT 7 for node announcements
+ // (unlike channel updates), we still enforce non-zero timestamps as a
+ // sanity check. A timestamp of zero is likely indicative of a bug or
+ // uninitialized message.
+ if nodeAnn.Timestamp == 0 {
+ err := fmt.Errorf("rejecting node announcement with zero "+
+ "timestamp for node %x", nodeAnn.NodeID)
+
+ log.Warnf("Rejecting node announcement from peer=%v: %v",
+ nMsg.peer, err)
+
+ nMsg.err <- err
+
+ return nil, false
+ }
+
// We'll quickly ask the router if it already has a newer update for
// this node so we can skip validating signatures if not required.
if d.cfg.Graph.IsStaleNode(ctx, nodeAnn.NodeID, timestamp) {
@@ -3056,6 +3072,27 @@ func (d *AuthenticatedGossiper) handleChanUpdate(ctx context.Context,
// quickly reject it.
timestamp := time.Unix(int64(upd.Timestamp), 0)
+ // Per BOLT 7, the timestamp MUST be greater than 0.
+ if upd.Timestamp == 0 {
+ err := fmt.Errorf("rejecting channel update with zero "+
+ "timestamp for short_chan_id(%v)", shortChanID)
+
+ // Only increase ban score for remote peers.
+ if nMsg.isRemote {
+ log.Warnf("Increasing ban score for peer=%v: %v",
+ nMsg.peer, err)
+
+ dcErr := d.handleBadPeer(nMsg.peer)
+ if dcErr != nil {
+ err = dcErr
+ }
+ }
+
+ nMsg.err <- err
+
+ return nil, false
+ }
+
// Fetch the SCID we should be using to lock the channelMtx and make
// graph queries with.
graphScid, err := d.cfg.FindBaseByAlias(upd.ShortChannelID)
diff --git a/discovery/gossiper_test.go b/discovery/gossiper_test.go
index 01faf55..b9f97e5 100644
--- a/discovery/gossiper_test.go
+++ b/discovery/gossiper_test.go
@@ -2930,6 +2930,78 @@ func TestExtraDataNodeAnnouncementValidation(t *testing.T) {
require.NoError(t, err, "unable to process announcement")
}
+// TestZeroTimestampNodeAnnouncementRejection tests that a NodeAnnouncement with
+// a zero timestamp is rejected per BOLT 7.
+func TestZeroTimestampNodeAnnouncementRejection(t *testing.T) {
+ t.Parallel()
+ ctx := t.Context()
+
+ tCtx, err := createTestCtx(t, 0, false)
+ require.NoError(t, err, "can't create context")
+
+ remotePeer := &mockPeer{
+ remoteKeyPriv1.PubKey(), nil, nil, atomic.Bool{},
+ }
+
+ // Create a node announcement with a zero timestamp.
+ nodeAnn, err := createNodeAnnouncement(remoteKeyPriv1, 0)
+ require.NoError(t, err, "can't create node announcement")
+
+ // Processing the announcement should fail with a zero timestamp error.
+ select {
+ case err = <-tCtx.gossiper.ProcessRemoteAnnouncement(
+ ctx, nodeAnn, remotePeer,
+ ):
+ case <-time.After(2 * time.Second):
+ t.Fatal("did not process remote announcement")
+ }
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "zero timestamp")
+}
+
+// TestZeroTimestampChannelUpdateRejection tests that a ChannelUpdate with a
+// zero timestamp is rejected per BOLT 7.
+func TestZeroTimestampChannelUpdateRejection(t *testing.T) {
+ t.Parallel()
+ ctx := t.Context()
+
+ tCtx, err := createTestCtx(t, 0, false)
+ require.NoError(t, err, "can't create context")
+
+ remotePeer := &mockPeer{
+ remoteKeyPriv1.PubKey(), nil, nil, atomic.Bool{},
+ }
+
+ // First, we need to process a channel announcement so that the channel
+ // update has a valid channel to refer to.
+ chanAnn, err := tCtx.createRemoteChannelAnnouncement(0)
+ require.NoError(t, err, "unable to create chan ann")
+
+ select {
+ case err = <-tCtx.gossiper.ProcessRemoteAnnouncement(
+ ctx, chanAnn, remotePeer,
+ ):
+ case <-time.After(2 * time.Second):
+ t.Fatal("did not process remote announcement")
+ }
+ require.NoError(t, err, "unable to process chan ann")
+
+ // Now create a channel update with a zero timestamp.
+ chanUpdAnn, err := createUpdateAnnouncement(0, 0, remoteKeyPriv1, 0)
+ require.NoError(t, err, "unable to create chan update")
+
+ // Processing the update should fail with a zero timestamp error.
+ select {
+ case err = <-tCtx.gossiper.ProcessRemoteAnnouncement(
+ ctx, chanUpdAnn, remotePeer,
+ ):
+ case <-time.After(2 * time.Second):
+ t.Fatal("did not process remote announcement")
+ }
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "zero timestamp")
+}
+
// assertBroadcast checks that num messages are being broadcasted from the
// gossiper. The broadcasted messages are returned.
func assertBroadcast(t *testing.T, ctx *testCtx, num int) []lnwire.Message {
diff --git a/docs/release-notes/release-notes-0.20.1.md b/docs/release-notes/release-notes-0.20.1.md
index 571cfc3..14b9a76 100644
--- a/docs/release-notes/release-notes-0.20.1.md
+++ b/docs/release-notes/release-notes-0.20.1.md
@@ -103,6 +103,12 @@
# Technical and Architectural Updates
## BOLT Spec Updates
+* [Enforce non-zero timestamps](https://github.com/lightningnetwork/lnd/pull/10469)
+ for `channel_update` (as required by BOLT 7) and `node_announcement` messages.
+ Gossip messages with zero timestamps are now rejected. For `channel_update`
+ messages, remote peers sending such invalid messages will have their ban score
+ incremented.
+
## Testing
## Database
Why this scored 48/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.