From af7109e83cfcc6fb0ba8ac7de296bedb9bc563af Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 13:15:18 +0000 Subject: [PATCH] feat(p2p): enforce peer build versions on handshake --- commands_test.go | 44 ++++++++++++++++++++++++ config/config.go | 65 ++++++++++++++++++++---------------- p2p/peer_version.go | 72 ++++++++++++++++++++++++++++++++++++++++ p2p/peer_version_test.go | 59 ++++++++++++++++++++++++++++++++ sync_loop.go | 15 +++++++++ 5 files changed, 226 insertions(+), 29 deletions(-) create mode 100644 p2p/peer_version.go create mode 100644 p2p/peer_version_test.go diff --git a/commands_test.go b/commands_test.go index acfdedd..7fda68b 100644 --- a/commands_test.go +++ b/commands_test.go @@ -17,6 +17,7 @@ import ( "dappco.re/go/core/blockchain/chain" "dappco.re/go/core/blockchain/config" + "dappco.re/go/core/blockchain/p2p" "github.com/spf13/cobra" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -341,3 +342,46 @@ func TestRunChainSyncOnce_Bad_ReportsHeightLookupError(t *testing.T) { assert.ErrorContains(t, err, "read local height") assert.ErrorContains(t, err, "height failed") } + +func TestValidateHandshakePeer_Good_AcceptsMatchingNetworkAndVersion(t *testing.T) { + resp := &p2p.HandshakeResponse{ + NodeData: p2p.NodeData{ + NetworkID: config.NetworkIDTestnet, + }, + PayloadData: p2p.CoreSyncData{ + ClientVersion: config.ClientVersion, + }, + } + + require.NoError(t, validateHandshakePeer(&config.Testnet, resp)) +} + +func TestValidateHandshakePeer_Bad_RejectsNetworkMismatch(t *testing.T) { + resp := &p2p.HandshakeResponse{ + NodeData: p2p.NodeData{ + NetworkID: config.NetworkIDMainnet, + }, + PayloadData: p2p.CoreSyncData{ + ClientVersion: config.ClientVersion, + }, + } + + err := validateHandshakePeer(&config.Testnet, resp) + require.Error(t, err) + assert.ErrorContains(t, err, "network ID") +} + +func TestValidateHandshakePeer_Bad_RejectsStalePeerVersion(t *testing.T) { + resp := &p2p.HandshakeResponse{ + NodeData: p2p.NodeData{ + NetworkID: config.NetworkIDMainnet, + }, + PayloadData: p2p.CoreSyncData{ + ClientVersion: "5.9.9", + }, + } + + err := validateHandshakePeer(&config.Mainnet, resp) + require.Error(t, err) + assert.ErrorContains(t, err, "below minimum") +} diff --git a/config/config.go b/config/config.go index 1d73892..0ccd8af 100644 --- a/config/config.go +++ b/config/config.go @@ -215,14 +215,14 @@ const ( // --------------------------------------------------------------------------- const ( - BlockMajorVersionGenesis uint8 = 1 - BlockMinorVersionGenesis uint8 = 0 - BlockMajorVersionInitial uint8 = 0 - HF1BlockMajorVersion uint8 = 1 - HF3BlockMajorVersion uint8 = 2 - HF3BlockMinorVersion uint8 = 0 - CurrentBlockMajorVersion uint8 = 3 - CurrentBlockMinorVersion uint8 = 0 + BlockMajorVersionGenesis uint8 = 1 + BlockMinorVersionGenesis uint8 = 0 + BlockMajorVersionInitial uint8 = 0 + HF1BlockMajorVersion uint8 = 1 + HF3BlockMajorVersion uint8 = 2 + HF3BlockMinorVersion uint8 = 0 + CurrentBlockMajorVersion uint8 = 3 + CurrentBlockMinorVersion uint8 = 0 ) // --------------------------------------------------------------------------- @@ -230,11 +230,11 @@ const ( // --------------------------------------------------------------------------- const ( - TransactionVersionInitial uint8 = 0 - TransactionVersionPreHF4 uint8 = 1 - TransactionVersionPostHF4 uint8 = 2 - TransactionVersionPostHF5 uint8 = 3 - CurrentTransactionVersion uint8 = 3 + TransactionVersionInitial uint8 = 0 + TransactionVersionPreHF4 uint8 = 1 + TransactionVersionPostHF4 uint8 = 2 + TransactionVersionPostHF5 uint8 = 3 + CurrentTransactionVersion uint8 = 3 ) // --------------------------------------------------------------------------- @@ -242,12 +242,12 @@ const ( // --------------------------------------------------------------------------- const ( - PosScanWindow uint64 = 60 * 10 // 10 minutes in seconds - PosScanStep uint64 = 15 // seconds - PosModifierInterval uint64 = 10 - PosMinimumCoinstakeAge uint64 = 10 // blocks - PosStrictSequenceLimit uint64 = 20 - PosStarterKernelHash = "00000000000000000006382a8d8f94588ce93a1351924f6ccb9e07dd287c6e4b" + PosScanWindow uint64 = 60 * 10 // 10 minutes in seconds + PosScanStep uint64 = 15 // seconds + PosModifierInterval uint64 = 10 + PosMinimumCoinstakeAge uint64 = 10 // blocks + PosStrictSequenceLimit uint64 = 20 + PosStarterKernelHash = "00000000000000000006382a8d8f94588ce93a1351924f6ccb9e07dd287c6e4b" ) // --------------------------------------------------------------------------- @@ -255,13 +255,21 @@ const ( // --------------------------------------------------------------------------- const ( - P2PLocalWhitePeerlistLimit uint64 = 1000 - P2PLocalGrayPeerlistLimit uint64 = 5000 - P2PDefaultConnectionsCount uint64 = 8 - P2PDefaultHandshakeInterval uint64 = 60 // seconds - P2PDefaultPacketMaxSize uint64 = 50_000_000 - P2PIPBlockTime uint64 = 60 * 60 * 24 // 24 hours - P2PIPFailsBeforeBlock uint64 = 10 + P2PLocalWhitePeerlistLimit uint64 = 1000 + P2PLocalGrayPeerlistLimit uint64 = 5000 + P2PDefaultConnectionsCount uint64 = 8 + P2PDefaultHandshakeInterval uint64 = 60 // seconds + P2PDefaultPacketMaxSize uint64 = 50_000_000 + P2PIPBlockTime uint64 = 60 * 60 * 24 // 24 hours + P2PIPFailsBeforeBlock uint64 = 10 + + // MinimumPeerBuildVersionMainnet is the minimum accepted peer build + // version on mainnet after the HF5-era client version enforcement. + MinimumPeerBuildVersionMainnet uint64 = 601 + + // MinimumPeerBuildVersionTestnet is the minimum accepted peer build + // version on testnet after the HF5-era client version enforcement. + MinimumPeerBuildVersionTestnet uint64 = 2 ) // --------------------------------------------------------------------------- @@ -304,7 +312,6 @@ var NetworkIDTestnet = [16]byte{ // is below the minimum for the current hard-fork era. const ClientVersion = "6.0.1.2[go-blockchain]" - // --------------------------------------------------------------------------- // Currency identity // --------------------------------------------------------------------------- @@ -465,7 +472,7 @@ var Mainnet = ChainConfig{ HF4MandatoryDecoySetSize: HF4MandatoryDecoySetSize, MinedMoneyUnlockWindow: MinedMoneyUnlockWindow, P2PMaintainersPubKey: "8f138bb73f6d663a3746a542770781a09579a7b84cb4125249e95530824ee607", - NetworkID: NetworkIDMainnet, + NetworkID: NetworkIDMainnet, } // Testnet holds the chain configuration for the Lethean testnet. @@ -501,5 +508,5 @@ var Testnet = ChainConfig{ HF4MandatoryDecoySetSize: HF4MandatoryDecoySetSize, MinedMoneyUnlockWindow: MinedMoneyUnlockWindow, P2PMaintainersPubKey: "8f138bb73f6d663a3746a542770781a09579a7b84cb4125249e95530824ee607", - NetworkID: NetworkIDTestnet, + NetworkID: NetworkIDTestnet, } diff --git a/p2p/peer_version.go b/p2p/peer_version.go new file mode 100644 index 0000000..1a9bc4d --- /dev/null +++ b/p2p/peer_version.go @@ -0,0 +1,72 @@ +// Copyright (c) 2017-2026 Lethean (https://lt.hn) +// +// Licensed under the European Union Public Licence (EUPL) version 1.2. +// SPDX-License-Identifier: EUPL-1.2 + +package p2p + +import ( + "fmt" + "regexp" + "strconv" + + "dappco.re/go/core/blockchain/config" +) + +var clientVersionPattern = regexp.MustCompile(`(\d+)(?:\.(\d+))?(?:\.(\d+))?`) + +// ParseBuildVersion extracts the C++-style build version from a peer client +// version string. The first three numeric components are interpreted as +// major.minor.revision and encoded as major*100 + minor*10 + revision. +func ParseBuildVersion(clientVersion string) (uint64, error) { + match := clientVersionPattern.FindStringSubmatch(clientVersion) + if len(match) == 0 { + return 0, fmt.Errorf("parse peer client version %q: no numeric version found", clientVersion) + } + + parts := [3]uint64{} + for i := 1; i <= 3; i++ { + if match[i] == "" { + continue + } + n, err := strconv.ParseUint(match[i], 10, 64) + if err != nil { + return 0, fmt.Errorf("parse peer client version %q: %w", clientVersion, err) + } + parts[i-1] = n + } + + return parts[0]*100 + parts[1]*10 + parts[2], nil +} + +// MinimumPeerBuildVersion returns the minimum allowed peer build version for +// the given network ID. +func MinimumPeerBuildVersion(networkID [16]byte) (uint64, error) { + switch networkID { + case config.NetworkIDMainnet: + return config.MinimumPeerBuildVersionMainnet, nil + case config.NetworkIDTestnet: + return config.MinimumPeerBuildVersionTestnet, nil + default: + return 0, fmt.Errorf("unknown network ID %x", networkID) + } +} + +// ValidatePeerClientVersion rejects peers whose advertised client version is +// below the minimum build required for the active network. +func ValidatePeerClientVersion(networkID [16]byte, clientVersion string) error { + minBuild, err := MinimumPeerBuildVersion(networkID) + if err != nil { + return err + } + + build, err := ParseBuildVersion(clientVersion) + if err != nil { + return err + } + if build < minBuild { + return fmt.Errorf("peer build version %d from %q is below minimum %d", build, clientVersion, minBuild) + } + + return nil +} diff --git a/p2p/peer_version_test.go b/p2p/peer_version_test.go new file mode 100644 index 0000000..2afc8f4 --- /dev/null +++ b/p2p/peer_version_test.go @@ -0,0 +1,59 @@ +// Copyright (c) 2017-2026 Lethean (https://lt.hn) +// +// Licensed under the European Union Public Licence (EUPL) version 1.2. +// SPDX-License-Identifier: EUPL-1.2 + +package p2p + +import ( + "testing" + + "dappco.re/go/core/blockchain/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseBuildVersion_Good_CurrentClientVersion(t *testing.T) { + build, err := ParseBuildVersion(config.ClientVersion) + require.NoError(t, err) + assert.Equal(t, uint64(601), build) +} + +func TestParseBuildVersion_Good_EmbeddedPrefix(t *testing.T) { + build, err := ParseBuildVersion("Zano/6.0.1.2[commit]") + require.NoError(t, err) + assert.Equal(t, uint64(601), build) +} + +func TestParseBuildVersion_Bad_RejectsNonNumericVersion(t *testing.T) { + _, err := ParseBuildVersion("go-blockchain") + require.Error(t, err) + assert.ErrorContains(t, err, "no numeric version found") +} + +func TestMinimumPeerBuildVersion_Good(t *testing.T) { + build, err := MinimumPeerBuildVersion(config.NetworkIDMainnet) + require.NoError(t, err) + assert.Equal(t, config.MinimumPeerBuildVersionMainnet, build) + + build, err = MinimumPeerBuildVersion(config.NetworkIDTestnet) + require.NoError(t, err) + assert.Equal(t, config.MinimumPeerBuildVersionTestnet, build) +} + +func TestValidatePeerClientVersion_Good_MainnetAndTestnet(t *testing.T) { + require.NoError(t, ValidatePeerClientVersion(config.NetworkIDMainnet, config.ClientVersion)) + require.NoError(t, ValidatePeerClientVersion(config.NetworkIDTestnet, "test/0.2")) +} + +func TestValidatePeerClientVersion_Bad_RejectsBelowMinimum(t *testing.T) { + err := ValidatePeerClientVersion(config.NetworkIDMainnet, "5.9.9") + require.Error(t, err) + assert.ErrorContains(t, err, "below minimum") +} + +func TestValidatePeerClientVersion_Bad_RejectsUnknownNetwork(t *testing.T) { + err := ValidatePeerClientVersion([16]byte{}, config.ClientVersion) + require.Error(t, err) + assert.ErrorContains(t, err, "unknown network ID") +} diff --git a/sync_loop.go b/sync_loop.go index 136e3d0..6abc3d0 100644 --- a/sync_loop.go +++ b/sync_loop.go @@ -126,6 +126,9 @@ func runChainSyncOnce(ctx context.Context, blockchain *chain.Chain, chainConfig if err := handshakeResp.Decode(data); err != nil { return coreerr.E("runChainSyncOnce", "decode handshake", err) } + if err := validateHandshakePeer(chainConfig, &handshakeResp); err != nil { + return coreerr.E("runChainSyncOnce", "validate handshake", err) + } if err := tcpConn.SetDeadline(time.Time{}); err != nil { return coreerr.E("runChainSyncOnce", "clear handshake deadline", err) } @@ -139,3 +142,15 @@ func runChainSyncOnce(ctx context.Context, blockchain *chain.Chain, chainConfig return blockchain.P2PSync(ctx, p2pConn, opts) } + +func validateHandshakePeer(chainConfig *config.ChainConfig, handshakeResp *p2p.HandshakeResponse) error { + if handshakeResp.NodeData.NetworkID != chainConfig.NetworkID { + return fmt.Errorf("peer network ID %x does not match expected %x", handshakeResp.NodeData.NetworkID, chainConfig.NetworkID) + } + + if err := p2p.ValidatePeerClientVersion(chainConfig.NetworkID, handshakeResp.PayloadData.ClientVersion); err != nil { + return err + } + + return nil +}