This adds a test suite for discovery v4. The test suite is a port of the Hive suite for
discovery, and will replace the current suite on Hive soon-ish. The tests can be
run locally with this command:
    devp2p discv4 test -remote enode//...
Co-authored-by: Felix Lange <fjl@twurst.com>
		
	
			
		
			
				
	
	
		
			468 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
			
		
		
	
	
			468 lines
		
	
	
		
			14 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 v4test
 | |
| 
 | |
| import (
 | |
| 	"bytes"
 | |
| 	"crypto/rand"
 | |
| 	"fmt"
 | |
| 	"net"
 | |
| 	"reflect"
 | |
| 	"time"
 | |
| 
 | |
| 	"github.com/ethereum/go-ethereum/crypto"
 | |
| 	"github.com/ethereum/go-ethereum/internal/utesting"
 | |
| 	"github.com/ethereum/go-ethereum/p2p/discover/v4wire"
 | |
| )
 | |
| 
 | |
| const (
 | |
| 	expiration  = 20 * time.Second
 | |
| 	wrongPacket = 66
 | |
| 	macSize     = 256 / 8
 | |
| )
 | |
| 
 | |
| var (
 | |
| 	// Remote node under test
 | |
| 	Remote string
 | |
| 	// IP where the first tester is listening, port will be assigned
 | |
| 	Listen1 string = "127.0.0.1"
 | |
| 	// IP where the second tester is listening, port will be assigned
 | |
| 	// Before running the test, you may have to `sudo ifconfig lo0 add 127.0.0.2` (on MacOS at least)
 | |
| 	Listen2 string = "127.0.0.2"
 | |
| )
 | |
| 
 | |
| type pingWithJunk struct {
 | |
| 	Version    uint
 | |
| 	From, To   v4wire.Endpoint
 | |
| 	Expiration uint64
 | |
| 	JunkData1  uint
 | |
| 	JunkData2  []byte
 | |
| }
 | |
| 
 | |
| func (req *pingWithJunk) Name() string { return "PING/v4" }
 | |
| func (req *pingWithJunk) Kind() byte   { return v4wire.PingPacket }
 | |
| 
 | |
| type pingWrongType struct {
 | |
| 	Version    uint
 | |
| 	From, To   v4wire.Endpoint
 | |
| 	Expiration uint64
 | |
| }
 | |
| 
 | |
| func (req *pingWrongType) Name() string { return "WRONG/v4" }
 | |
| func (req *pingWrongType) Kind() byte   { return wrongPacket }
 | |
| 
 | |
| func futureExpiration() uint64 {
 | |
| 	return uint64(time.Now().Add(expiration).Unix())
 | |
| }
 | |
| 
 | |
| // This test just sends a PING packet and expects a response.
 | |
| func BasicPing(t *utesting.T) {
 | |
| 	te := newTestEnv(Remote, Listen1, Listen2)
 | |
| 	defer te.close()
 | |
| 
 | |
| 	pingHash := te.send(te.l1, &v4wire.Ping{
 | |
| 		Version:    4,
 | |
| 		From:       te.localEndpoint(te.l1),
 | |
| 		To:         te.remoteEndpoint(),
 | |
| 		Expiration: futureExpiration(),
 | |
| 	})
 | |
| 
 | |
| 	reply, _, _ := te.read(te.l1)
 | |
| 	if err := te.checkPong(reply, pingHash); err != nil {
 | |
| 		t.Fatal(err)
 | |
| 	}
 | |
| }
 | |
| 
 | |
| // checkPong verifies that reply is a valid PONG matching the given ping hash.
 | |
