mirror of
https://github.com/luxfi/chains.git
synced 2026-09-24 00:26:00 +00:00
An AllocateTx's nonce was covered by the owner's signature and compared to nothing. Every gate it passed the first time passed again for the identical bytes: the epoch was still current, the fingerprint still matched, the signer was still the range owner, the ML-DSA signature still verified. Anyone who saw an owner's allocate could resubmit it and consume the range's id space again, for free, as often as they liked. A range now carries the nonce of the allocation that last moved it, in the same key as its cursor, and takes only a strictly greater one. The state root covers it, so a chain cannot disagree about what has been spent and share a root. Accept committed whatever was staged. Every apply starts by dropping the version layer, so the layer belongs to whichever block was processed LAST — verify a sibling between a block's Verify and its Accept and the commit carried the sibling's writes under this block's id. Accept re-applies its own transactions from committed state, commits, and only then advances the chain in memory, under the lock it was mutating without. Block position is now checked before block content: the parent must be a block this node holds, the height must be parent+1, and the timestamp must not rewind or run past a skew ceiling floored at the parent. None of it was checked, so a block could claim any height — and the height it claimed became the chain's height and part of every state root computed after it. A proposer now leaves out what it cannot build rather than failing the whole build: one unauthorized allocate anyone could submit used to drain the mempool and destroy every manifest queued behind it. A verifier still refuses the whole block, which is the half that carries the safety property. Also: the height lookup reads an index instead of scanning the block map and returning whichever entry map iteration reached first; abandoned blocks are released; a rejected block leaves the index; boot no longer panics on a nil logger when the manager supplies no runtime; and the empty-set check in the owner gate is gone — pinning.Owner already answers that question, and answers the wider one the length check missed. Coverage 69.9% -> 97.9%, with 32 guards mutation-checked. The stale premise in SECURITY_review.md is recorded at its head: it reviewed a checkout that did not contain this VM.
222 lines
9.8 KiB
Go
222 lines
9.8 KiB
Go
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
// allocate_auth.go — the cryptographic half of the leaderless pinned-writer gate.
|
|
//
|
|
// pinning.Owner answers "WHICH validator owns this range" deterministically. This
|
|
// file answers the verifiable question the original gate could not: "did the ACTUAL
|
|
// owner authorize this allocate?" — without trusting any unverifiable proposer
|
|
// identity. An AllocateTx carries an ML-DSA signature by the owner's staking key;
|
|
// the owner's NodeID is a SHAKE256-384 commitment to that key (ids.DeriveMLDSA), so
|
|
// the carried public key is bound to the claimed NodeID and the signature is bound
|
|
// to the key. A non-owner cannot forge it: they cannot sign as the owner (no key),
|
|
// and claiming the owner's NodeID with their own key fails the NodeID re-derivation.
|
|
//
|
|
// The signature uses the SAME ML-DSA scheme the validator NodeID derives from
|
|
// (FIPS 204 ML-DSA-65/87), so the staking identity and the allocate authorization
|
|
// are one key, one trust root. Verification is pure and local: it needs only the
|
|
// tx, the epoch-frozen member set (already required by pinning), and the chain id
|
|
// the NodeIDs were derived under — no network, no live validator lookup.
|
|
package schain
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
|
|
mldsa65 "github.com/luxfi/crypto/pq/mldsa/mldsa65"
|
|
mldsa87 "github.com/luxfi/crypto/pq/mldsa/mldsa87"
|
|
"github.com/luxfi/ids"
|
|
|
|
"github.com/luxfi/chains/schain/txs"
|
|
)
|
|
|
|
// allocateSigContext is the FIPS 204 §5.2 domain-separating context string bound
|
|
// into every allocate signature. It isolates a validator's ML-DSA staking key from
|
|
// any other protocol that key might sign for — a signature made here can never be
|
|
// replayed as a signature in another context, and vice versa.
|
|
var allocateSigContext = []byte("lux/schain/allocate-sig/v1")
|
|
|
|
var (
|
|
// errUnsignedAllocate — a block carried an AllocateTx with no pinned-writer
|
|
// authorization. Fail closed: an unsigned allocate is unauthorized.
|
|
errUnsignedAllocate = errors.New("allocate: missing pinned-writer signature")
|
|
|
|
// errUnknownSignerScheme — the SignerScheme byte names no ML-DSA scheme this
|
|
// build verifies (only the post-quantum ML-DSA family is accepted).
|
|
errUnknownSignerScheme = errors.New("allocate: signer scheme is not ML-DSA")
|
|
|
|
// errSignerKeyMismatch — the carried public key does not re-derive to the
|
|
// claimed Signer NodeID. The NodeID is a commitment to the key, so this means
|
|
// the key was swapped: someone claimed an owner's NodeID with a foreign key.
|
|
errSignerKeyMismatch = errors.New("allocate: signer public key does not bind to signer NodeID")
|
|
|
|
// errBadAllocateSig — the ML-DSA signature did not verify over the canonical
|
|
// signing bytes under the signer's key (forged or tampered).
|
|
errBadAllocateSig = errors.New("allocate: pinned-writer signature is invalid")
|
|
|
|
// errEpochMismatch — the signed Epoch does not equal the block's epoch, so the
|
|
// ownership the signer attested to was resolved against a different set.
|
|
errEpochMismatch = errors.New("allocate: signed epoch does not match block epoch")
|
|
|
|
// errAllocateReplay — the allocate's nonce is one the range has already spent.
|
|
// A signature stays valid forever, so without this an owner's allocate could
|
|
// be resubmitted by anyone to consume the range's id space again.
|
|
errAllocateReplay = errors.New("allocate: nonce already spent for this range")
|
|
|
|
// errEpochFingerprintMismatch — the signed validator-set fingerprint does not
|
|
// match the fingerprint recomputed from the verifier's local epoch snapshot.
|
|
// This is the DESIGN §6.4 local-determinism guard: the proposer pinned against
|
|
// a set the verifier does not hold, so the block is rejected (fail closed)
|
|
// rather than the verifier reaching over the network to reconcile.
|
|
errEpochFingerprintMismatch = errors.New("allocate: epoch validator-set fingerprint mismatch")
|
|
)
|
|
|
|
// isAllocateGateError reports whether err is a pinned-writer safety-gate violation.
|
|
// Every such error FAILS THE WHOLE BLOCK (it is not a soft per-tx failure): letting
|
|
// one through would silently admit an allocate the owner did not authorize — the
|
|
// exact double-write the pinned writer forbids.
|
|
func isAllocateGateError(err error) bool {
|
|
switch {
|
|
case errors.Is(err, errNonOwnerAllocate),
|
|
errors.Is(err, errNoValidatorSet),
|
|
errors.Is(err, errAllocateReplay),
|
|
errors.Is(err, errUnsignedAllocate),
|
|
errors.Is(err, errUnknownSignerScheme),
|
|
errors.Is(err, errSignerKeyMismatch),
|
|
errors.Is(err, errBadAllocateSig),
|
|
errors.Is(err, errEpochMismatch),
|
|
errors.Is(err, errEpochFingerprintMismatch):
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// AllocateSigner stamps an unsigned AllocateTx with the proposer's ML-DSA
|
|
// pinned-writer authorization. It binds one validator's staking key to the
|
|
// allocate gate: its NodeID is the SHAKE256-384 commitment to its public key under
|
|
// identityChainID, and it signs with the matching secret key. The VM installs one
|
|
// on the owning node; BuildBlock uses it to sign the allocates that node owns.
|
|
type AllocateSigner struct {
|
|
scheme ids.NodeIDScheme
|
|
identityChainID ids.ID
|
|
nodeID ids.NodeID
|
|
pub []byte
|
|
sign func(msg, ctx []byte) ([]byte, error)
|
|
}
|
|
|
|
// NewMLDSA65Signer builds an AllocateSigner over an ML-DSA-65 staking key. chainID
|
|
// is the chain the validator NodeIDs are derived under (the same id Verify uses to
|
|
// re-derive and check the NodeID binding).
|
|
func NewMLDSA65Signer(chainID ids.ID, pub *mldsa65.PublicKey, sk *mldsa65.PrivateKey) (*AllocateSigner, error) {
|
|
pubB := pub.Bytes()
|
|
nodeID, _, err := ids.NodeIDSchemeMLDSA65.DeriveMLDSA(chainID, pubB)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("allocate signer: derive node id: %w", err)
|
|
}
|
|
return &AllocateSigner{
|
|
scheme: ids.NodeIDSchemeMLDSA65,
|
|
identityChainID: chainID,
|
|
nodeID: nodeID,
|
|
pub: pubB,
|
|
// Deterministic signatures (randomized=false): reproducible and equally
|
|
// EUF-CMA-secure under FIPS 204.
|
|
sign: func(msg, ctx []byte) ([]byte, error) { return mldsa65.Sign(sk, msg, ctx, false) },
|
|
}, nil
|
|
}
|
|
|
|
// NewMLDSA87Signer builds an AllocateSigner over an ML-DSA-87 staking key (the
|
|
// high-value validator scheme). Identical contract to NewMLDSA65Signer.
|
|
func NewMLDSA87Signer(chainID ids.ID, pub *mldsa87.PublicKey, sk *mldsa87.PrivateKey) (*AllocateSigner, error) {
|
|
pubB := pub.Bytes()
|
|
nodeID, _, err := ids.NodeIDSchemeMLDSA87.DeriveMLDSA(chainID, pubB)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("allocate signer: derive node id: %w", err)
|
|
}
|
|
return &AllocateSigner{
|
|
scheme: ids.NodeIDSchemeMLDSA87,
|
|
identityChainID: chainID,
|
|
nodeID: nodeID,
|
|
pub: pubB,
|
|
sign: func(msg, ctx []byte) ([]byte, error) { return mldsa87.Sign(sk, msg, ctx, false) },
|
|
}, nil
|
|
}
|
|
|
|
// NodeID is this signer's validator NodeID (the candidate range owner).
|
|
func (s *AllocateSigner) NodeID() ids.NodeID { return s.nodeID }
|
|
|
|
// signAllocate stamps Epoch/Nonce/Fingerprint onto an unsigned AllocateTx and
|
|
// signs the resulting canonical bytes, returning the authoritative signed tx.
|
|
func (s *AllocateSigner) signAllocate(tx *txs.AllocateTx, epoch, nonce uint64, fingerprint ids.ID) (*txs.AllocateTx, error) {
|
|
msg := txs.AllocateSigningBytes(tx.Range, tx.Count, epoch, nonce, fingerprint)
|
|
sig, err := s.sign(msg, allocateSigContext)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("allocate signer: sign: %w", err)
|
|
}
|
|
return tx.WithAuthorization(epoch, nonce, fingerprint, s.nodeID, uint8(s.scheme), s.pub, sig), nil
|
|
}
|
|
|
|
// verifyAllocateSig checks the pinned-writer authorization on an AllocateTx:
|
|
//
|
|
// 1. the carried public key re-derives (under identityChainID) to the claimed
|
|
// Signer NodeID — binding the key to the identity; and
|
|
// 2. the ML-DSA signature verifies over the canonical signing bytes under that
|
|
// key — binding the authorization to the key holder.
|
|
//
|
|
// It does NOT decide ownership (whether Signer is the HRW owner) — that is the
|
|
// caller's pinning.Owner check. Together they make a non-owner's forged allocate
|
|
// unverifiable: a non-owner cannot sign as the owner (step 2 fails), and cannot
|
|
// claim the owner's NodeID with a foreign key (step 1 fails).
|
|
func verifyAllocateSig(tx *txs.AllocateTx, identityChainID ids.ID) error {
|
|
if !tx.IsSigned() || len(tx.SignerPubKey) == 0 {
|
|
return errUnsignedAllocate
|
|
}
|
|
scheme := ids.NodeIDScheme(tx.SignerScheme)
|
|
if !scheme.IsPostQuantum() {
|
|
return errUnknownSignerScheme
|
|
}
|
|
|
|
// (1) Bind the public key to the claimed NodeID. The NodeID is a SHAKE256-384
|
|
// commitment to (domain, chainID, scheme, pubkey); re-deriving and comparing it
|
|
// proves the carried key is the genuine key for that NodeID. A forger who claims
|
|
// the owner's NodeID with their own key is rejected here.
|
|
derivedID, _, err := scheme.DeriveMLDSA(identityChainID, tx.SignerPubKey)
|
|
if err != nil {
|
|
return fmt.Errorf("%w: %v", errSignerKeyMismatch, err)
|
|
}
|
|
if derivedID != tx.Signer {
|
|
return errSignerKeyMismatch
|
|
}
|
|
|
|
// (2) Verify the signature over the canonical signing bytes under the bound key.
|
|
ok, err := mldsaVerify(scheme, tx.SignerPubKey, tx.SigningBytes(), allocateSigContext, tx.Sig)
|
|
if err != nil {
|
|
return fmt.Errorf("%w: %v", errBadAllocateSig, err)
|
|
}
|
|
if !ok {
|
|
return errBadAllocateSig
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// mldsaVerify dispatches signature verification to the scheme named by the byte.
|
|
// Only the post-quantum ML-DSA family is accepted.
|
|
func mldsaVerify(scheme ids.NodeIDScheme, pubBytes, msg, ctx, sig []byte) (bool, error) {
|
|
switch scheme {
|
|
case ids.NodeIDSchemeMLDSA65:
|
|
pk := new(mldsa65.PublicKey)
|
|
if err := pk.UnmarshalBinary(pubBytes); err != nil {
|
|
return false, err
|
|
}
|
|
return mldsa65.Verify(pk, msg, ctx, sig), nil
|
|
case ids.NodeIDSchemeMLDSA87:
|
|
pk := new(mldsa87.PublicKey)
|
|
if err := pk.UnmarshalBinary(pubBytes); err != nil {
|
|
return false, err
|
|
}
|
|
return mldsa87.Verify(pk, msg, ctx, sig), nil
|
|
default:
|
|
return false, errUnknownSignerScheme
|
|
}
|
|
}
|