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