| func (te *testenv) checkPong(reply v4wire.Packet, pingHash []byte) error {
 | |
| 	if reply == nil || reply.Kind() != v4wire.PongPacket {
 | |
| 		return fmt.Errorf("expected PONG reply, got %v", reply)
 | |
| 	}
 | |
| 	pong := reply.(*v4wire.Pong)
 | |
| 	if !bytes.Equal(pong.ReplyTok, pingHash) {
 | |
| 		return fmt.Errorf("PONG reply token mismatch: got %x, want %x", pong.ReplyTok, pingHash)
 | |
| 	}
 | |
| 	wantEndpoint := te.localEndpoint(te.l1)
 | |
| 	if !reflect.DeepEqual(pong.To, wantEndpoint) {
 | |
| 		return fmt.Errorf("PONG 'to' endpoint mismatch: got %+v, want %+v", pong.To, wantEndpoint)
 | |
| 	}
 | |
| 	if v4wire.Expired(pong.Expiration) {
 | |
| 		return fmt.Errorf("PONG is expired (%v)", pong.Expiration)
 | |
| 	}
 | |
| 	return nil
 | |
| }
 | |
| 
 | |
| // This test sends a PING packet with wrong 'to' field and expects a PONG response.
 | |
| func PingWrongTo(t *utesting.T) {
 | |
| 	te := newTestEnv(Remote, Listen1, Listen2)
 | |
| 	defer te.close()
 | |
| 
 | |
| 	wrongEndpoint := v4wire.Endpoint{IP: net.ParseIP("192.0.2.0")}
 | |
| 	pingHash := te.send(te.l1, &v4wire.Ping{
 | |
| 		Version:    4,
 | |
| 		From:       te.localEndpoint(te.l1),
 | |
| 		To:         wrongEndpoint,
 | |
| 		Expiration: futureExpiration(),
 | |
| 	})
 | |
| 
 | |
| 	reply, _, _ := te.read(te.l1)
 | |
| 	if err := te.checkPong(reply, pingHash); err != nil {
 | |
| 		t.Fatal(err)
 | |
| 	}
 | |
| }
 | |
| 
 | |
| // This test sends a PING packet with wrong 'from' field and expects a PONG response.
 | |
| func PingWrongFrom(t *utesting.T) {
 | |
| 	te := newTestEnv(Remote, Listen1, Listen2)
 | |
| 	defer te.close()
 | |
| 
 | |
| 	wrongEndpoint := v4wire.Endpoint{IP: net.ParseIP("192.0.2.0")}
 | |
| 	pingHash := te.send(te.l1, &v4wire.Ping{
 | |
| 		Version:    4,
 | |
| 		From:       wrongEndpoint,
 | |
| 		To:         te.remoteEndpoint(),
 | |
| 		Expiration: futureExpiration(),
 | |
| 	})
 | |
| 
 | |
| 	reply, _, _ := te.read(te.l1)
 | |
| 	if err := te.checkPong(reply, pingHash); err != nil {
 | |
| 		t.Fatal(err)
 | |
| 	}
 | |
| }
 | |
| 
 | |
| // This test sends a PING packet with additional data at the end and expects a PONG
 | |
| // response. The remote node should respond because EIP-8 mandates ignoring additional
 | |
| // trailing data.
 | |
| func PingExtraData(t *utesting.T) {
 | |
| 	te := newTestEnv(Remote, Listen1, Listen2)
 | |
| 	defer te.close()
 | |
| 
 | |
| 	pingHash := te.send(te.l1, &pingWithJunk{
 | |
| 		Version:    4,
 | |
| 		From:       te.localEndpoint(te.l1),
 | |
| 		To:         te.remoteEndpoint(),
 | |
| 		Expiration: futureExpiration(),
 | |
| 		JunkData1:  42,
 | |
| 		JunkData2:  []byte{9, 8, 7, 6, 5, 4, 3, 2, 1},
 | |
| 	})
 | |
| 
 | |
| 	reply, _, _ := te.read(te.l1)
 | |
| 	if err := te.checkPong(reply, pingHash); err != nil {
 | |
| 		t.Fatal(err)
 | |
| 	}
 | |
| }
 | |
