fix(proposervm): strict-PQ proposer identity — sign+derive with ML-DSA-65 to match the windower

Under strict-PQ the canonical NodeID is ML-DSA-65-derived (config/node DeriveNodeID →
DeriveMLDSA), so the P-chain validator set and the proposervm windower are ML-DSA-keyed.
But proposervm signed post-fork blocks with the classical TLS leaf and derived the block
Proposer() via ids.NodeIDFromCert — a different value than DeriveMLDSA — so every signed
block (height ≥ 2) failed verifyPostDurangoBlockDelay with errUnexpectedProposer, was
dropped, and rebuilt in an unbounded storm (block 1 survived only because the unsigned
transition block skips the proposer comparison).

The block's offCert slot now carries a scheme-tagged proposer identity [scheme:1B|identity]:
0x90 classical (cert DER → NodeIDFromCert, ECDSA verify) or 0x42 strict-PQ (raw ML-DSA-65
pubkey → DeriveMLDSA(ids.Empty,pub), ML-DSA verify). ML-DSA signing/verification uses a
FIPS 204 §5.2 domain-separation context ('lux-proposervm-block-v1') so a proposer signature
can never be replayed as another ML-DSA message. proposervm.Config gains StakingMLDSASigner
/StakingMLDSAPub, plumbed from StakingConfig through ManagerConfig; exactly one scheme is
active per chain. K=1 nets are unaffected (transition + no-window blocks are unsigned).

