fix(network): strict-PQ peer handshake forms the validator mesh (devnet P-chain 0-blocks)#136
Closed
Darkhorse7stars wants to merge 2 commits into
Closed
fix(network): strict-PQ peer handshake forms the validator mesh (devnet P-chain 0-blocks)#136Darkhorse7stars wants to merge 2 commits into
Darkhorse7stars wants to merge 2 commits into
Conversation
The strict-PQ sovereign devnet L1 produced 0 P-chain blocks: consensus never formed a validator mesh because peers connected under ephemeral classical NodeIDs that never matched the genesis ML-DSA validator set. Coupled root causes, all scoped to the strict-PQ path: 1. handshake.go: derive the on-wire peer identity from the persistent ML-DSA-65 staking key (NewLocalIdentityFromMLDSA) and bind the peer's claimed NodeID to its ML-DSA key, so the post-handshake NodeID equals the validator-set NodeID (StakingConfig.DeriveNodeID). 2. network.go: when the PQ handshake is active the TLS layer is transport-only (peer leaf certs are ephemeral ECDSA by design, which schemeFromCert classifies classical). Pass a nil SchemeGate to the upgrader so the transport cert isn't refused at the TLS upgrade; identity is enforced by the PQ handshake instead. 3. network.go + peer.go: run the PQ handshake per-connection in network.upgrade BEFORE the connection dedup and without holding peersLock. It previously ran synchronously under peersLock with initiator/responder fixed by dial direction, so simultaneous mutual dials deadlocked every node as a lone initiator (each kept its outbound and dropped the peer's inbound at the connecting-dedup) -> INIT sent, no responder -> 0 peers. Now each TCP connection completes its handshake independently (distinct dialer/acceptor) and the dedup keys on the stable ML-DSA NodeID. 4. config.go + node.go: load the staking ML-DSA key and propagate it to NetworkConfig.StakingMLDSAPub/Priv; honor the genesis-file securityProfile pin; pemBytesOrFile accepts path-form key flags. 5. Dockerfile: symlink /lqd/build/lqd -> /luxd/build/luxd for the operator-rendered startup script. Result: full validator mesh on a fresh genesis — every node connected to the other two by its stable ML-DSA NodeID, P-chain bootstrapped. Classical / legacy networking is unchanged (PQHandshakeConfig nil -> nil gate + in-Start handshake). Peer + network unit tests pass.
On a strict-PQ chain the platformvm/xvm mempool refuses every classical secp256k1 tx credential (RequireTypedTxAuth) and the wallet builder only emits secp256k1 txs, so chain creation (CreateNetwork/CreateChainTx, signed by the bootstrap control key) was impossible. Populate the previously-nil ClassicalCompatRegistry from the genesis-funded P-chain allocation owners (the bootstrap trust root), gated on strict-PQ. Also include ids.ShortEmpty — the originator the mempool currently passes for P-chain txs (originator resolution is stubbed in vms/platformvm/txs/mempool/mempool.go) — so the bootstrap's classical P-chain txs are admitted. Devnet bootstrap escape hatch (the designed ClassicalCompatRegistry mechanism). Admits classical P-chain txs on this chain; does NOT touch the C-chain/EVM (separate secp256k1 auth) or the strict-PQ consensus/handshake. FOLLOW-UP: implement real originator resolution in the mempool, then narrow to the named genesis-owner allow-list. Unblocks the chainboot: EVM/DEX/FHE chains created + serving on 8675311.
Darkhorse7stars
added a commit
that referenced
this pull request
Jun 7, 2026
…andshake + classical-compat) (#137) main carries the strict-PQ peer-identity fix (NewLocalIdentityFromStakingKey + adoptVerifiedPQIdentity) but is missing three further layers that are each required for strict-PQ consensus and chain creation to actually work. All three are proven on a live devnet running the equivalent fix (node v1.10.18-strictpq): a full ML-DSA validator mesh forms and the EVM/DEX/FHE chains are created on the sovereign L1. 1. schemeGate nil-gate (network/network.go) Under strict-PQ the TLS layer is transport-only: peer leaf certs are ephemeral ECDSA, which schemeFromCert classifies as classical, so the SchemeGate refuses every peer at the upgrade. Pass a nil gate to the TLS upgraders when PQHandshakeConfig is active; identity is enforced by the PQ handshake instead. Non-PQ paths keep the real gate. 2. pre-dedup PQ handshake (network/network.go + network/peer/peer.go) The PQ handshake ran inside peer.Start, under peersLock, with init/responder roles fixed by dial direction. On simultaneous mutual dials every node acts as a lone initiator (keeps its outbound, drops the peer's inbound at the connecting-dedup) and deadlocks -> 0 peers. Run the handshake per-conn in network.upgrade, before the dedup and without the lock, so each TCP conn completes independently and the dedup keys on the resulting stable ML-DSA NodeID. RunPQHandshakeConn shares main's binding via a new verifyPQIdentityBinding helper, so the pre-dedup and in-Start paths enforce byte-identical identity semantics. 3. classical-compat allow-list (node/node.go) The ClassicalCompatRegistry was nil, so strict-PQ refused every classical secp256k1 P-chain credential and the bootstrap control key could not issue CreateNetwork/CreateChainTx. Seed it (strict-PQ only) from the genesis P-chain allocation owners plus ids.ShortEmpty (the mempool's current originator). This unblocks creating the EVM/DEX/FHE chains. Builds clean with CGO_ENABLED=0; network/peer, network, vms/txs/auth and platformvm genesis/mempool tests pass. main's identity fix is unchanged. Supersedes #136 (which carried these layers on a branch that had diverged 331 commits behind main). Follow-up, tracked separately: config.pemBytesOrFile short-circuits on a blank *-content flag and silently degrades a strict-PQ validator to an ECDSA NodeID. Co-authored-by: Woo Bin <woo.bin@satschel.com>
Member
Author
|
Superseded by #137, which re-lands these same three layers (schemeGate nil-gate, pre-dedup PQ handshake, classical-compat allow-list) as a clean additive change off current |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes the strict-PQ sovereign devnet L1 producing 0 P-chain blocks: consensus never formed a validator mesh because peers connected under ephemeral classical NodeIDs that never matched the genesis ML-DSA validator set.
Root causes (coupled, all scoped to the strict-PQ path)
network/peer/handshake.go): derive the on-wire identity from the persistent ML-DSA-65 staking key (NewLocalIdentityFromMLDSA) and bind the peer's claimed NodeID to its ML-DSA key → the post-handshake NodeID equals the validator-set NodeID (StakingConfig.DeriveNodeID).network/network.go): when the PQ handshake is active, TLS is transport-only (peer leaf certs are ephemeral ECDSA, whichschemeFromCertclassifies classical). Pass a nilSchemeGateto the upgrader so the transport cert isn't refused at the TLS upgrade; identity is enforced by the PQ handshake.network/network.go+network/peer/peer.go): the PQ handshake ran synchronously underpeersLockwith initiator/responder fixed by dial direction, so simultaneous mutual dials deadlocked every node as a lone initiator (each kept its outbound and dropped the peer's inbound at the connecting-dedup) → INIT sent, no responder → 0 peers. Run it per-connection innetwork.upgradebefore the dedup/lock → each TCP connection completes independently (distinct dialer/acceptor); the dedup keys on the stable ML-DSA NodeID.config/config.go+node/node.go): load the staking ML-DSA key intoNetworkConfig.StakingMLDSAPub/Priv; honor the genesis-filesecurityProfilepin;pemBytesOrFileaccepts path-form key flags./lqd/build/lqd→/luxd/build/luxdfor the operator-rendered startup script.Verification
Deployed as
node:v1.10.16-strictpqon devnet. Fresh-genesis result: full validator mesh — every node connected to the other two by its stable ML-DSA NodeID (AwyXmejb/56ks/4tat, matching the genesis validator set), P-chainisBootstrapped:true, pods healthy. Classical / legacy networking is unchanged (PQHandshakeConfignil → nil gate + in-Start handshake). Peer + network unit tests pass.Follow-up (not in this PR)
Creating the EVM chain on the now-live L1 is blocked separately: the strict-PQ P-chain refuses the chainboot's classical secp256k1-signed
CreateNetworkTx(ecdsaContractAuth: forbidden) — the chainboot needs ML-DSA control-key tx-signing.🤖 Generated with Claude Code