| 
 | |
| // This test sends a PING packet with additional data and wrong 'from' field
 | |
| // and expects a PONG response.
 | |
| func PingExtraDataWrongFrom(t *utesting.T) {
 | |
| 	te := newTestEnv(Remote, Listen1, Listen2)
 | |
| 	defer te.close()
 | |
| 
 | |
| 	wrongEndpoint := v4wire.Endpoint{IP: net.ParseIP("192.0.2.0")}
 | |
| 	req := pingWithJunk{
 | |
| 		Version:    4,
 | |
| 		From:       wrongEndpoint,
 | |
| 		To:         te.remoteEndpoint(),
 | |
| 		Expiration: futureExpiration(),
 | |
| 		JunkData1:  42,
 | |
| 		JunkData2:  []byte{9, 8, 7, 6, 5, 4, 3, 2, 1},
 | |
| 	}
 | |
| 	pingHash := te.send(te.l1, &req)
 | |
| 	reply, _, _ := te.read(te.l1)
 | |
| 	if err := te.checkPong(reply, pingHash); err != nil {
 | |
| 		t.Fatal(err)
 | |
| 	}
 | |
| }
 | |
| 
 | |
| // This test sends a PING packet with an expiration in the past.
 | |
| // The remote node should not respond.
 | |
| func PingPastExpiration(t *utesting.T) {
 | |
| 	te := newTestEnv(Remote, Listen1, Listen2)
 | |
| 	defer te.close()
 | |
| 
 | |
| 	te.send(te.l1, &v4wire.Ping{
 | |
| 		Version:    4,
 | |
| 		From:       te.localEndpoint(te.l1),
 | |
| 		To:         te.remoteEndpoint(),
 | |
| 		Expiration: -futureExpiration(),
 | |
| 	})
 | |
| 
 | |
| 	reply, _, _ := te.read(te.l1)
 | |
| 	if reply != nil {
 | |
| 		t.Fatal("Expected no reply, got", reply)
 | |
| 	}
 | |
| }
 | |
| 
 | |
| // This test sends an invalid packet. The remote node should not respond.
 | |
| func WrongPacketType(t *utesting.T) {
 | |
| 	te := newTestEnv(Remote, Listen1, Listen2)
 | |
| 	defer te.close()
 | |
| 
 | |
| 	te.send(te.l1, &pingWrongType{
 | |
| 		Version:    4,
 | |
| 		From:       te.localEndpoint(te.l1),
 | |
| 		To:         te.remoteEndpoint(),
 | |
| 		Expiration: futureExpiration(),
 | |
| 	})
 | |
| 
 | |
| 	reply, _, _ := te.read(te.l1)
 | |
| 	if reply != nil {
 | |
| 		t.Fatal("Expected no reply, got", reply)
 | |
| 	}
 | |
| }
 | |
| 
 | |
| // This test verifies that the default behaviour of ignoring 'from' fields is unaffected by
 | |
| // the bonding process. After bonding, it pings the target with a different from endpoint.
 | |
| func BondThenPingWithWrongFrom(t *utesting.T) {
 | |
| 	te := newTestEnv(Remote, Listen1, Listen2)
 | |
| 	defer te.close()
 | |
| 	bond(t, te)
 | |
| 
 | |
| 	wrongEndpoint := v4wire.Endpoint{IP: net.ParseIP("192.0.2.0")}
 | |
| 	pingHash := te.send(te.l1, &v4wire.Ping{
 | |
| 		Version:    4,
 | |
| 		From:       wrongEndpoint,
 | |
| 		To:         te.remoteEndpoint(),
 | |
| 		Expiration: futureExpiration(),
 | |
| 	})
 | |
| 
 | |
| 	reply, _, _ := te.read(te.l1)
 | |
| 	if err := te.checkPong(reply, pingHash); err != nil {
 | |
| 		t.Fatal(err)
 | |
| 	}
 | |
| }
 | |
