refactor(pq): strict-PQ forward-only, drop transition window + classical-compat

- Remove ActivationHeight migration window from SchemeGate; the gate
  refuses non-PQ NodeIDScheme bytes at every height.
- Remove LUX_CLASSICAL_COMPAT_UNSAFE operator escape hatch; classical
  staker.crt/key flags retained only for legacy no-profile chains.
- Rename profile from LUX_STRICT_E2E_PQ to STRICT_E2E_PQ (brand sweep).
- Update LLM.md to reflect forward-only PQ policy.
This commit is contained in:
Hanzo AI
2026-05-15 12:15:42 -07:00
parent bcf717a5c5
commit f56ab5d4af
14 changed files with 107 additions and 207 deletions
+6 -6
View File
@@ -417,11 +417,11 @@ cross-axis check against the chain's `ChainSecurityProfile`.
`"proposer"`, `"validator"`, `"mempool-sender"`) that appears in the
refused-by error.
- Migration path: `ActivationHeight` is the block at which a strict-PQ
chain stops accepting the classical `secp256k1` (0x90) scheme. Before
`ActivationHeight` the gate accepts BOTH the pinned PQ scheme and the
named classical scheme (transition window); from `ActivationHeight`
onward, strict-PQ chains refuse classical regardless of the operator
`LUX_CLASSICAL_COMPAT_UNSAFE` flag.
chain refuses any non-PQ `NodeIDScheme` byte at every height under
the forward-only PQ policy. The classical `secp256k1` (0x90) scheme is
refused at the gate; there is no transition window and no operator
classical-compat escape hatch (strict-PQ chains refuse classical at
every boundary, period).
- Typed errors: `ErrSchemeGateConfig`, `ErrSchemeGateMismatch`,
`ErrSchemeGateUnknownScheme`.
@@ -695,7 +695,7 @@ Testing conducted on a single Lux validator node (testnet mode, macOS):
**Benchmark Command:**
```bash
cd ~/work/lux/benchmarks
LUX_ENDPOINT="http://localhost:9640/ext/bc/C/rpc" \
NODE_ENDPOINT="http://localhost:9640/ext/bc/C/rpc" \
PRIVATE_KEY="<funded_key>" \
./bin/bench tps --chains=lux --duration=60s --concurrency=5
```
+1 -1
View File
@@ -283,7 +283,7 @@ func (c *StakingConfig) IsStrictPQ() bool {
// staking identity under chainID:
//
// - Strict-PQ (StakingMLDSAPub set):
// NodeID = SHAKE256-384("LUX_NODE_ID_V1" || chainID || 0x42 || pubKey)[:20]
// NodeID = SHAKE256-384("NODE_ID_V1" || chainID || 0x42 || pubKey)[:20]
// via ids.NodeIDSchemeMLDSA65.DeriveMLDSA. chainID-bound so the
// same key on a different chain produces a different NodeID,
// blocking cross-chain replay of validator registrations.
+3 -4
View File
@@ -1082,10 +1082,9 @@ func allFlags() []FlagSpec {
// the node uses the ML-DSA-65 public key as the NodeID source via
// ids.NodeIDSchemeMLDSA65.DeriveMLDSA(chainID, pubKey) — replacing
// the classical TLS-cert NodeID derivation. The classical TLS
// staker.crt/key + signer.key flags above stay defined so a
// classical-compat chain (LUX_CLASSICAL_COMPAT_UNSAFE) can still
// boot, but a strict-PQ profile rejects any classical material at
// the validator-set boundary.
// staker.crt/key + signer.key flags above are retained only for
// legacy chains that ship no ChainSecurityProfile; strict-PQ
// chains refuse classical material at every boundary.
{
Key: "staking-mldsa-key-file",
Type: TypeString,
+3 -8
View File
@@ -204,13 +204,8 @@ type Config struct {
// node.Node.initSecurityProfile). When non-nil, the network upgrader
// builds a peer.SchemeGate from it and refuses any inbound or
// outbound TLS connection whose wire NodeIDScheme is not admissible
// under the profile. nil leaves the cross-axis gate disabled
// (legacy / classical-compat networks).
// under the profile. nil leaves the cross-axis gate disabled (chains
// that ship no profile remain accepted by the upgrader's nil-safe
// path).
SecurityProfile *consensusconfig.ChainSecurityProfile `json:"-"`
// ClassicalCompatUnsafe mirrors the operator's
// LUX_CLASSICAL_COMPAT_UNSAFE knob; threaded into the peer.SchemeGate
// so permissive profiles can opt into legacy classical NodeIDs. The
// flag has no effect on strict-PQ profiles (defence in depth).
ClassicalCompatUnsafe bool `json:"classicalCompatUnsafe"`
}
+1 -1
View File
@@ -33,7 +33,7 @@ var ErrDKGSchemeMismatch = errors.New("kem: DKG channels MUST use ML-KEM-1024")
//
// The returned KEMSession is otherwise byte-identical to what
// InitiateKEMSession(MLKEM1024, ...) returns; downstream code may call
// DeriveDKGAEADKey on it to bind the AEAD key under the "LUX_NODE_DKG_V1"
// DeriveDKGAEADKey on it to bind the AEAD key under the "NODE_DKG_V1"
// customisation.
func InitiateDKGKEMSession(
scheme KeyExchangeID,
+11 -11
View File
@@ -18,13 +18,13 @@ import (
// package. Pinned at "_V1"; bumping the tag invalidates every prior derived
// key (which is the correct behaviour for a hard-fork of the encoding).
//
// LUX_NODE_AEAD_V1 — peer-to-peer session AEAD key
// LUX_NODE_DKG_V1 — DKG channel AEAD key (high-value, ML-KEM-1024 only)
// LUX_NODE_TRANSCRIPT_V1 — TupleHash256 customization for handshake transcript
// NODE_AEAD_V1 — peer-to-peer session AEAD key
// NODE_DKG_V1 — DKG channel AEAD key (high-value, ML-KEM-1024 only)
// NODE_TRANSCRIPT_V1 — TupleHash256 customization for handshake transcript
const (
customizationAEAD = "LUX_NODE_AEAD_V1"
customizationDKGAEAD = "LUX_NODE_DKG_V1"
customizationTranscript = "LUX_NODE_TRANSCRIPT_V1"
customizationAEAD = "NODE_AEAD_V1"
customizationDKGAEAD = "NODE_DKG_V1"
customizationTranscript = "NODE_TRANSCRIPT_V1"
// TranscriptHashSize is the byte width of the bound handshake
// transcript hash returned by HashTranscript and stored in KEMSession.
@@ -78,7 +78,7 @@ var (
// and the 384-bit running transcript hash (cSHAKE256 / TupleHash256 over
// every handshake message produced before the KEM round, in canonical
// order). The AEAD key is derived from SharedSecret + TranscriptHash via
// cSHAKE256 customisation "LUX_NODE_AEAD_V1".
// cSHAKE256 customisation "NODE_AEAD_V1".
//
// Field invariants enforced by package APIs:
//
@@ -114,13 +114,13 @@ type KEMSession struct {
// HashTranscript returns the canonical cSHAKE256 / TupleHash256 commitment
// to msgs in the order they appear. Customization is
// "LUX_NODE_TRANSCRIPT_V1" so two different handshake protocols cannot
// "NODE_TRANSCRIPT_V1" so two different handshake protocols cannot
// produce the same hash from the same byte content.
//
// The encoding is SP 800-185 TupleHash256: each input is left-encoded with
// its bit-length prefix, the right-encoded total output bit-length is
// appended, and the result is fed to cSHAKE256 with the function-name
// "TupleHash" and customisation "LUX_NODE_TRANSCRIPT_V1". This matches
// "TupleHash" and customisation "NODE_TRANSCRIPT_V1". This matches
// the helper used by ChainSecurityProfile.ComputeHash, so the same
// transcript hash byte-encodes identically across the node and consensus.
func HashTranscript(msgs ...[]byte) [TranscriptHashSize]byte {
@@ -335,7 +335,7 @@ func finishSession(scheme KeyExchangeID, ssBuf, transcript []byte) *KEMSession {
// DeriveAEADKey returns the 256-bit AEAD session key bound to this KEM
// session's shared secret AND its transcript hash. Uses cSHAKE256 with
// customisation "LUX_NODE_AEAD_V1"; the function name is "KEMDerive".
// customisation "NODE_AEAD_V1"; the function name is "KEMDerive".
//
// Two sessions with the same SharedSecret but different TranscriptHash
// produce two different keys. Two sessions with the same TranscriptHash
@@ -356,7 +356,7 @@ func (s *KEMSession) DeriveAEADKey() [AEADKeySize]byte {
}
// DeriveDKGAEADKey returns the 256-bit AEAD session key bound to this KEM
// session under the DKG-channel customisation "LUX_NODE_DKG_V1". A DKG
// session under the DKG-channel customisation "NODE_DKG_V1". A DKG
// channel session is required by the strict-PQ profile to negotiate
// ML-KEM-1024; this function refuses to derive a key on any other scheme
// so a misconfigured caller fails loud at key derivation rather than
+5 -5
View File
@@ -15,7 +15,7 @@ import (
// network-layer projection of that profile.
type ActiveProfile struct {
// Name is the canonical profile name for log lines and audit reports.
// Production strict-PQ deployments use "LUX_STRICT_E2E_PQ".
// Production strict-PQ deployments use "STRICT_E2E_PQ".
Name string
// NodeIdentity is the wire name of the per-node identity signature
@@ -43,13 +43,13 @@ type ActiveProfile struct {
ForbidClassicalKEM bool
}
// LuxStrictE2EPQ returns the canonical Lux strict-PQ profile projection
// StrictE2EPQ returns the canonical strict-PQ profile projection
// for the network layer. Mirrors the consensus-config profile of the
// same shape; values here are pinned so a node that boots without an
// explicit profile still advertises the strict posture by default.
func LuxStrictE2EPQ() ActiveProfile {
func StrictE2EPQ() ActiveProfile {
return ActiveProfile{
Name: "LUX_STRICT_E2E_PQ",
Name: "STRICT_E2E_PQ",
NodeIdentity: "ML-DSA-65",
SessionKEM: KeyExchangeMLKEM768,
DKGKEM: KeyExchangeMLKEM1024,
@@ -63,7 +63,7 @@ func LuxStrictE2EPQ() ActiveProfile {
// startup MUST emit on boot under a strict-PQ profile. Fixed format so
// log scrapers can match deterministically.
//
// SECURITY PROFILE: LUX_STRICT_E2E_PQ
// SECURITY PROFILE: STRICT_E2E_PQ
// NODE IDENTITY: ML-DSA-65
// SESSION KEM: ML-KEM-768
// DKG KEM: ML-KEM-1024
+9 -9
View File
@@ -10,16 +10,16 @@ import (
"github.com/stretchr/testify/require"
)
// TestLuxStrictE2EPQ_Banner asserts the canonical strict-PQ banner format
// TestStrictE2EPQ_Banner asserts the canonical strict-PQ banner format
// matches the line-shape audit pipelines look for at boot.
func TestLuxStrictE2EPQ_Banner(t *testing.T) {
func TestStrictE2EPQ_Banner(t *testing.T) {
require := require.New(t)
p := LuxStrictE2EPQ()
p := StrictE2EPQ()
require.NoError(p.Validate())
out := p.Banner()
require.Contains(out, "SECURITY PROFILE: LUX_STRICT_E2E_PQ")
require.Contains(out, "SECURITY PROFILE: STRICT_E2E_PQ")
require.Contains(out, "NODE IDENTITY: ML-DSA-65")
require.Contains(out, "SESSION KEM: ML-KEM-768")
require.Contains(out, "DKG KEM: ML-KEM-1024")
@@ -36,23 +36,23 @@ func TestLuxStrictE2EPQ_Banner(t *testing.T) {
func TestActiveProfile_RefusesInternalInconsistency(t *testing.T) {
require := require.New(t)
bad := LuxStrictE2EPQ()
bad := StrictE2EPQ()
bad.DKGKEM = KeyExchangeMLKEM768
require.Error(bad.Validate())
bad = LuxStrictE2EPQ()
bad = StrictE2EPQ()
bad.SessionKEM = KeyExchangeX25519Unsafe
require.Error(bad.Validate())
bad = LuxStrictE2EPQ()
bad = StrictE2EPQ()
bad.ForbidClassicalKEM = false
require.Error(bad.Validate())
bad = LuxStrictE2EPQ()
bad = StrictE2EPQ()
bad.PostQuantumE2E = false
require.Error(bad.Validate())
bad = LuxStrictE2EPQ()
bad = StrictE2EPQ()
bad.Name = ""
require.Error(bad.Validate())
}
+1 -1
View File
@@ -8,7 +8,7 @@
//
// The session shared secret is derived through one FIPS 203 encapsulate /
// decapsulate dance and then bound to the handshake transcript via cSHAKE256
// with customization "LUX_NODE_AEAD_V1". The bound key is the AEAD session
// with customization "NODE_AEAD_V1". The bound key is the AEAD session
// key used by every subsequent frame on the connection.
//
// One and only one KEM family is admitted on a strict-PQ profile: ML-KEM.
+1 -2
View File
@@ -492,14 +492,13 @@ func NewNetwork(
// gate nil — upgrader.connToIDAndCert is nil-safe and refuses nobody.
var schemeGate *peer.SchemeGate
if config.SecurityProfile != nil {
schemeGate, err = peer.NewSchemeGate(config.SecurityProfile, config.ClassicalCompatUnsafe, 0)
schemeGate, err = peer.NewSchemeGate(config.SecurityProfile, 0)
if err != nil {
return nil, fmt.Errorf("building peer SchemeGate: %w", err)
}
log.Info("peer SchemeGate active",
zap.String("profile", config.SecurityProfile.ProfileName),
zap.Uint32("profileID", config.SecurityProfile.ProfileID),
zap.Bool("classicalCompatUnsafe", config.ClassicalCompatUnsafe),
)
}
+8 -8
View File
@@ -19,7 +19,7 @@ import (
// after a TLS 1.3 (X25519+ML-KEM-768) channel is established. The TLS
// layer authenticates the underlying transport; this handshake binds the
// node-level ML-DSA-65 identity to an ML-KEM session, deriving an AEAD
// session key under cSHAKE256 customisation "LUX_NODE_AEAD_V1".
// session key under cSHAKE256 customisation "NODE_AEAD_V1".
//
// Wire protocol (4 byte tags described below are encoded big-endian
// length-prefixed):
@@ -47,11 +47,11 @@ import (
// Both sides then:
//
// 1. Derive shared_secret = ML-KEM.Decapsulate(KEMCiphertext)
// 2. Derive bound transcript = TupleHash256("LUX_NODE_TRANSCRIPT_V1",
// 2. Derive bound transcript = TupleHash256("NODE_TRANSCRIPT_V1",
// INIT bytes ‖ RESP bytes ‖
// init_ml_dsa_pub ‖ resp_ml_dsa_pub ‖
// profile_id ‖ chain_id)
// 3. Derive aead_key = cSHAKE256("LUX_NODE_AEAD_V1",
// 3. Derive aead_key = cSHAKE256("NODE_AEAD_V1",
// scheme ‖ shared_secret ‖ transcript)
//
// The aead_key is what callers feed to ChaCha20-Poly1305 or AES-256-GCM
@@ -294,7 +294,7 @@ func InitiateHandshake(
}
// Sign the transcript prefix (every field above Sig). The signature
// context ("LUX_NODE_PQ_HANDSHAKE_V1") makes role and version part of
// context ("NODE_PQ_HANDSHAKE_V1") makes role and version part of
// the signed bytes so a captured signature cannot be replayed as a
// responder signature or under a future protocol version.
transcriptPrefix := init.transcriptPrefix(roleInitiator)
@@ -302,7 +302,7 @@ func InitiateHandshake(
if err := mldsa65.SignTo(
local.Secret,
transcriptPrefix,
[]byte("LUX_NODE_PQ_HANDSHAKE_V1/initiator"),
[]byte("NODE_PQ_HANDSHAKE_V1/initiator"),
false, // deterministic mode; randomized=true is also valid, but determinism keeps test vectors stable
sig,
); err != nil {
@@ -343,7 +343,7 @@ func RespondHandshake(
if !mldsa65.Verify(
peerPub,
init.transcriptPrefix(roleInitiator),
[]byte("LUX_NODE_PQ_HANDSHAKE_V1/initiator"),
[]byte("NODE_PQ_HANDSHAKE_V1/initiator"),
init.Sig,
) {
return nil, nil, fmt.Errorf("%w: initiator signature failed", ErrHandshakeBadIdentity)
@@ -374,7 +374,7 @@ func RespondHandshake(
if err := mldsa65.SignTo(
local.Secret,
respPrefix,
[]byte("LUX_NODE_PQ_HANDSHAKE_V1/responder"),
[]byte("NODE_PQ_HANDSHAKE_V1/responder"),
false,
sig,
); err != nil {
@@ -425,7 +425,7 @@ func FinishInitiatorHandshake(
if !mldsa65.Verify(
peerPub,
resp.transcriptPrefix(init, roleResponder),
[]byte("LUX_NODE_PQ_HANDSHAKE_V1/responder"),
[]byte("NODE_PQ_HANDSHAKE_V1/responder"),
resp.Sig,
) {
return nil, fmt.Errorf("%w: responder signature failed", ErrHandshakeBadIdentity)
+25 -60
View File
@@ -14,9 +14,11 @@
// "validator" — platformvm validator registration
// "mempool-sender" — mempool signed-tx submission
//
// The hardfork-style activation block lets a chain accept BOTH classical
// and ML-DSA NodeIDs during a transition window, then switch the
// strict-PQ profile to ML-DSA-only at a configured activation block.
// Forward-only PQ: there is no transition window and no classical
// fallback. Every site funnels through Classify which admits only the
// pinned PQ NodeIDScheme byte (ML-DSA-65 today); anything else — a
// classical secp256k1 byte, a cross-PQ scheme like ML-DSA-87 on a chain
// pinning ML-DSA-65, or an unknown byte — is refused at the gate.
package peer
@@ -31,35 +33,23 @@ import (
// SchemeGate is the chain-scoped policy object that decides whether a
// peer's NodeIDScheme is admissible under the chain's pinned profile.
// One SchemeGate per chain; created at chain-bootstrap from the chain's
// ChainSecurityProfile and the operator's classical-compat flag, and
// pinned for the chain's lifetime (no re-derivation in the hot path).
// ChainSecurityProfile and pinned for the chain's lifetime (no
// re-derivation in the hot path).
//
// The migration window is expressed as an activation height: until
// ActivationHeight, the gate accepts BOTH the pinned PQ scheme and the
// classical secp256k1 scheme (the chain is in "mixed-scheme" mode);
// from ActivationHeight onward, strict-PQ chains refuse classical
// regardless of the operator flag.
//
// ActivationHeight == 0 means "the gate is already active at genesis"
// — the right value for a fresh strict-PQ chain. A chain migrating
// from classical to PQ sets ActivationHeight to the block at which the
// hardfork takes effect.
// Forward-only PQ: the gate has no transition window and no operator
// classical-compat escape hatch. Every Classify call admits only the
// pinned PQ scheme byte; classical secp256k1 NodeIDs and cross-PQ
// bytes are refused at every height.
type SchemeGate struct {
// Profile is the chain's locked ChainSecurityProfile. The pinned
// ValidatorSchemeID is read through profile.ValidatorSchemeID().
Profile *consensusconfig.ChainSecurityProfile
// ClassicalCompatUnsafe mirrors the operator's
// LUX_CLASSICAL_COMPAT_UNSAFE knob. Strict-PQ profiles refuse
// classical schemes regardless of this flag (defence in depth);
// permissive profiles honour it.
ClassicalCompatUnsafe bool
// ActivationHeight is the block height at which the gate stops
// accepting classical NodeIDs on strict-PQ chains. Before
// ActivationHeight, the gate is in "transition" mode and accepts
// both. At or after ActivationHeight, the strict-PQ rule applies.
// Zero means "no transition window — strict from genesis".
// ActivationHeight is preserved for symmetry with the per-chain
// fork-height bookkeeping; the gate's policy is the same on both
// sides of it (PQ-only). Reserved for downstream callers that want
// a single field to log "where strict-PQ took effect" alongside
// other hardfork heights — the gate itself does not branch on it.
ActivationHeight uint64
}
@@ -68,16 +58,14 @@ type SchemeGate struct {
// the gate does not re-validate the profile on each call.
func NewSchemeGate(
profile *consensusconfig.ChainSecurityProfile,
classicalCompatUnsafe bool,
activationHeight uint64,
) (*SchemeGate, error) {
if profile == nil {
return nil, fmt.Errorf("%w: profile is nil", ErrSchemeGateConfig)
}
return &SchemeGate{
Profile: profile,
ClassicalCompatUnsafe: classicalCompatUnsafe,
ActivationHeight: activationHeight,
Profile: profile,
ActivationHeight: activationHeight,
}, nil
}
@@ -87,11 +75,9 @@ func NewSchemeGate(
// a site tag for the log line; it returns a TypedNodeID stamped with the
// scheme byte once the chain's policy admits the pair.
//
// The classical-secp256k1 input is what the existing TLS upgrader
// produces today; the gate stamps it with the matching scheme byte and
// runs the cross-axis check. Strict-PQ chains past their activation
// height refuse the classical NodeID here; that is the migration
// enforcement point.
// Forward-only PQ: only the pinned PQ scheme byte is admitted. A
// classical secp256k1 byte, a cross-PQ byte (ML-DSA-87 on a chain
// pinning ML-DSA-65), or an unknown byte is refused at every height.
//
// site is a free-form tag ("handshake", "proposer", "validator",
// "mempool-sender" are the conventional values) included verbatim in
@@ -107,32 +93,11 @@ func (g *SchemeGate) Classify(
ErrSchemeGateUnknownScheme, site, byte(derivedScheme))
}
// Transition window: accept the pinned PQ byte AND the named
// classical byte. Any byte outside that pair (e.g. ML-DSA-87 on a
// chain that pins ML-DSA-65) is still refused as a cross-scheme
// mismatch.
if height < g.ActivationHeight {
pinned := ids.NodeIDScheme(g.Profile.ValidatorSchemeID())
if derivedScheme != pinned && derivedScheme != ids.NodeIDSchemeSecp256k1 {
return ids.TypedNodeID{}, fmt.Errorf("%w: site=%s presented=%s pinned=%s height=%d (transition)",
ErrSchemeGateMismatch,
site, derivedScheme, pinned, height)
}
typed, err := ids.NewTypedNodeID(derivedScheme, nodeID)
if err != nil {
return ids.TypedNodeID{}, fmt.Errorf("%w: site=%s: %w",
ErrSchemeGateMismatch, site, err)
}
return typed, nil
}
// Post-activation: defer to the profile's cross-axis check. The
// profile's strict-PQ class refuses classical even under the
// operator flag.
// PQ-only: defer to the profile's cross-axis check with the
// classical-compat escape hatch pinned off. The profile refuses
// classical schemes; this gate refuses them at every height.
presentedSig := consensusconfig.SigSchemeID(derivedScheme)
if err := g.Profile.AcceptsValidatorScheme(
presentedSig, g.ClassicalCompatUnsafe,
); err != nil {
if err := g.Profile.AcceptsValidatorScheme(presentedSig, false); err != nil {
return ids.TypedNodeID{}, fmt.Errorf("%w: site=%s height=%d: %w",
ErrSchemeGateMismatch, site, height, err)
}
+30 -87
View File
@@ -16,134 +16,79 @@ import (
// half-formed gate.
func TestSchemeGate_NewSchemeGate_RejectsNilProfile(t *testing.T) {
require := require.New(t)
_, err := NewSchemeGate(nil, false, 0)
_, err := NewSchemeGate(nil, 0)
require.ErrorIs(err, ErrSchemeGateConfig)
}
// TestSchemeGate_StrictPQ_AcceptsMatchedScheme — under a strict-PQ
// profile with no transition window, an ML-DSA-65 NodeID is accepted
// regardless of the site tag passed in.
// profile an ML-DSA-65 NodeID is accepted at every site at every
// height.
func TestSchemeGate_StrictPQ_AcceptsMatchedScheme(t *testing.T) {
require := require.New(t)
g, err := NewSchemeGate(consensusconfig.StrictPQ(), false, 0)
g, err := NewSchemeGate(consensusconfig.StrictPQ(), 0)
require.NoError(err)
id := ids.NodeID{0xaa, 0xbb}
for _, site := range []string{
"handshake", "proposer", "validator", "mempool-sender",
} {
typed, err := g.Classify(id, ids.NodeIDSchemeMLDSA65, 100, site)
require.NoError(err, "site=%s", site)
require.Equal(ids.NodeIDSchemeMLDSA65, typed.Scheme)
require.Equal(id, typed.NodeID)
for _, height := range []uint64{0, 100, 1_000_000} {
typed, err := g.Classify(id, ids.NodeIDSchemeMLDSA65, height, site)
require.NoError(err, "site=%s height=%d", site, height)
require.Equal(ids.NodeIDSchemeMLDSA65, typed.Scheme)
require.Equal(id, typed.NodeID)
}
}
}
// TestSchemeGate_StrictPQ_PostActivation_RejectsClassical — once
// past the activation height a strict-PQ chain refuses a classical
// NodeID at every site, even when ClassicalCompatUnsafe=true.
func TestSchemeGate_StrictPQ_PostActivation_RejectsClassical(t *testing.T) {
// TestSchemeGate_StrictPQ_RejectsClassicalAtEveryHeight — a strict-PQ
// chain refuses a classical secp256k1 NodeID at every site and every
// height. There is no transition window in forward-only PQ mode.
func TestSchemeGate_StrictPQ_RejectsClassicalAtEveryHeight(t *testing.T) {
require := require.New(t)
g, err := NewSchemeGate(consensusconfig.StrictPQ(), true, 50)
g, err := NewSchemeGate(consensusconfig.StrictPQ(), 0)
require.NoError(err)
id := ids.NodeID{0x11, 0x22}
for _, site := range []string{
"handshake", "proposer", "validator", "mempool-sender",
} {
_, err := g.Classify(id, ids.NodeIDSchemeSecp256k1, 100 /* past activation */, site)
require.ErrorIs(err, ErrSchemeGateMismatch, "site=%s", site)
for _, height := range []uint64{0, 50, 1_000_000} {
_, err := g.Classify(id, ids.NodeIDSchemeSecp256k1, height, site)
require.ErrorIs(err, ErrSchemeGateMismatch,
"site=%s height=%d", site, height)
}
}
}
// TestSchemeGate_StrictPQ_PreActivation_AcceptsBothSchemesduring
// the transition window the strict-PQ gate accepts BOTH the pinned PQ
// scheme and the classical scheme. The window is the migration path.
func TestSchemeGate_StrictPQ_PreActivation_AcceptsBothSchemes(t *testing.T) {
// TestSchemeGate_RejectsCrossPQScheme — a chain pinning ML-DSA-65 must
// refuse an ML-DSA-87 NodeID. Cross-PQ bytes are never admissible.
func TestSchemeGate_RejectsCrossPQScheme(t *testing.T) {
require := require.New(t)
g, err := NewSchemeGate(consensusconfig.StrictPQ(), false, 100)
g, err := NewSchemeGate(consensusconfig.StrictPQ(), 0)
require.NoError(err)
id := ids.NodeID{0x33, 0x44}
// Pinned PQ scheme always accepted, including in the window.
typed, err := g.Classify(id, ids.NodeIDSchemeMLDSA65, 50, "handshake")
require.NoError(err)
require.Equal(ids.NodeIDSchemeMLDSA65, typed.Scheme)
// Classical accepted in the window even on strict-PQ.
typed, err = g.Classify(id, ids.NodeIDSchemeSecp256k1, 50, "handshake")
require.NoError(err)
require.Equal(ids.NodeIDSchemeSecp256k1, typed.Scheme)
// Cross-PQ scheme (ML-DSA-87 on a chain pinning ML-DSA-65) is
// still refused inside the window: the transition window admits
// the named-classical fallback, not "any other PQ byte".
_, err = g.Classify(id, ids.NodeIDSchemeMLDSA87, 50, "handshake")
require.ErrorIs(err, ErrSchemeGateMismatch)
}
// TestSchemeGate_StrictPQ_PreActivation_RejectsClassicalAfterCutover
// — exactly at ActivationHeight the transition window closes; the
// classical scheme is refused at that height and beyond.
func TestSchemeGate_StrictPQ_PreActivation_RejectsClassicalAfterCutover(t *testing.T) {
require := require.New(t)
g, err := NewSchemeGate(consensusconfig.StrictPQ(), false, 100)
require.NoError(err)
id := ids.NodeID{0x55}
// At ActivationHeight: closed.
_, err = g.Classify(id, ids.NodeIDSchemeSecp256k1, 100, "handshake")
require.ErrorIs(err, ErrSchemeGateMismatch)
// Past ActivationHeight: closed.
_, err = g.Classify(id, ids.NodeIDSchemeSecp256k1, 101, "handshake")
require.ErrorIs(err, ErrSchemeGateMismatch)
}
// TestSchemeGate_Permissive_AcceptsClassicalUnderUnsafeFlag — a
// permissive (testnet/devnet) profile with the operator-set unsafe
// flag accepts classical NodeIDs at every height. Without the flag,
// classical is refused even on permissive.
func TestSchemeGate_Permissive_AcceptsClassicalUnderUnsafeFlag(t *testing.T) {
require := require.New(t)
id := ids.NodeID{0x77}
// With the flag: classical accepted post-activation on permissive.
gOn, err := NewSchemeGate(consensusconfig.Permissive(), true, 0)
require.NoError(err)
_, err = gOn.Classify(id, ids.NodeIDSchemeSecp256k1, 1000, "handshake")
require.NoError(err)
// Without the flag: classical refused even on permissive.
gOff, err := NewSchemeGate(consensusconfig.Permissive(), false, 0)
require.NoError(err)
_, err = gOff.Classify(id, ids.NodeIDSchemeSecp256k1, 1000, "handshake")
_, err = g.Classify(ids.NodeID{0x33}, ids.NodeIDSchemeMLDSA87, 0, "handshake")
require.ErrorIs(err, ErrSchemeGateMismatch)
}
// TestSchemeGate_RejectsUnknownSchemeByte — an unknown scheme byte is
// refused at every height, regardless of profile class or operator flag.
// The transition window does NOT widen the byte set.
// refused at every height. The PQ-only rule does not widen the byte
// set.
func TestSchemeGate_RejectsUnknownSchemeByte(t *testing.T) {
require := require.New(t)
g, err := NewSchemeGate(consensusconfig.StrictPQ(), true, 1000)
g, err := NewSchemeGate(consensusconfig.StrictPQ(), 1000)
require.NoError(err)
id := ids.NodeID{0x99}
// Inside the transition window.
_, err = g.Classify(id, ids.NodeIDScheme(0x91), 500, "handshake")
require.ErrorIs(err, ErrSchemeGateUnknownScheme)
// Past the activation height.
_, err = g.Classify(id, ids.NodeIDScheme(0x91), 2000, "handshake")
require.ErrorIs(err, ErrSchemeGateUnknownScheme)
}
@@ -155,16 +100,14 @@ func TestSchemeGate_PinsProfileScheme(t *testing.T) {
require := require.New(t)
p := consensusconfig.StrictPQ()
g, err := NewSchemeGate(p, false, 0)
g, err := NewSchemeGate(p, 0)
require.NoError(err)
id := ids.NodeID{0x01}
// Pinned scheme accepted.
_, err = g.Classify(id, ids.NodeIDScheme(p.ValidatorSchemeID()), 0, "handshake")
require.NoError(err)
// Cross-PQ scheme refused.
otherPQ := ids.NodeIDSchemeMLDSA87
if p.ValidatorSchemeID() == consensusconfig.SigSchemeMLDSA87 {
otherPQ = ids.NodeIDSchemeMLDSA65
@@ -179,7 +122,7 @@ func TestSchemeGate_PinsProfileScheme(t *testing.T) {
func TestSchemeGate_SiteTagIncludedInError(t *testing.T) {
require := require.New(t)
g, err := NewSchemeGate(consensusconfig.StrictPQ(), false, 0)
g, err := NewSchemeGate(consensusconfig.StrictPQ(), 0)
require.NoError(err)
_, err = g.Classify(ids.NodeID{0x01}, ids.NodeIDSchemeSecp256k1, 1, "proposer")
+3 -4
View File
@@ -28,10 +28,9 @@ import (
func TestUpgrader_StrictPQ_RefusesClassicalTLS(t *testing.T) {
require := require.New(t)
// Strict-PQ SchemeGate active from genesis. No transition window,
// ClassicalCompatUnsafe=false — the gate's job is to refuse the
// classical scheme byte regardless of operator flag.
gate, err := peer.NewSchemeGate(consensusconfig.StrictPQ(), false, 0)
// Strict-PQ SchemeGate active from genesis. Forward-only PQ —
// the gate refuses the classical scheme byte at every height.
gate, err := peer.NewSchemeGate(consensusconfig.StrictPQ(), 0)
require.NoError(err)
serverKey, err := rsa.GenerateKey(rand.Reader, 2048)