mirror of
https://github.com/luxfi/node.git
synced 2026-07-27 03:39:39 +00:00
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:
@@ -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
|
||||
```
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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_AcceptsBothSchemes — during
|
||||
// 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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user