| 
 | |
| // This test just sends FINDNODE. The remote node should not reply
 | |
| // because the endpoint proof has not completed.
 | |
| func FindnodeWithoutEndpointProof(t *utesting.T) {
 | |
| 	te := newTestEnv(Remote, Listen1, Listen2)
 | |
| 	defer te.close()
 | |
| 
 | |
| 	req := v4wire.Findnode{Expiration: futureExpiration()}
 | |
| 	rand.Read(req.Target[:])
 | |
| 	te.send(te.l1, &req)
 | |
| 
 | |
| 	reply, _, _ := te.read(te.l1)
 | |
| 	if reply != nil {
 | |
| 		t.Fatal("Expected no response, got", reply)
 | |
| 	}
 | |
| }
 | |
| 
 | |
| // BasicFindnode sends a FINDNODE request after performing the endpoint
 | |
| // proof. The remote node should respond.
 | |
| func BasicFindnode(t *utesting.T) {
 | |
| 	te := newTestEnv(Remote, Listen1, Listen2)
 | |
| 	defer te.close()
 | |
| 	bond(t, te)
 | |
| 
 | |
| 	findnode := v4wire.Findnode{Expiration: futureExpiration()}
 | |
| 	rand.Read(findnode.Target[:])
 | |
| 	te.send(te.l1, &findnode)
 | |
| 
 | |
| 	reply, _, err := te.read(te.l1)
 | |
| 	if err != nil {
 | |
| 		t.Fatal("read find nodes", err)
 | |
| 	}
 | |
| 	if reply.Kind() != v4wire.NeighborsPacket {
 | |
| 		t.Fatal("Expected neighbors, got", reply.Name())
 | |
| 	}
 | |
| }
 | |
| 
 | |
| // This test sends an unsolicited NEIGHBORS packet after the endpoint proof, then sends
 | |
| // FINDNODE to read the remote table. The remote node should not return the node contained
 | |
| // in the unsolicited NEIGHBORS packet.
 | |
| func UnsolicitedNeighbors(t *utesting.T) {
 | |
| 	te := newTestEnv(Remote, Listen1, Listen2)
 | |
| 	defer te.close()
 | |
| 	bond(t, te)
 | |
| 
 | |
| 	// Send unsolicited NEIGHBORS response.
 | |
| 	fakeKey, _ := crypto.GenerateKey()
 | |
| 	encFakeKey := v4wire.EncodePubkey(&fakeKey.PublicKey)
 | |
| 	neighbors := v4wire.Neighbors{
 | |
| 		Expiration: futureExpiration(),
 | |
| 		Nodes: []v4wire.Node{{
 | |
| 			ID:  encFakeKey,
 | |
| 			IP:  net.IP{1, 2, 3, 4},
 | |
| 			UDP: 30303,
 | |
| 			TCP: 30303,
 | |
| 		}},
 | |
| 	}
 | |
| 	te.send(te.l1, &neighbors)
 | |
| 
 | |
| 	// Check if the remote node included the fake node.
 | |
| 	te.send(te.l1, &v4wire.Findnode{
 | |
| 		Expiration: futureExpiration(),
 | |
| 		Target:     encFakeKey,
 | |
| 	})
 | |
| 
 | |
| 	reply, _, err := te.read(te.l1)
 | |
| 	if err != nil {
 | |
| 		t.Fatal("read find nodes", err)
 | |
| 	}
 | |
| 	if reply.Kind() != v4wire.NeighborsPacket {
 | |
| 		t.Fatal("Expected neighbors, got", reply.Name())
 | |
| 	}
 | |
| 	nodes := reply.(*v4wire.Neighbors).Nodes
 | |
| 	if contains(nodes, encFakeKey) {
 | |
| 		t.Fatal("neighbors response contains node from earlier unsolicited neighbors response")
 | |
| 	}
 | |
| }
 | |
