Skip to content

fix(network): strict-PQ peer handshake forms the validator mesh (devnet P-chain 0-blocks)#136

Closed
Darkhorse7stars wants to merge 2 commits into
mainfrom
fix/strict-pq-activation
Closed

fix(network): strict-PQ peer handshake forms the validator mesh (devnet P-chain 0-blocks)#136
Darkhorse7stars wants to merge 2 commits into
mainfrom
fix/strict-pq-activation

Conversation

@Darkhorse7stars

Copy link
Copy Markdown
Member

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)

  1. Peer identity (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).
  2. schemeGate vs ephemeral cert (network/network.go): when the PQ handshake is active, TLS is transport-only (peer leaf certs are ephemeral ECDSA, 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.
  3. Mutual-dial deadlock (network/network.go + network/peer/peer.go): the PQ handshake 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. Run it per-connection in network.upgrade before the dedup/lock → each TCP connection completes independently (distinct dialer/acceptor); the dedup keys on the stable ML-DSA NodeID.
  4. Key plumbing (config/config.go + node/node.go): load the staking ML-DSA key into 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.

Verification

Deployed as node:v1.10.16-strictpq on 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-chain isBootstrapped:true, pods healthy. Classical / legacy networking is unchanged (PQHandshakeConfig nil → 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

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>
@Darkhorse7stars

Copy link
Copy Markdown
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 main — keeping main's identity fix (NewLocalIdentityFromStakingKey / adoptVerifiedPQIdentity) intact. #137 is merged (a14518b). This branch had drifted 331 commits behind and conflicted on the identity layer.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants