mirror of
https://github.com/luxfi/node.git
synced 2026-10-30 15:24:05 +00:00
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]>
306 lines
10 KiB
Go
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)
|
|
}
|