| 
 | |
| // This test sends FINDNODE with an expiration timestamp in the past.
 | |
| // The remote node should not respond.
 | |
| func FindnodePastExpiration(t *utesting.T) {
 | |
| 	te := newTestEnv(Remote, Listen1, Listen2)
 | |
| 	defer te.close()
 | |
| 	bond(t, te)
 | |
| 
 | |
| 	findnode := v4wire.Findnode{Expiration: -futureExpiration()}
 | |
| 	rand.Read(findnode.Target[:])
 | |
| 	te.send(te.l1, &findnode)
 | |
| 
 | |
| 	for {
 | |
| 		reply, _, _ := te.read(te.l1)
 | |
| 		if reply == nil {
 | |
| 			return
 | |
| 		} else if reply.Kind() == v4wire.NeighborsPacket {
 | |
| 			t.Fatal("Unexpected NEIGHBORS response for expired FINDNODE request")
 | |
| 		}
 | |
| 	}
 | |
| }
 | |
| 
 | |
| // bond performs the endpoint proof with the remote node.
 | |
| func bond(t *utesting.T, te *testenv) {
 | |
| 	te.send(te.l1, &v4wire.Ping{
 | |
| 		Version:    4,
 | |
| 		From:       te.localEndpoint(te.l1),
 | |
| 		To:         te.remoteEndpoint(),
 | |
| 		Expiration: futureExpiration(),
 | |
| 	})
 | |
| 
 | |
| 	var gotPing, gotPong bool
 | |
| 	for !gotPing || !gotPong {
 | |
| 		req, hash, err := te.read(te.l1)
 | |
| 		if err != nil {
 | |
| 			t.Fatal(err)
 | |
| 		}
 | |
| 		switch req.(type) {
 | |
| 		case *v4wire.Ping:
 | |
| 			te.send(te.l1, &v4wire.Pong{
 | |
| 				To:         te.remoteEndpoint(),
 | |
| 				ReplyTok:   hash,
 | |
| 				Expiration: futureExpiration(),
 | |
| 			})
 | |
| 			gotPing = true
 | |
| 		case *v4wire.Pong:
 | |
| 			// TODO: maybe verify pong data here
 | |
| 			gotPong = true
 | |
| 		}
 | |
| 	}
 | |
| }
 | |
| 
 | |
| // This test attempts to perform a traffic amplification attack against a
 | |
| // 'victim' endpoint using FINDNODE. In this attack scenario, the attacker
 | |
| // attempts to complete the endpoint proof non-interactively by sending a PONG
 | |
| // with mismatching reply token from the 'victim' endpoint. The attack works if
 | |
| // the remote node does not verify the PONG reply token field correctly. The
 | |
| // attacker could then perform traffic amplification by sending many FINDNODE
 | |
| // requests to the discovery node, which would reply to the 'victim' address.
 | |