Proven on a 5-node strict-PQ local net: sustained multi-block production, every block
built once, gossiped, and finalized byte-identical on all 5 (no automine). Pins consensus
v1.36.6 (engine logger + logged build-drops) which made this diagnosable.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
This commit is contained in:
zeekay
2026-07-15 17:59:16 -07:00
co-authored by Hanzo Dev
parent 57792ee625
commit 13b6e80a78
8 changed files with 193 additions and 32 deletions
+9 -1
View File
@@ -58,6 +58,7 @@ import (
"github.com/luxfi/constants"
"github.com/luxfi/container/buffer"
"github.com/luxfi/crypto/bls"
"github.com/luxfi/crypto/mldsa"
"github.com/luxfi/filesystem/perms"
"github.com/luxfi/log"
"github.com/luxfi/math/set"
@@ -363,7 +364,12 @@ type ManagerConfig struct {
SybilProtectionEnabled bool
StakingTLSSigner crypto.Signer
StakingTLSCert *staking.Certificate
StakingBLSKey bls.Signer
// Strict-PQ proposer identity. When StakingMLDSASigner is non-nil, chains wrap
// their proposervm with ML-DSA-65 block signing so the signed block's
// Proposer() matches the ML-DSA-keyed validator set. Nil ⇒ classical TLS-leaf.
StakingMLDSASigner *mldsa.PrivateKey
StakingMLDSAPub []byte
StakingBLSKey bls.Signer
TracingEnabled bool
// Must not be used unless [TracingEnabled] is true as this may be nil.
Tracer trace.Tracer
@@ -1287,6 +1293,8 @@ func (m *manager) buildChain(chainParams ChainParameters, sb nets.Net) (*chainIn
NumHistoricalBlocks: proposervm.DefaultNumHistoricalBlocks,
StakingLeafSigner: m.StakingTLSSigner,
StakingCertLeaf: m.StakingTLSCert,
StakingMLDSASigner: m.StakingMLDSASigner,
StakingMLDSAPub: m.StakingMLDSAPub,
Registerer: proposervmReg,
})
m.Log.Info("wrapping chain VM in proposervm for single-proposer-per-height block production",
+1 -1
View File
@@ -26,7 +26,7 @@ require (
github.com/huin/goupnp v1.3.0
github.com/jackpal/gateway v1.1.1
github.com/jackpal/go-nat-pmp v1.0.2
github.com/luxfi/consensus v1.36.2
github.com/luxfi/consensus v1.36.6
github.com/luxfi/crypto v1.20.2
github.com/luxfi/database v1.21.1
github.com/luxfi/ids v1.3.2
+8
View File
@@ -317,6 +317,14 @@ github.com/luxfi/concurrent v0.1.1 h1:LkAmNXybOsAm97nFILqMDBp/YLgleuLmyGomFT7YNx
github.com/luxfi/concurrent v0.1.1/go.mod h1:GlkfiJtPBpatNxvROf7hkPi4gsnmdHGmqhDnVWFrkZE=
github.com/luxfi/consensus v1.36.2 h1:IbeWQF1wEcA/MNxe4fKniSICPndAGEysV/A3DZGMXFM=
github.com/luxfi/consensus v1.36.2/go.mod h1:m4aMFbKYxwM2X4oPYRjq4LmDSShfCgAHnhmxIGKDyFU=
github.com/luxfi/consensus v1.36.3 h1:pEfHf7asnIKQ2Dr2tWe5nKv/0lRRa/iAQ9IyevcRU3s=
github.com/luxfi/consensus v1.36.3/go.mod h1:m4aMFbKYxwM2X4oPYRjq4LmDSShfCgAHnhmxIGKDyFU=
github.com/luxfi/consensus v1.36.4 h1:Pe6srq5vD+ObRQRR8L1F/QVvsW+F86BJMKEiofz7qeQ=
github.com/luxfi/consensus v1.36.4/go.mod h1:m4aMFbKYxwM2X4oPYRjq4LmDSShfCgAHnhmxIGKDyFU=
github.com/luxfi/consensus v1.36.5 h1:kVJp8celtf2RJmMYkkELnrqNXu7HGLmFC+aR6LsBZ34=
github.com/luxfi/consensus v1.36.5/go.mod h1:m4aMFbKYxwM2X4oPYRjq4LmDSShfCgAHnhmxIGKDyFU=
github.com/luxfi/consensus v1.36.6 h1:iXmYQ20A7j8spZldfU/1cbabqolniec/WTseBDH18WQ=
github.com/luxfi/consensus v1.36.6/go.mod h1:m4aMFbKYxwM2X4oPYRjq4LmDSShfCgAHnhmxIGKDyFU=
github.com/luxfi/constants v1.6.2 h1:pXHdKIFbfE9qX4xOjq2LxYvagNhhNvspUVEbPcIEKfA=
github.com/luxfi/constants v1.6.2/go.mod h1:r0oH8C/+r/XFYBq1AJxt6zWRKKRKgDzrEMop/CCs9rI=
github.com/luxfi/container v0.2.1 h1:MTnfKXzS5+oxV5jKZerdOxSA6iMPaQI9/FWGufizzaw=
+2
View File
@@ -1354,6 +1354,8 @@ func (n *Node) initChainManager(utxoAssetID ids.ID) error {
SybilProtectionEnabled: n.Config.SybilProtectionEnabled,
StakingTLSSigner: n.StakingTLSSigner,
StakingTLSCert: n.StakingTLSCert,
StakingMLDSASigner: n.Config.StakingConfig.StakingMLDSA,
StakingMLDSAPub: n.Config.StakingConfig.StakingMLDSAPub,
StakingBLSKey: n.Config.StakingSigningKey,
Log: n.Log,
LogFactory: n.LogFactory,
+25 -10
View File
@@ -307,16 +307,31 @@ func (p *postForkCommonComponents) buildChild(
// Build the child
var statelessChild block.SignedBlock
if shouldBuildSignedBlock {
statelessChild, err = block.Build(
parentID,
newTimestamp,
pChainHeight,
epoch,
p.vm.StakingCertLeaf,
innerBlock.Bytes(),
p.vm.rt.ChainID,
p.vm.StakingLeafSigner,
)
if p.vm.StakingMLDSASigner != nil {
// Strict-PQ: sign with ML-DSA-65 and stamp the raw ML-DSA pubkey so the
// block's Proposer() == the windower's ML-DSA-keyed election.
statelessChild, err = block.BuildMLDSA(
parentID,
newTimestamp,
pChainHeight,
epoch,
p.vm.StakingMLDSAPub,
innerBlock.Bytes(),
p.vm.rt.ChainID,
p.vm.StakingMLDSASigner,
)
} else {
statelessChild, err = block.Build(
parentID,
newTimestamp,
pChainHeight,
epoch,
p.vm.StakingCertLeaf,
innerBlock.Bytes(),
p.vm.rt.ChainID,
p.vm.StakingLeafSigner,
)
}
} else {
statelessChild, err = block.BuildUnsigned(
parentID,
+71 -16
View File
@@ -9,6 +9,7 @@ import (
"time"
"github.com/luxfi/crypto/hash"
"github.com/luxfi/crypto/mldsa"
"github.com/luxfi/ids"
"github.com/luxfi/node/staking"
"github.com/luxfi/zap"
@@ -17,8 +18,26 @@ import (
var (
_ SignedBlock = (*statelessBlock)(nil)
errUnexpectedSignature = errors.New("signature provided when none was expected")
errInvalidCertificate = errors.New("invalid certificate")
errUnexpectedSignature = errors.New("signature provided when none was expected")
errInvalidCertificate = errors.New("invalid certificate")
errUnknownProposerScheme = errors.New("proposervm block: unknown proposer identity scheme")
errMLDSAProposerSigInvalid = errors.New("proposervm block: ML-DSA proposer signature invalid")
)
// Proposer-identity scheme tags stored as the FIRST byte of the offCert slot,
// so a block is self-describing about which primitive proves its proposer.
// They are the SAME bytes as the canonical wire NodeID scheme (ids.NodeIDScheme):
// 0x90 classical secp256k1/ECDSA TLS leaf, 0x42 strict-PQ ML-DSA-65. Keeping the
// tag in the block means a verifier dispatches to the correct verifier + the
// correct NodeID derivation WITHOUT consulting the chain profile — the block
// carries its own identity discriminator, exactly like a TypedNodeID does on the
// handshake. The proposer NodeID a signed block reports MUST equal the NodeID the
// windower elected; the windower reads the P-chain validator set, whose NodeIDs
// are ids.NodeIDFromCert (classical) or DeriveMLDSA(ids.Empty, pub) (strict-PQ) —
// so the two branches below reproduce exactly those two derivations.
const (
schemeSecp256k1 = byte(ids.NodeIDSchemeSecp256k1) // 0x90
schemeMLDSA65 = byte(ids.NodeIDSchemeMLDSA65) // 0x42
)
// Epoch represents a P-Chain epoch for validator set coordination
@@ -66,7 +85,9 @@ type statelessBlock struct {
id ids.ID
timestamp time.Time
cert *staking.Certificate
scheme byte // proposer-identity scheme (0 for unsigned)
cert *staking.Certificate // set for the classical (secp256k1) scheme
mldsaPub *mldsa.PublicKey // set for the strict-PQ (ML-DSA-65) scheme
proposer ids.NodeID
bytes []byte // full signed bytes = unsigned ‖ sig
}
@@ -119,25 +140,49 @@ func (b *statelessBlock) initialize(bytes []byte) error {
root := b.msg.Root()
b.timestamp = time.Unix(root.Int64(offTimestamp), 0)
certBytes := root.Bytes(offCert)
if len(certBytes) == 0 {
// Proposer-identity slot: [scheme:1B | identity]. Empty ⇒ unsigned block.
idSlot := root.Bytes(offCert)
if len(idSlot) == 0 {
return nil
}
b.cert, err = staking.ParseCertificate(certBytes)
if err != nil {
return fmt.Errorf("%w: %w", errInvalidCertificate, err)
b.scheme = idSlot[0]
identity := idSlot[1:]
switch b.scheme {
case schemeMLDSA65:
// Strict-PQ: identity is the raw ML-DSA-65 public key. Derive the proposer
// NodeID the SAME way the node derives its own canonical NodeID and the
// windower reads it — DeriveMLDSA over the empty chain id (node.go boots with
// DeriveNodeID(ids.Empty)). This is what makes Proposer() == the windower's
// elected NodeID under strict-PQ.
pub, err := mldsa.PublicKeyFromBytes(identity, mldsa.MLDSA65)
if err != nil {
return fmt.Errorf("%w: %w", errInvalidCertificate, err)
}
b.mldsaPub = pub
nodeID, _, err := ids.NodeIDSchemeMLDSA65.DeriveMLDSA(ids.Empty, identity)
if err != nil {
return err
}
b.proposer = nodeID
case schemeSecp256k1:
// Classical: identity is the DER TLS cert. NodeID = hash of the cert, the
// legacy upstream derivation used on non-strict-PQ chains.
b.cert, err = staking.ParseCertificate(identity)
if err != nil {
return fmt.Errorf("%w: %w", errInvalidCertificate, err)
}
b.proposer = ids.NodeIDFromCert(&ids.Certificate{
Raw: b.cert.Raw,
PublicKey: b.cert.PublicKey,
})
default:
return fmt.Errorf("%w: 0x%02x", errUnknownProposerScheme, b.scheme)
}
b.proposer = ids.NodeIDFromCert(&ids.Certificate{
Raw: b.cert.Raw,
PublicKey: b.cert.PublicKey,
})
return nil
}
func (b *statelessBlock) verify(chainID ids.ID) error {
if b.cert == nil {
if b.cert == nil && b.mldsaPub == nil {
if len(b.Signature) > 0 {
return errUnexpectedSignature
}
@@ -148,8 +193,18 @@ func (b *statelessBlock) verify(chainID ids.ID) error {
if err != nil {
return err
}
headerBytes := header.Bytes()
if b.mldsaPub != nil {
// FIPS 204 §5.2 domain-separated verification: the same proposervm context
// the signer bound, so a proposer signature can never be replayed as any
// other ML-DSA message (UTXO auth, consensus vote) the validator produces.
if !b.mldsaPub.VerifySignatureCtx(headerBytes, b.Signature, proposerSigCtx) {
return errMLDSAProposerSigInvalid
}
return nil
}
return staking.CheckSignature(
b.cert,
headerBytes,
+66 -4
View File
@@ -9,10 +9,18 @@ import (
"time"
"github.com/luxfi/crypto/hash"
"github.com/luxfi/crypto/mldsa"
"github.com/luxfi/ids"
"github.com/luxfi/node/staking"
)
// proposerSigCtx domain-separates proposervm block signatures (FIPS 204 §5.2)
// from every other ML-DSA signature a validator makes (UTXO auth, consensus
// votes), so a proposer signature can never be replayed as another protocol's
// message. The classical (ECDSA) path gets its cross-chain separation from the
// chainID bound into the signed header.
var proposerSigCtx = []byte("lux-proposervm-block-v1")
func BuildUnsigned(
parentID ids.ID,
timestamp time.Time,
@@ -20,7 +28,9 @@ func BuildUnsigned(
epoch Epoch,
blockBytes []byte,
) (SignedBlock, error) {
// No certificate, no signature: the block bytes are just the unsigned body.
// No identity, no signature: the block bytes are just the unsigned body. This
// is the pre-fork→post-fork transition block and every K=1 block (no proposer
// window), so small local nets never exercise a signed path.
unsignedBytes := buildUnsignedBuffer(parentID, timestamp.Unix(), pChainHeight, epoch, nil, blockBytes)
block := &statelessBlock{}
@@ -30,6 +40,9 @@ func BuildUnsigned(
return block, nil
}
// Build produces a CLASSICAL (secp256k1/ECDSA TLS-leaf) signed block: identity
// slot = [schemeSecp256k1 | cert DER], signature = ECDSA over SHA256(header).
// Used on chains whose validator set is keyed by classical NodeIDs.
func Build(
parentID ids.ID,
timestamp time.Time,
@@ -40,7 +53,57 @@ func Build(
chainID ids.ID,
key crypto.Signer,
) (SignedBlock, error) {
unsignedBytes := buildUnsignedBuffer(parentID, timestamp.Unix(), pChainHeight, epoch, cert.Raw, blockBytes)
return buildSigned(parentID, timestamp, pChainHeight, epoch,
schemeSecp256k1, cert.Raw, blockBytes, chainID,
func(headerBytes []byte) ([]byte, error) {
// ECDSA signs the digest; CheckSignature re-hashes the header to match.
return key.Sign(rand.Reader, hash.ComputeHash256(headerBytes), crypto.SHA256)
})
}
// BuildMLDSA produces a strict-PQ (ML-DSA-65, FIPS 204) signed block: identity
// slot = [schemeMLDSA65 | raw ML-DSA-65 pubkey], signature = ML-DSA over the raw
// header with the proposervm domain-separation context. The block's Proposer()
// NodeID derives via DeriveMLDSA, so it EQUALS the strict-PQ NodeID the windower
// elected — the fix for the height≥2 "unexpected proposer" verify failure.
func BuildMLDSA(
parentID ids.ID,
timestamp time.Time,
pChainHeight uint64,
epoch Epoch,
mldsaPub []byte,
blockBytes []byte,
chainID ids.ID,
key *mldsa.PrivateKey,
) (SignedBlock, error) {
return buildSigned(parentID, timestamp, pChainHeight, epoch,
schemeMLDSA65, mldsaPub, blockBytes, chainID,
func(headerBytes []byte) ([]byte, error) {
return key.SignCtx(rand.Reader, headerBytes, proposerSigCtx)
})
}
// buildSigned is the one signed-block core: it composes the [scheme|identity]
// proposer slot, encodes the unsigned body, derives the block ID, builds the
// (chainID, parentID, blockID) header, calls the scheme's signer over it, and
// appends the signature buffer. Build / BuildMLDSA differ ONLY in the scheme tag,
// the identity bytes, and the sign closure — DRY across primitives.
func buildSigned(
parentID ids.ID,
timestamp time.Time,
pChainHeight uint64,
epoch Epoch,
scheme byte,
identity []byte,
blockBytes []byte,
chainID ids.ID,
sign func(headerBytes []byte) ([]byte, error),
) (SignedBlock, error) {
idSlot := make([]byte, 0, 1+len(identity))
idSlot = append(idSlot, scheme)
idSlot = append(idSlot, identity...)
unsignedBytes := buildUnsignedBuffer(parentID, timestamp.Unix(), pChainHeight, epoch, idSlot, blockBytes)
// The block ID is the hash of the unsigned prefix; the proposer signs a
// header binding (chainID, parentID, blockID).
@@ -51,8 +114,7 @@ func Build(
return nil, err
}
headerHash := hash.ComputeHash256(header.Bytes())
sig, err := key.Sign(rand.Reader, headerHash, crypto.SHA256)
sig, err := sign(header.Bytes())
if err != nil {
return nil, err
}
+11
View File
@@ -7,6 +7,7 @@ import (
"crypto"
"time"
"github.com/luxfi/crypto/mldsa"
"github.com/luxfi/ids"
"github.com/luxfi/metric"
@@ -44,6 +45,16 @@ type Config struct {
// Block certificate
StakingCertLeaf *staking.Certificate
// Strict-PQ block signer. When StakingMLDSASigner is non-nil the proposer
// signs post-fork blocks with ML-DSA-65 and stamps the block's proposer
// identity as the raw ML-DSA-65 public key, so Proposer() derives via
// DeriveMLDSA and EQUALS the strict-PQ NodeID the windower elected (the
// validator set is ML-DSA-keyed under strict-PQ). Nil ⇒ classical TLS-leaf
// signing above. Exactly one of the two schemes is active per chain, chosen at
// wiring time from the resolved ChainSecurityProfile.
StakingMLDSASigner *mldsa.PrivateKey
StakingMLDSAPub []byte
// Registerer for metric metrics
Registerer metric.Registerer