Files
node/mesh/peer/handshake_test.go
zeekayandHanzo Dev 37e2940ead mesh is the peer layer, network is the validator set
Two packages were named for the same word and meant different things. network/
dialed peers, exchanged keys, throttled and tracked them; nets/ tracked which
chains share a validator set and when they have all bootstrapped. One letter
apart, and neither name said which was which.

The peer layer is mesh — the word this estate already uses for it, in
lux-rs/node's own mesh.rs. That frees network for the thing that is actually a
network: a set of chains sharing a validator set. A network holds chains; a
chain belongs to one network.

github.com/luxfi/p2p stays what it is, the gossip library the VMs use. Three
concerns, three names, none of them borrowed from another.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-07 21:17:09 -07:00

306 lines
10 KiB
Go

// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package peer
import (
"crypto/rand"
"testing"
"github.com/stretchr/testify/require"
"github.com/luxfi/ids"
"github.com/luxfi/node/mesh/kem"
)
// newTestIdentities returns two fresh ML-DSA-65 identities and a shared
// chain ID. Used by every handshake test that needs a happy-path setup.
func newTestIdentities(t *testing.T) (initiator, responder *LocalIdentity, chainID [32]byte) {
t.Helper()
require := require.New(t)
initNodeID := ids.GenerateTestNodeID()
respNodeID := ids.GenerateTestNodeID()
init, err := NewLocalIdentity(initNodeID)
require.NoError(err)
resp, err := NewLocalIdentity(respNodeID)
require.NoError(err)
_, err = rand.Read(chainID[:])
require.NoError(err)
return init, resp, chainID
}
// runHandshake drives a full INIT/RESP/FINISH on a strict-PQ config under
// the supplied KEM scheme. Returns both result objects.
func runHandshake(
t *testing.T,
initiator, responder *LocalIdentity,
chainID [32]byte,
scheme kem.KeyExchangeID,
) (*HandshakeResult, *HandshakeResult) {
t.Helper()
require := require.New(t)
cfg := &HandshakeConfig{
Profile: ProfileStrictPQ,
ChainID: chainID,
KEMScheme: scheme,
ForbidClassicalKEM: true,
}
init, kemSec, err := InitiateHandshake(cfg, initiator)
require.NoError(err)
require.NotNil(init)
require.NotEmpty(kemSec)
resp, respResult, err := RespondHandshake(cfg, responder, init)
require.NoError(err)
require.NotNil(resp)
require.NotNil(respResult)
initResult, err := FinishInitiatorHandshake(cfg, initiator, init, resp, kemSec)
require.NoError(err)
require.NotNil(initResult)
require.Equal(initResult.AEADKey, respResult.AEADKey,
"both sides MUST derive the same AEAD key")
return initResult, respResult
}
// TestHandshake_StrictPQ_MLKEM768_RoundTrip exercises the default
// production handshake: strict-PQ profile, ML-KEM-768.
func TestHandshake_StrictPQ_MLKEM768_RoundTrip(t *testing.T) {
require := require.New(t)
initiator, responder, chainID := newTestIdentities(t)
initResult, respResult := runHandshake(t, initiator, responder, chainID, kem.KeyExchangeMLKEM768)
require.Equal(initiator.NodeID, respResult.PeerNodeID)
require.Equal(responder.NodeID, initResult.PeerNodeID)
require.Equal(kem.KeyExchangeMLKEM768, initResult.KEMSession.SchemeID)
}
// TestHandshake_StrictPQ_MLKEM1024_RoundTrip exercises the high-value
// DKG-grade handshake under ML-KEM-1024.
func TestHandshake_StrictPQ_MLKEM1024_RoundTrip(t *testing.T) {
require := require.New(t)
initiator, responder, chainID := newTestIdentities(t)
initResult, respResult := runHandshake(t, initiator, responder, chainID, kem.KeyExchangeMLKEM1024)
require.Equal(kem.KeyExchangeMLKEM1024, initResult.KEMSession.SchemeID)
require.Equal(kem.KeyExchangeMLKEM1024, respResult.KEMSession.SchemeID)
// AEADKey under DKG customisation MUST differ from peer-AEAD customisation.
dkgKey, err := initResult.KEMSession.DeriveDKGAEADKey()
require.NoError(err)
require.NotEqual(initResult.AEADKey, dkgKey)
}
// TestHandshake_StrictPQ_RefusesClassicalKEM asserts a strict-PQ peer
// rejects any peer offering an explicit classical KEM marker.
func TestHandshake_StrictPQ_RefusesClassicalKEM(t *testing.T) {
require := require.New(t)
initiator, responder, chainID := newTestIdentities(t)
cfg := &HandshakeConfig{
Profile: ProfileStrictPQ,
ChainID: chainID,
KEMScheme: kem.KeyExchangeMLKEM768,
ForbidClassicalKEM: true,
}
// Local config trying to offer a classical KEM is refused at config
// validation time, before any wire bytes leave the box.
badCfg := *cfg
badCfg.KEMScheme = kem.KeyExchangeX25519Unsafe
_, _, err := InitiateHandshake(&badCfg, initiator)
require.ErrorIs(err, ErrHandshakeClassicalKEM)
// A peer that hand-crafts a classical KEM byte into an INIT is
// refused by the responder.
init, _, err := InitiateHandshake(cfg, initiator)
require.NoError(err)
init.KEMScheme = kem.KeyExchangeX25519Unsafe
_, _, err = RespondHandshake(cfg, responder, init)
require.ErrorIs(err, ErrHandshakeClassicalKEM)
}
// TestHandshake_StrictPQ_RefusesECDSAIdentity asserts the handshake
// rejects an identity public key that isn't a valid ML-DSA-65 key. We
// simulate this by replacing MLDSAPub with arbitrary bytes of the wrong
// length (ECDSA-P256 sec1 width = 65 bytes; ML-DSA-65 pub = 1952 bytes).
func TestHandshake_StrictPQ_RefusesECDSAIdentity(t *testing.T) {
require := require.New(t)
initiator, responder, chainID := newTestIdentities(t)
cfg := &HandshakeConfig{
Profile: ProfileStrictPQ,
ChainID: chainID,
KEMScheme: kem.KeyExchangeMLKEM768,
ForbidClassicalKEM: true,
}
init, _, err := InitiateHandshake(cfg, initiator)
require.NoError(err)
// Replace ML-DSA-65 public key with a 65-byte ECDSA-P256 sec1-uncompressed
// representation. The handshake MUST refuse before any signature work.
init.MLDSAPub = make([]byte, 65)
init.MLDSAPub[0] = 0x04 // sec1 uncompressed prefix
_, _, err = RespondHandshake(cfg, responder, init)
require.ErrorIs(err, ErrHandshakeBadIdentity)
}
// TestHandshake_PreservesNodeIdentitySignature asserts that the identity
// signature actually covers the transcript: flipping any byte in the
// INIT or RESP after signing causes the verifier to reject.
func TestHandshake_PreservesNodeIdentitySignature(t *testing.T) {
require := require.New(t)
initiator, responder, chainID := newTestIdentities(t)
cfg := &HandshakeConfig{
Profile: ProfileStrictPQ,
ChainID: chainID,
KEMScheme: kem.KeyExchangeMLKEM768,
ForbidClassicalKEM: true,
}
// Tamper INIT after signing: flip one byte of the KEMPub.
init, _, err := InitiateHandshake(cfg, initiator)
require.NoError(err)
require.NotEmpty(init.Sig)
init.KEMPub[0] ^= 0x01
_, _, err = RespondHandshake(cfg, responder, init)
require.ErrorIs(err, ErrHandshakeBadIdentity,
"flipping a signed-over byte MUST cause initiator signature to fail")
// Tamper RESP after signing: re-run a clean handshake, mutate resp.
init2, kemSec2, err := InitiateHandshake(cfg, initiator)
require.NoError(err)
resp, _, err := RespondHandshake(cfg, responder, init2)
require.NoError(err)
require.NotEmpty(resp.Sig)
resp.KEMCiphertext[0] ^= 0x01
_, err = FinishInitiatorHandshake(cfg, initiator, init2, resp, kemSec2)
require.ErrorIs(err, ErrHandshakeBadIdentity,
"flipping a signed-over byte MUST cause responder signature to fail")
}
// TestHandshake_RefusesProfileMismatch asserts two peers running
// different security profiles cannot complete the handshake.
func TestHandshake_RefusesProfileMismatch(t *testing.T) {
require := require.New(t)
initiator, responder, chainID := newTestIdentities(t)
initCfg := &HandshakeConfig{
Profile: ProfileStrictPQ,
ChainID: chainID,
KEMScheme: kem.KeyExchangeMLKEM768,
ForbidClassicalKEM: true,
}
respCfg := *initCfg
respCfg.Profile = ProfileFIPS
init, _, err := InitiateHandshake(initCfg, initiator)
require.NoError(err)
_, _, err = RespondHandshake(&respCfg, responder, init)
require.ErrorIs(err, ErrHandshakeProfileMismatch)
}
// TestHandshake_RefusesChainMismatch asserts two peers on different
// chains cannot complete the handshake.
func TestHandshake_RefusesChainMismatch(t *testing.T) {
require := require.New(t)
initiator, responder, chainID := newTestIdentities(t)
var otherChain [32]byte
_, err := rand.Read(otherChain[:])
require.NoError(err)
initCfg := &HandshakeConfig{
Profile: ProfileStrictPQ,
ChainID: chainID,
KEMScheme: kem.KeyExchangeMLKEM768,
ForbidClassicalKEM: true,
}
respCfg := *initCfg
respCfg.ChainID = otherChain
init, _, err := InitiateHandshake(initCfg, initiator)
require.NoError(err)
_, _, err = RespondHandshake(&respCfg, responder, init)
require.ErrorIs(err, ErrHandshakeChainMismatch)
}
// TestHandshake_RefusesResponderKEMDowngrade asserts the initiator
// rejects a responder that echoes back a different KEM scheme (e.g.
// downgrades ML-KEM-1024 to ML-KEM-768).
func TestHandshake_RefusesResponderKEMDowngrade(t *testing.T) {
require := require.New(t)
initiator, responder, chainID := newTestIdentities(t)
cfg := &HandshakeConfig{
Profile: ProfileStrictPQ,
ChainID: chainID,
KEMScheme: kem.KeyExchangeMLKEM1024,
ForbidClassicalKEM: true,
}
init, kemSec, err := InitiateHandshake(cfg, initiator)
require.NoError(err)
resp, _, err := RespondHandshake(cfg, responder, init)
require.NoError(err)
// Tamper the scheme byte in the responder message; the initiator MUST
// catch the downgrade before deriving any key material.
resp.KEMScheme = kem.KeyExchangeMLKEM768
_, err = FinishInitiatorHandshake(cfg, initiator, init, resp, kemSec)
require.ErrorIs(err, ErrHandshakeKEMScheme)
}
// TestHandshake_DistinctSessionsDistinctKeys asserts two independent
// handshakes between the same pair derive distinct AEAD keys (the KEM
// randomness alone is enough to differentiate; the transcript binding
// reinforces it).
func TestHandshake_DistinctSessionsDistinctKeys(t *testing.T) {
require := require.New(t)
initiator, responder, chainID := newTestIdentities(t)
a1, _ := runHandshake(t, initiator, responder, chainID, kem.KeyExchangeMLKEM768)
a2, _ := runHandshake(t, initiator, responder, chainID, kem.KeyExchangeMLKEM768)
require.NotEqual(a1.AEADKey, a2.AEADKey,
"independent sessions MUST yield distinct AEAD keys")
}
// TestHandshake_NodeIDZeroRefused asserts the handshake rejects a peer
// presenting the zero NodeID.
func TestHandshake_NodeIDZeroRefused(t *testing.T) {
require := require.New(t)
initiator, responder, chainID := newTestIdentities(t)
cfg := &HandshakeConfig{
Profile: ProfileStrictPQ,
ChainID: chainID,
KEMScheme: kem.KeyExchangeMLKEM768,
ForbidClassicalKEM: true,
}
init, _, err := InitiateHandshake(cfg, initiator)
require.NoError(err)
init.NodeID = ids.EmptyNodeID
_, _, err = RespondHandshake(cfg, responder, init)
require.ErrorIs(err, ErrHandshakeNodeIDZero)
}