| func FindnodeAmplificationInvalidPongHash(t *utesting.T) {
 | |
| 	te := newTestEnv(Remote, Listen1, Listen2)
 | |
| 	defer te.close()
 | |
| 
 | |
| 	// Send PING to start endpoint verification.
 | |
| 	te.send(te.l1, &v4wire.Ping{
 | |
| 		Version:    4,
 | |
| 		From:       te.localEndpoint(te.l1),
 | |
| 		To:         te.remoteEndpoint(),
 | |
| 		Expiration: futureExpiration(),
 | |
| 	})
 | |
| 
 | |
| 	var gotPing, gotPong bool
 | |
| 	for !gotPing || !gotPong {
 | |
| 		req, _, err := te.read(te.l1)
 | |
| 		if err != nil {
 | |
| 			t.Fatal(err)
 | |
| 		}
 | |
| 		switch req.(type) {
 | |
| 		case *v4wire.Ping:
 | |
| 			// Send PONG from this node ID, but with invalid ReplyTok.
 | |
| 			te.send(te.l1, &v4wire.Pong{
 | |
| 				To:         te.remoteEndpoint(),
 | |
| 				ReplyTok:   make([]byte, macSize),
 | |
| 				Expiration: futureExpiration(),
 | |
| 			})
 | |
| 			gotPing = true
 | |
| 		case *v4wire.Pong:
 | |
| 			gotPong = true
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	// Now send FINDNODE. The remote node should not respond because our
 | |
| 	// PONG did not reference the PING hash.
 | |
| 	findnode := v4wire.Findnode{Expiration: futureExpiration()}
 | |
| 	rand.Read(findnode.Target[:])
 | |
| 	te.send(te.l1, &findnode)
 | |
| 
 | |
| 	// If we receive a NEIGHBORS response, the attack worked and the test fails.
 | |
| 	reply, _, _ := te.read(te.l1)
 | |
| 	if reply != nil && reply.Kind() == v4wire.NeighborsPacket {
 | |
| 		t.Error("Got neighbors")
 | |
| 	}
 | |
| }
 | |
| 
 | |
| // This test attempts to perform a traffic amplification attack using FINDNODE.
 | |
| // The attack works if the remote node does not verify the IP address of FINDNODE
 | |
| // against the endpoint verification proof done by PING/PONG.
 | |
| func FindnodeAmplificationWrongIP(t *utesting.T) {
 | |
| 	te := newTestEnv(Remote, Listen1, Listen2)
 | |
| 	defer te.close()
 | |
| 
 | |
| 	// Do the endpoint proof from the l1 IP.
 | |
| 	bond(t, te)
 | |
| 
 | |
| 	// Now send FINDNODE from the same node ID, but different IP address.
 | |
| 	// The remote node should not respond.
 | |
| 	findnode := v4wire.Findnode{Expiration: futureExpiration()}
 | |
| 	rand.Read(findnode.Target[:])
 | |
| 	te.send(te.l2, &findnode)
 | |
| 
 | |
| 	// If we receive a NEIGHBORS response, the attack worked and the test fails.
 | |
| 	reply, _, _ := te.read(te.l2)
 | |
| 	if reply != nil {
 | |
| 		t.Error("Got NEIGHORS response for FINDNODE from wrong IP")
 | |
| 	}
 | |
| }
 | |
| 
 | |
| var AllTests = []utesting.Test{
 | |
| 	{Name: "Ping/Basic", Fn: BasicPing},
 | |
| 	{Name: "Ping/WrongTo", Fn: PingWrongTo},
 | |
| 	{Name: "Ping/WrongFrom", Fn: PingWrongFrom},
 | |
| 	{Name: "Ping/ExtraData", Fn: PingExtraData},
 | |
| 	{Name: "Ping/ExtraDataWrongFrom", Fn: PingExtraDataWrongFrom},
 | |
| 	{Name: "Ping/PastExpiration", Fn: PingPastExpiration},
 | |
| 	{Name: "Ping/WrongPacketType", Fn: WrongPacketType},
 | |
| 	{Name: "Ping/BondThenPingWithWrongFrom", Fn: BondThenPingWithWrongFrom},
 | |
| 	{Name: "Findnode/WithoutEndpointProof", Fn: FindnodeWithoutEndpointProof},
 | |
| 	{Name: "Findnode/BasicFindnode", Fn: BasicFindnode},
 | |
| 	{Name: "Findnode/UnsolicitedNeighbors", Fn: UnsolicitedNeighbors},
 | |
| 	{Name: "Findnode/PastExpiration", Fn: FindnodePastExpiration},
 | |
| 	{Name: "Amplification/InvalidPongHash", Fn: FindnodeAmplificationInvalidPongHash},
 | |
| 	{Name: "Amplification/WrongIP", Fn: FindnodeAmplificationWrongIP},
 | |
| }
 |