go-ethereum/cmd/devp2p/internal/v5test/discv5tests.go
2026-04-20 12:54:31 +00:00

614 lines
20 KiB
Go

// Copyright 2020 The go-ethereum Authors
// This file is part of go-ethereum.
//
// go-ethereum is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// go-ethereum is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with go-ethereum. If not, see <http://www.gnu.org/licenses/>.
package v5test
import (
"bytes"
"net"
"slices"
"sync"
"time"
"github.com/ethereum/go-ethereum/internal/utesting"
"github.com/ethereum/go-ethereum/p2p/discover/v5wire"
"github.com/ethereum/go-ethereum/p2p/enode"
"github.com/ethereum/go-ethereum/p2p/enr"
"github.com/ethereum/go-ethereum/p2p/netutil"
)
// Suite is the discv5 test suite.
type Suite struct {
Dest *enode.Node
Listen1, Listen2 string // listening addresses
}
func (s *Suite) listen1(log logger) (*conn, net.PacketConn) {
c := newConn(s.Dest, log)
l := c.listen(s.Listen1)
return c, l
}
func (s *Suite) listen2(log logger) (*conn, net.PacketConn, net.PacketConn) {
c := newConn(s.Dest, log)
l1, l2 := c.listen(s.Listen1), c.listen(s.Listen2)
return c, l1, l2
}
func (s *Suite) AllTests() []utesting.Test {
return []utesting.Test{
{Name: "Ping", Fn: s.TestPing},
{Name: "PingLargeRequestID", Fn: s.TestPingLargeRequestID},
{Name: "PingMultiIP", Fn: s.TestPingMultiIP},
{Name: "HandshakeResend", Fn: s.TestHandshakeResend},
{Name: "TalkRequest", Fn: s.TestTalkRequest},
{Name: "FindnodeWrongIP", Fn: s.TestFindnodeWrongIP},
{Name: "FindnodeHandshake", Fn: s.TestFindnodeHandshake},
{Name: "FindnodeZeroDistance", Fn: s.TestFindnodeZeroDistance},
{Name: "FindnodeResults", Fn: s.TestFindnodeResults},
{Name: "UnsolicitedNodes", Fn: s.TestUnsolicitedNodes},
}
}
func (s *Suite) TestPing(t *utesting.T) {
t.Log(`This test is just a sanity check. It sends PING and expects a PONG response.`)
conn, l1 := s.listen1(t)
defer conn.close()
ping := &v5wire.Ping{ReqID: conn.nextReqID()}
switch resp := conn.reqresp(l1, ping).(type) {
case *v5wire.Pong:
checkPong(t, resp, ping, l1)
default:
t.Fatal("expected PONG, got", resp.Name())
}
}
func checkPong(t *utesting.T, pong *v5wire.Pong, ping *v5wire.Ping, c net.PacketConn) {
if !bytes.Equal(pong.ReqID, ping.ReqID) {
t.Fatalf("wrong request ID %x in PONG, want %x", pong.ReqID, ping.ReqID)
}
if !pong.ToIP.Equal(laddr(c).IP) {
t.Fatalf("wrong destination IP %v in PONG, want %v", pong.ToIP, laddr(c).IP)
}
if int(pong.ToPort) != laddr(c).Port {
t.Fatalf("wrong destination port %v in PONG, want %v", pong.ToPort, laddr(c).Port)
}
}
func (s *Suite) TestPingLargeRequestID(t *utesting.T) {
t.Log(`This test sends PING with a 9-byte request ID, which isn't allowed by the spec.
The remote node should not respond.`)
conn, l1 := s.listen1(t)
defer conn.close()
ping := &v5wire.Ping{ReqID: make([]byte, 9)}
switch resp := conn.reqresp(l1, ping).(type) {
case *v5wire.Pong:
t.Errorf("PONG response with unknown request ID %x", resp.ReqID)
case *readError:
if resp.err == v5wire.ErrInvalidReqID {
t.Error("response with oversized request ID")
} else if !netutil.IsTimeout(resp.err) {
t.Error(resp)
}
}
}
func (s *Suite) TestPingMultiIP(t *utesting.T) {
t.Log(`This test establishes a session from one IP as usual. The session is then reused
on another IP, which shouldn't work. The remote node should respond with WHOAREYOU for
the attempt from a different IP.`)
conn, l1, l2 := s.listen2(t)
defer conn.close()
// Create the session on l1.
ping := &v5wire.Ping{ReqID: conn.nextReqID()}
resp := conn.reqresp(l1, ping)
if resp.Kind() != v5wire.PongMsg {
t.Fatal("expected PONG, got", resp)
}
checkPong(t, resp.(*v5wire.Pong), ping, l1)
// Send on l2. This reuses the session because there is only one codec.
t.Log("sending ping from alternate IP", l2.LocalAddr())
ping2 := &v5wire.Ping{ReqID: conn.nextReqID()}
conn.write(l2, ping2, nil)
switch resp := conn.read(l2).(type) {
case *v5wire.Pong:
t.Fatalf("remote responded to PING from %v for session on IP %v", laddr(l2).IP, laddr(l1).IP)
case *v5wire.Whoareyou:
t.Logf("got WHOAREYOU for new session as expected")
resp.Node = s.Dest
conn.write(l2, ping2, resp)
default:
t.Fatal("expected WHOAREYOU, got", resp)
}
// Catch the PONG on l2.
switch resp := conn.read(l2).(type) {
case *v5wire.Pong:
checkPong(t, resp, ping2, l2)
default:
t.Fatal("expected PONG, got", resp)
}
// Try on l1 again.
ping3 := &v5wire.Ping{ReqID: conn.nextReqID()}
conn.write(l1, ping3, nil)
switch resp := conn.read(l1).(type) {
case *v5wire.Pong:
t.Fatalf("remote responded to PING from %v for session on IP %v", laddr(l1).IP, laddr(l2).IP)
case *v5wire.Whoareyou:
t.Logf("got WHOAREYOU for new session as expected")
default:
t.Fatal("expected WHOAREYOU, got", resp)
}
}
// TestHandshakeResend starts a handshake, but doesn't finish it and sends a second ordinary message
// packet instead of a handshake message packet. The remote node should repeat the previous WHOAREYOU
// challenge for the first PING.
func (s *Suite) TestHandshakeResend(t *utesting.T) {
conn, l1 := s.listen1(t)
defer conn.close()
// First PING triggers challenge.
ping := &v5wire.Ping{ReqID: conn.nextReqID()}
conn.write(l1, ping, nil)
var challenge1 *v5wire.Whoareyou
switch resp := conn.read(l1).(type) {
case *v5wire.Whoareyou:
challenge1 = resp
t.Logf("got WHOAREYOU for PING")
default:
t.Fatal("expected WHOAREYOU, got", resp)
}
// Send second PING.
ping2 := &v5wire.Ping{ReqID: conn.nextReqID()}
conn.write(l1, ping2, nil)
switch resp := conn.read(l1).(type) {
case *v5wire.Whoareyou:
if resp.Nonce != challenge1.Nonce {
t.Fatalf("wrong nonce %x in WHOAREYOU (want %x)", resp.Nonce[:], challenge1.Nonce[:])
}
if !bytes.Equal(resp.ChallengeData, challenge1.ChallengeData) {
t.Fatalf("wrong ChallengeData in resent WHOAREYOU (want %x)", resp.ChallengeData, challenge1.ChallengeData)
}
resp.Node = conn.remote
default:
t.Fatal("expected WHOAREYOU, got", resp)
}
}
func (s *Suite) TestTalkRequest(t *utesting.T) {
t.Log(`This test sends some examples of TALKREQ with a protocol-id of "test-protocol"
and expects an empty TALKRESP response.`)
conn, l1 := s.listen1(t)
defer conn.close()
// Non-empty request ID.
id := conn.nextReqID()
resp := conn.reqresp(l1, &v5wire.TalkRequest{ReqID: id, Protocol: "test-protocol"})
switch resp := resp.(type) {
case *v5wire.TalkResponse:
if !bytes.Equal(resp.ReqID, id) {
t.Fatalf("wrong request ID %x in TALKRESP, want %x", resp.ReqID, id)
}
if len(resp.Message) > 0 {
t.Fatalf("non-empty message %x in TALKRESP", resp.Message)
}
default:
t.Fatal("expected TALKRESP, got", resp.Name())
}
// Empty request ID.
t.Log("sending TALKREQ with empty request-id")
resp = conn.reqresp(l1, &v5wire.TalkRequest{Protocol: "test-protocol"})
switch resp := resp.(type) {
case *v5wire.TalkResponse:
if len(resp.ReqID) > 0 {
t.Fatalf("wrong request ID %x in TALKRESP, want empty byte array", resp.ReqID)
}
if len(resp.Message) > 0 {
t.Fatalf("non-empty message %x in TALKRESP", resp.Message)
}
default:
t.Fatal("expected TALKRESP, got", resp.Name())
}
}
// TestFindnodeWrongIP establishes a session on one IP, then sends FINDNODE from a
// different IP and expects the remote node to restart the handshake with WHOAREYOU.
//
// Why it exists:
// This test checks that discv5 sessions are tied to the UDP endpoint, not just the peer
// identity. Reusing a previously established session from a different source IP/port
// should fail decryption/authentication and trigger a fresh challenge instead of
// producing a valid NODES response. It extends the existing wrong-IP coverage from PING
// to FINDNODE.
//
// Relevant spec:
// The discv5 session model binds handshake state and session secrets to a specific UDP
// endpoint. When a peer switches endpoints, recipients should refuse to decrypt messages
// from the new endpoint and answer with WHOAREYOU so the session is re-established.
func (s *Suite) TestFindnodeWrongIP(t *utesting.T) {
t.Log(`This test establishes a session on one IP, then sends FINDNODE from another IP.
The remote node should challenge the second endpoint with WHOAREYOU instead of returning NODES.`)
conn, l1, l2 := s.listen2(t)
defer conn.close()
ping := &v5wire.Ping{ReqID: conn.nextReqID()}
if resp := conn.reqresp(l1, ping); resp.Kind() != v5wire.PongMsg {
t.Fatal("expected PONG, got", resp)
}
req := &v5wire.Findnode{ReqID: conn.nextReqID(), Distances: []uint{0}}
conn.write(l2, req, nil)
switch resp := conn.read(l2).(type) {
case *v5wire.Whoareyou:
t.Log("got WHOAREYOU for FINDNODE on wrong IP as expected")
case *v5wire.Nodes:
t.Fatalf("unexpected NODES response on wrong IP: %+v", resp)
default:
t.Fatal("expected WHOAREYOU, got", resp)
}
}
// TestFindnodeHandshake sends FINDNODE without an existing session and verifies
// that the remote node answers only after the WHOAREYOU handshake completes.
//
// Why it exists:
// This test makes FINDNODE's handshake gating explicit. Without an existing session, the
// recipient should not answer the initial request directly with NODES. It must first send
// WHOAREYOU, require the requester to resend the same FINDNODE as a handshake packet, and
// only then return the response. The test also checks that the challenge nonce matches the
// original request and that the eventual NODES reply carries the right request ID.
//
// Relevant spec:
// In discv5, when the recipient has no valid session or cannot decrypt/authenticate an
// incoming request, it must respond with WHOAREYOU. The requester then resends the same
// request as a handshake packet, and only after the handshake succeeds should the
// recipient answer the request.
func (s *Suite) TestFindnodeHandshake(t *utesting.T) {
t.Log(`This test checks that the remote answers a FINDNODE request only after completing the WHOAREYOU handshake.`)
conn, l1 := s.listen1(t)
defer conn.close()
req := &v5wire.Findnode{ReqID: conn.nextReqID(), Distances: []uint{0}}
nonce := conn.write(l1, req, nil)
resp, from := conn.readFrom(l1)
challenge, ok := resp.(*v5wire.Whoareyou)
if !ok {
t.Fatalf("expected WHOAREYOU before NODES, got %T (%v) from %v", resp, resp, from)
}
if challenge.Nonce != nonce {
t.Fatalf("wrong nonce %x in WHOAREYOU (want %x)", challenge.Nonce[:], nonce[:])
}
challenge.Node = conn.remote
conn.writeTo(l1, req, challenge, from)
for {
resp, from := conn.readFrom(l1)
switch resp := resp.(type) {
case *v5wire.Ping:
conn.writeTo(l1, &v5wire.Pong{
ReqID: resp.ReqID,
ENRSeq: conn.localNode.Seq(),
ToIP: from.IP,
ToPort: uint16(from.Port),
}, nil, from)
case *v5wire.Nodes:
if !bytes.Equal(resp.ReqID, req.ReqID) {
t.Fatalf("wrong request ID %x in NODES, want %x", resp.ReqID, req.ReqID)
}
return
default:
t.Fatalf("expected NODES after completing handshake, got %T (%v) from %v", resp, resp, from)
}
}
}
func (s *Suite) TestFindnodeZeroDistance(t *utesting.T) {
t.Log(`This test checks that the remote node returns itself for FINDNODE with distance zero.`)
conn, l1 := s.listen1(t)
defer conn.close()
nodes, err := conn.findnode(l1, []uint{0})
if err != nil {
t.Fatal(err)
}
if len(nodes) != 1 {
t.Fatalf("remote returned more than one node for FINDNODE [0]")
}
if nodes[0].ID() != conn.remote.ID() {
t.Errorf("ID of response node is %v, want %v", nodes[0].ID(), conn.remote.ID())
}
}
// TestUnsolicitedNodes sends an unsolicited authenticated NODES packet and checks
// that the advertised node is neither contacted nor returned by later FINDNODE queries.
//
// Why it exists:
// This test checks that a peer cannot inject arbitrary ENRs into the remote node's view
// of the network by sending unsolicited NODES. Even when the packet is authenticated and
// well-formed, accepting it as useful routing data would allow table pollution. The test
// therefore looks for two concrete bad outcomes: the remote node contacting the injected
// fake node, or the fake node later appearing in FINDNODE results.
//
// Relevant spec:
// In discv5, NODES is defined as the response to FINDNODE or TOPICQUERY, not as a
// free-standing advertisement. Handling of NODES is therefore request/response-oriented,
// keyed to an earlier query and request ID. Ignoring unsolicited NODES is the hygiene
// behavior implied by that response model.
func (s *Suite) TestUnsolicitedNodes(t *utesting.T) {
t.Log(`This test sends an unsolicited NODES response advertising a fake node.
The remote node should neither contact the injected node nor return it from later FINDNODE queries.`)
conn, l1 := s.listen1(t)
defer conn.close()
// Establish session so the unsolicited packet is well-formed and authenticated.
ping := &v5wire.Ping{ReqID: conn.nextReqID()}
if resp := conn.reqresp(l1, ping); resp.Kind() != v5wire.PongMsg {
t.Fatal("expected PONG, got", resp)
}
fakeConn, fakeL := s.listen1(t)
defer fakeConn.close()
fakeConn.setEndpoint(fakeL)
unsolicited := &v5wire.Nodes{
ReqID: conn.nextReqID(),
RespCount: 1,
Nodes: []*enr.Record{fakeConn.localNode.Node().Record()},
}
t.Log("sending unsolicited NODES response with injected node")
conn.write(l1, unsolicited, nil)
const contactWindow = 500 * time.Millisecond
buf := make([]byte, 1280)
if err := fakeL.SetReadDeadline(time.Now().Add(contactWindow)); err != nil {
t.Fatal(err)
}
if n, from, err := fakeL.ReadFrom(buf); err == nil {
t.Fatalf("remote contacted injected node after unsolicited NODES: %d bytes from %v", n, from)
} else if !netutil.IsTimeout(err) {
t.Fatalf("waiting for unexpected contact failed: %v", err)
}
dist := uint(enode.LogDist(fakeConn.localNode.ID(), s.Dest.ID()))
const maxAttempts = 3
const retryInterval = 200 * time.Millisecond
for attempt := 1; attempt <= maxAttempts; attempt++ {
results, err := conn.findnode(l1, []uint{dist})
if err != nil {
t.Fatal(err)
}
for _, n := range results {
if n.ID() == fakeConn.localNode.ID() {
t.Fatalf("attempt %d: FINDNODE result contains node from unsolicited NODES response", attempt)
}
}
t.Logf("attempt %d: injected node not returned in FINDNODE results", attempt)
if attempt < maxAttempts {
time.Sleep(retryInterval)
}
}
}
func (s *Suite) TestFindnodeResults(t *utesting.T) {
t.Log(`This test pings the node under test from multiple other endpoints and node identities
(the 'bystanders'). After waiting for them to be accepted into the remote table, the test checks
that they are returned by FINDNODE.`)
// Create bystanders.
nodes := make([]*bystander, 5)
liveCh := make(chan enode.ID, len(nodes))
for i := range nodes {
nodes[i] = newBystander(t, s, liveCh)
defer nodes[i].close()
}
// Prefill each bystander with the full bystander set so background FINDNODE
// lookups see useful routing data instead of empty responses.
known := make([]*enode.Node, 0, len(nodes))
for _, bn := range nodes {
known = append(known, bn.conn.localNode.Node())
}
for _, bn := range nodes {
bn.known = append([]*enode.Node(nil), known...)
}
// Wait until enough bystanders have actually become live, i.e. the remote node
// has revalidated them by sending PING and receiving our PONG.
requiredLiveNodes := len(nodes)
timeout := 60 * time.Second
timeoutCh := time.After(timeout)
liveSet := make(map[enode.ID]*enode.Node)
for len(liveSet) < requiredLiveNodes {
select {
case id := <-liveCh:
for _, bn := range nodes {
if bn.id() == id {
liveSet[id] = bn.conn.localNode.Node()
break
}
}
t.Logf("bystander node %v became live", id)
case <-timeoutCh:
t.Errorf("remote revalidated %d bystander nodes in %v, need %d to continue", len(liveSet), timeout, requiredLiveNodes)
return
}
}
t.Logf("continuing after all %d bystander nodes became live", len(liveSet))
// Collect live nodes by distance.
var dists []uint
expect := make(map[enode.ID]*enode.Node)
for id, n := range liveSet {
expect[id] = n
d := uint(enode.LogDist(n.ID(), s.Dest.ID()))
if !slices.Contains(dists, d) {
dists = append(dists, d)
}
}
// Send FINDNODE for all distances.
t.Log("requesting nodes")
conn, l1 := s.listen1(t)
defer conn.close()
const maxAttempts = 5
const retryInterval = 2 * time.Second
for attempt := 1; attempt <= maxAttempts; attempt++ {
foundNodes, err := conn.findnode(l1, dists)
if err != nil {
t.Fatal(err)
}
missing := make(map[enode.ID]struct{})
for id := range expect {
missing[id] = struct{}{}
}
for _, n := range foundNodes {
delete(missing, n.ID())
}
t.Logf("attempt %d: remote returned %d nodes for distance list %v, missing %d", attempt, len(foundNodes), dists, len(missing))
if len(missing) == 0 {
t.Logf("all %d expected live nodes were returned", len(expect))
return
}
if attempt < maxAttempts {
time.Sleep(retryInterval)
}
}
t.Errorf("missing nodes in FINDNODE result after %d attempts", maxAttempts)
t.Logf("this can happen if the node has a non-empty table from previous runs")
}
// A bystander is a node whose only purpose is filling a spot in the remote table.
type bystander struct {
dest *enode.Node
conn *conn
l net.PacketConn
known []*enode.Node
liveCh chan enode.ID
sent map[v5wire.Nonce]v5wire.Packet
done sync.WaitGroup
}
func newBystander(t *utesting.T, s *Suite, live chan enode.ID) *bystander {
conn, l := s.listen1(t)
conn.setEndpoint(l) // bystander nodes need IP/port to get pinged
bn := &bystander{
conn: conn,
l: l,
dest: s.Dest,
liveCh: live,
sent: make(map[v5wire.Nonce]v5wire.Packet),
}
// Establish an initial session and let the remote learn this node before
// switching to the passive responder loop below.
conn.reqresp(l, &v5wire.Ping{
ReqID: conn.nextReqID(),
ENRSeq: conn.localNode.Seq(),
})
bn.done.Add(1)
go bn.loop()
return bn
}
// id returns the node ID of the bystander.
func (bn *bystander) id() enode.ID {
return bn.conn.localNode.ID()
}
// close shuts down loop.
func (bn *bystander) close() {
bn.conn.close()
bn.done.Wait()
}
// loop answers packets from the remote node until quit.
func (bn *bystander) loop() {
defer bn.done.Done()
for {
p, from := bn.conn.readFrom(bn.l)
switch p := p.(type) {
case *v5wire.Whoareyou:
p.Node = bn.dest
if resp, ok := bn.sent[p.Nonce]; ok {
nonce := bn.conn.writeTo(bn.l, resp, p, from)
delete(bn.sent, p.Nonce)
bn.sent[nonce] = resp
} else {
bn.conn.writeTo(bn.l, &v5wire.Ping{
ReqID: bn.conn.nextReqID(),
ENRSeq: bn.conn.localNode.Seq(),
}, p, from)
}
case *v5wire.Ping:
resp := &v5wire.Pong{
ReqID: append([]byte(nil), p.ReqID...),
ENRSeq: bn.conn.localNode.Seq(),
ToIP: from.IP,
ToPort: uint16(from.Port),
}
nonce := bn.conn.writeTo(bn.l, resp, nil, from)
bn.sent[nonce] = resp
bn.notifyLive()
case *v5wire.Findnode:
resp := &v5wire.Nodes{ReqID: append([]byte(nil), p.ReqID...), RespCount: 1}
for _, n := range bn.known {
if slices.Contains(p.Distances, uint(enode.LogDist(n.ID(), bn.id()))) {
resp.Nodes = append(resp.Nodes, n.Record())
}
}
nonce := bn.conn.writeTo(bn.l, resp, nil, from)
bn.sent[nonce] = resp
case *v5wire.TalkRequest:
resp := &v5wire.TalkResponse{ReqID: append([]byte(nil), p.ReqID...)}
nonce := bn.conn.writeTo(bn.l, resp, nil, from)
bn.sent[nonce] = resp
case *readError:
if netutil.IsTemporaryError(p.err) || v5wire.IsInvalidHeader(p.err) {
continue
}
bn.conn.logf("shutting down: %v", p.err)
return
}
}
}
func (bn *bystander) notifyLive() {
if bn.liveCh != nil {
bn.liveCh <- bn.id()
bn.liveCh = nil
}
}