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.
762 lines
29 KiB
Go
762 lines
29 KiB
Go
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
// allocate_test.go — the end-to-end proof for the SIGNED leaderless pinned-writer
|
|
// ALLOCATOR. It proves the property that replaces raft's single-serialized-writer
|
|
// role WITHOUT a leader election AND without trusting any unverifiable proposer
|
|
// identity: an AllocateTx for range R commits ONLY if it carries a valid ML-DSA
|
|
// signature by pinning.Owner(R, V@epoch) — verified purely and locally inside block
|
|
// apply against the epoch-frozen validator set.
|
|
//
|
|
// owner-signed allocate → AllocateTx signed by the range owner commits; the
|
|
// reserved [base, base+Count) is contiguous + monotonic.
|
|
// non-owner (valid key) → a validator with a real staking key that is NOT the
|
|
// HRW owner of R cannot get its allocate accepted
|
|
// (errNonOwnerAllocate) — even though its signature is
|
|
// cryptographically valid. THE security property.
|
|
// forged signature → claiming the owner's NodeID + key with a bad signature
|
|
// is rejected (errBadAllocateSig).
|
|
// foreign key → claiming the owner's NodeID with a different key is
|
|
// rejected (errSignerKeyMismatch) — the NodeID binds the
|
|
// key.
|
|
// tampered tx → a signature over a different Range/Count does not
|
|
// verify against the carried fields (errBadAllocateSig).
|
|
// epoch / fingerprint → an allocate pinned against a different epoch or
|
|
// validator set than the verifier holds is rejected
|
|
// (errEpochMismatch / errEpochFingerprintMismatch).
|
|
package schain
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"errors"
|
|
"testing"
|
|
|
|
mldsa65 "github.com/luxfi/crypto/pq/mldsa/mldsa65"
|
|
"github.com/luxfi/ids"
|
|
|
|
"github.com/luxfi/chains/schain/pinning"
|
|
"github.com/luxfi/chains/schain/txs"
|
|
)
|
|
|
|
// testIdentityChainID is the chain id the test validator NodeIDs are derived under
|
|
// (ids.DeriveMLDSA). Verify re-derives a signer's NodeID under this same id, so it
|
|
// must match across the whole test.
|
|
var testIdentityChainID = ids.ID{
|
|
0x5c, 0x7a, 0x1e, 0x42, 0x9b, 0x0d, 0xf3, 0x11,
|
|
0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99,
|
|
0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x01, 0x02,
|
|
0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a,
|
|
}
|
|
|
|
// testVal is a deterministic validator with a REAL ML-DSA-65 staking key. Its
|
|
// NodeID is the SHAKE256-384 commitment to its public key (ids.DeriveMLDSA) — the
|
|
// same derivation a real validator's NodeID uses — so signatures verify and the
|
|
// key-to-NodeID binding holds exactly as in production.
|
|
type testVal struct {
|
|
nodeID ids.NodeID
|
|
pub *mldsa65.PublicKey
|
|
sk *mldsa65.PrivateKey
|
|
weight uint64
|
|
}
|
|
|
|
// newTestValidators builds n deterministic validators with real ML-DSA-65 keys and
|
|
// production-derived NodeIDs.
|
|
func newTestValidators(t *testing.T, n int) []testVal {
|
|
t.Helper()
|
|
vals := make([]testVal, n)
|
|
for i := range vals {
|
|
var seed [mldsa65.SeedSize]byte
|
|
seed[0] = byte(i + 1)
|
|
seed[1] = 0xA5 // domain nudge so seeds are not trivially small
|
|
pub, sk, err := mldsa65.NewKeyFromSeed(seed[:])
|
|
if err != nil {
|
|
t.Fatalf("validator %d keygen: %v", i, err)
|
|
}
|
|
nodeID, _, err := ids.NodeIDSchemeMLDSA65.DeriveMLDSA(testIdentityChainID, pub.Bytes())
|
|
if err != nil {
|
|
t.Fatalf("validator %d derive NodeID: %v", i, err)
|
|
}
|
|
vals[i] = testVal{nodeID: nodeID, pub: pub, sk: sk, weight: 10}
|
|
}
|
|
return vals
|
|
}
|
|
|
|
// membersOf projects validators to the (NodeID, Weight) member set pinning needs.
|
|
func membersOf(vals []testVal) []pinning.Member {
|
|
ms := make([]pinning.Member, len(vals))
|
|
for i, v := range vals {
|
|
ms[i] = pinning.Member{NodeID: v.nodeID, Weight: v.weight}
|
|
}
|
|
return ms
|
|
}
|
|
|
|
// valByID returns the validator with the given NodeID.
|
|
func valByID(t *testing.T, vals []testVal, id ids.NodeID) testVal {
|
|
t.Helper()
|
|
for _, v := range vals {
|
|
if v.nodeID == id {
|
|
return v
|
|
}
|
|
}
|
|
t.Fatalf("no validator with NodeID %s", id)
|
|
return testVal{}
|
|
}
|
|
|
|
// ownerOf returns the HRW owner NodeID of range R under members.
|
|
func ownerOf(t *testing.T, rng string, members []pinning.Member) ids.NodeID {
|
|
t.Helper()
|
|
owner, ok := pinning.Owner([]byte(rng), members)
|
|
if !ok {
|
|
t.Fatalf("no owner for range %q", rng)
|
|
}
|
|
return owner
|
|
}
|
|
|
|
// ownerVal returns the validator that owns range R.
|
|
func ownerVal(t *testing.T, rng string, vals []testVal) testVal {
|
|
t.Helper()
|
|
return valByID(t, vals, ownerOf(t, rng, membersOf(vals)))
|
|
}
|
|
|
|
// nonOwnerVal returns a validator that is NOT the owner of range R (a valid staking
|
|
// key that nonetheless has no right to allocate R).
|
|
func nonOwnerVal(t *testing.T, rng string, vals []testVal) testVal {
|
|
t.Helper()
|
|
owner := ownerOf(t, rng, membersOf(vals))
|
|
for _, v := range vals {
|
|
if v.nodeID != owner {
|
|
return v
|
|
}
|
|
}
|
|
t.Fatalf("no non-owner validator for range %q", rng)
|
|
return testVal{}
|
|
}
|
|
|
|
// withValidatorSet installs the deterministic BlockContext (the frozen set + epoch +
|
|
// identity chain id) AND the proposer's ML-DSA signer, so BuildBlock signs the
|
|
// allocates this node owns. Every height resolves to the SAME (set, epoch, identity),
|
|
// so the owner verdict and fingerprint are reproducible network-wide.
|
|
func withValidatorSet(t *testing.T, cvm *ChainVM, vals []testVal, proposer testVal) {
|
|
t.Helper()
|
|
mem := membersOf(vals)
|
|
cvm.SetBlockContextBuilder(func(_ context.Context, height uint64) (BlockContext, error) {
|
|
return BlockContext{
|
|
Members: mem,
|
|
Proposer: proposer.nodeID,
|
|
Epoch: height,
|
|
IdentityChainID: testIdentityChainID,
|
|
}, nil
|
|
})
|
|
signer, err := NewMLDSA65Signer(testIdentityChainID, proposer.pub, proposer.sk)
|
|
if err != nil {
|
|
t.Fatalf("install signer: %v", err)
|
|
}
|
|
cvm.SetAllocateSigner(signer)
|
|
}
|
|
|
|
// allocate drives one UNSIGNED AllocateTx through Build -> Verify -> Accept. The
|
|
// installed proposer signer stamps the authorization at BuildBlock. Returns the
|
|
// [base, base+Count) range reserved (read from committed state); a non-nil error is
|
|
// the gate rejection.
|
|
func allocate(t *testing.T, cvm *ChainVM, rng string, count uint32) (base, next uint64, err error) {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
|
|
pre, gerr := cvm.inner.state.GetAlloc(rng)
|
|
if gerr != nil {
|
|
t.Fatalf("GetAlloc(pre): %v", gerr)
|
|
}
|
|
base = pre.Next
|
|
|
|
tx := txs.NewAllocateTx(rng, count)
|
|
if serr := cvm.SubmitTx(tx.Bytes()); serr != nil {
|
|
t.Fatalf("SubmitTx: %v", serr)
|
|
}
|
|
blk, berr := cvm.BuildBlock(ctx)
|
|
if berr != nil {
|
|
return base, base, berr
|
|
}
|
|
if len(blk.(*Block).txs) == 0 {
|
|
// The proposer left the allocate out of its own block — a non-owner cannot
|
|
// authorize a range it does not own, so it does not build a block carrying
|
|
// one. A builder drops in silence, so ask a verifier WHY: it names the gate.
|
|
return base, base, refusalFor(t, cvm, rng, count)
|
|
}
|
|
if verr := blk.Verify(ctx); verr != nil {
|
|
return base, base, verr
|
|
}
|
|
if aerr := blk.Accept(ctx); aerr != nil {
|
|
t.Fatalf("Accept: %v", aerr)
|
|
}
|
|
post, gerr := cvm.inner.state.GetAlloc(rng)
|
|
if gerr != nil {
|
|
t.Fatalf("GetAlloc(post): %v", gerr)
|
|
}
|
|
return base, post.Next, nil
|
|
}
|
|
|
|
// refusalFor stamps the authorization this node's proposer would have produced
|
|
// for (rng, count) and asks a verifier to judge a block carrying it, returning
|
|
// the gate's verdict. It is how a test recovers the REASON a proposer left an
|
|
// allocate out of its own block.
|
|
func refusalFor(t *testing.T, cvm *ChainVM, rng string, count uint32) error {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
|
|
cvm.lock.RLock()
|
|
height := cvm.lastAcceptedHeight + 1
|
|
signer := cvm.allocateSigner
|
|
cvm.lock.RUnlock()
|
|
|
|
blockCtx, err := cvm.resolveBlockContext(ctx, height)
|
|
if err != nil {
|
|
t.Fatalf("resolve block context: %v", err)
|
|
}
|
|
intent := txs.NewAllocateTx(rng, count)
|
|
if signer == nil || len(blockCtx.Members) == 0 {
|
|
// No signer or no set: the intent travels unsigned, exactly as
|
|
// signOwnedAllocates would leave it.
|
|
return carry(t, cvm, intent.Bytes()).Verify(ctx)
|
|
}
|
|
fp := pinning.EpochFingerprint(blockCtx.Epoch, blockCtx.Members)
|
|
signed, serr := signer.signAllocate(intent, blockCtx.Epoch, height, fp)
|
|
if serr != nil {
|
|
t.Fatalf("sign allocate: %v", serr)
|
|
}
|
|
return carry(t, cvm, signed.Bytes()).Verify(ctx)
|
|
}
|
|
|
|
// signAllocateAs signs the canonical allocate bytes with v's ML-DSA-65 key.
|
|
func signAllocateAs(t *testing.T, v testVal, rng string, count uint32, epoch, nonce uint64, fp ids.ID) []byte {
|
|
t.Helper()
|
|
msg := txs.AllocateSigningBytes(rng, count, epoch, nonce, fp)
|
|
sig, err := mldsa65.Sign(v.sk, msg, allocateSigContext, false)
|
|
if err != nil {
|
|
t.Fatalf("sign: %v", err)
|
|
}
|
|
return sig
|
|
}
|
|
|
|
// buildPreSigned puts an already-signed (possibly adversarial) AllocateTx into a
|
|
// block DIRECTLY, bypassing the proposer's own selection, and asks a verifier to
|
|
// judge it. That is the safety question the pinned writer exists to answer: a
|
|
// proposer that includes an allocate the owner did not authorize must not get
|
|
// the block accepted. The returned error is the gate's verdict; nil means the
|
|
// block was accepted.
|
|
//
|
|
// A node's own BuildBlock leaves such a transaction OUT rather than failing —
|
|
// that is the liveness half, proven separately in TestBuilderDropsWhatItCannotAuthorize.
|
|
func buildPreSigned(t *testing.T, cvm *ChainVM, signed *txs.AllocateTx) error {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
blk := carry(t, cvm, signed.Bytes())
|
|
if err := blk.Verify(ctx); err != nil {
|
|
return err
|
|
}
|
|
return blk.Accept(ctx)
|
|
}
|
|
|
|
// carry builds a block carrying exactly these transaction bytes on top of the
|
|
// last accepted block, with the same deterministic block context a proposer
|
|
// would resolve. It is how a test plays a proposer that includes whatever it
|
|
// likes — the input a verifier actually has to survive.
|
|
func carry(t *testing.T, cvm *ChainVM, txBytes ...[]byte) *Block {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
|
|
cvm.lock.Lock()
|
|
parentID, height := cvm.lastAcceptedID, cvm.lastAcceptedHeight+1
|
|
cvm.lock.Unlock()
|
|
|
|
blockCtx, err := cvm.resolveBlockContext(ctx, height)
|
|
if err != nil {
|
|
t.Fatalf("resolve block context: %v", err)
|
|
}
|
|
blk := &Block{
|
|
vm: cvm, parentID: parentID, height: height,
|
|
timestamp: cvm.inner.clock.Time(), txs: txBytes,
|
|
blockCtx: blockCtx, status: StatusProcessing,
|
|
}
|
|
hash := sha256.Sum256(blk.Bytes())
|
|
copy(blk.id[:], hash[:])
|
|
|
|
// The proposer's claimed root is the one an honest apply of these exact
|
|
// transactions produces, so the root check never masks the gate verdict.
|
|
result, err := cvm.inner.Select(height, blk.timestamp, txBytes, blockCtx)
|
|
if err != nil {
|
|
t.Fatalf("compute claimed root: %v", err)
|
|
}
|
|
cvm.inner.abort()
|
|
blk.stateRoot = result.StateRoot
|
|
hash = sha256.Sum256(blk.Bytes())
|
|
copy(blk.id[:], hash[:])
|
|
|
|
cvm.lock.Lock()
|
|
cvm.blocks[blk.id] = blk
|
|
cvm.lock.Unlock()
|
|
return blk
|
|
}
|
|
|
|
// TestOwnerSignedAllocatesContiguousMonotonic proves: an AllocateTx signed by the
|
|
// range OWNER commits; the reserved id range is [base, base+Count); and across
|
|
// blocks the counter is contiguous + strictly monotonic. This is the allocator
|
|
// behaviour raft gave via its single leader — now from the signed HRW owner with no
|
|
// election.
|
|
func TestOwnerSignedAllocatesContiguousMonotonic(t *testing.T) {
|
|
cvm, _ := newTestVM(t)
|
|
vals := newTestValidators(t, 5)
|
|
const rng = "bucket-A"
|
|
withValidatorSet(t, cvm, vals, ownerVal(t, rng, vals))
|
|
|
|
base, next, err := allocate(t, cvm, rng, 8)
|
|
if err != nil {
|
|
t.Fatalf("owner allocate rejected: %v", err)
|
|
}
|
|
if base != 0 || next != 8 {
|
|
t.Fatalf("first allocation = [%d,%d), want [0,8)", base, next)
|
|
}
|
|
|
|
base2, next2, err := allocate(t, cvm, rng, 5)
|
|
if err != nil {
|
|
t.Fatalf("second owner allocate rejected: %v", err)
|
|
}
|
|
if base2 != next {
|
|
t.Fatalf("allocation not contiguous: block2 base %d != block1 next %d", base2, next)
|
|
}
|
|
if base2 != 8 || next2 != 13 {
|
|
t.Fatalf("second allocation = [%d,%d), want [8,13)", base2, next2)
|
|
}
|
|
if n, _ := cvm.inner.state.GetAlloc(rng); n.Next != 13 {
|
|
t.Fatalf("committed counter = %d, want 13", n)
|
|
}
|
|
}
|
|
|
|
// TestNonOwnerWithValidKeyRejected is THE security property: a validator with a
|
|
// REAL, valid staking key — whose signature verifies perfectly — but who is NOT the
|
|
// HRW owner of the range CANNOT get its allocate accepted. This is what replaces
|
|
// raft's serialized writer: an adversary cannot manufacture a second writer for a
|
|
// range it does not own, even holding a legitimate validator key.
|
|
func TestNonOwnerWithValidKeyRejected(t *testing.T) {
|
|
cvm, _ := newTestVM(t)
|
|
vals := newTestValidators(t, 5)
|
|
const rng = "bucket-A"
|
|
owner := ownerVal(t, rng, vals)
|
|
attacker := nonOwnerVal(t, rng, vals)
|
|
|
|
// Resolve the block context the gate will use: epoch 1 (first block), the real
|
|
// member set, its fingerprint.
|
|
const epoch = 1
|
|
fp := pinning.EpochFingerprint(epoch, membersOf(vals))
|
|
|
|
// The attacker signs a perfectly well-formed allocate with ITS OWN valid key.
|
|
sig := signAllocateAs(t, attacker, rng, 4, epoch, epoch, fp)
|
|
adversarial := txs.NewAllocateTx(rng, 4).WithAuthorization(
|
|
epoch, epoch, fp, attacker.nodeID, uint8(ids.NodeIDSchemeMLDSA65), attacker.pub.Bytes(), sig,
|
|
)
|
|
|
|
// Install the validator set; the proposer signer is irrelevant (pre-signed txs
|
|
// pass through). The gate must reject: signer != HRW owner.
|
|
withValidatorSet(t, cvm, vals, owner)
|
|
err := buildPreSigned(t, cvm, adversarial)
|
|
if !errors.Is(err, errNonOwnerAllocate) {
|
|
t.Fatalf("non-owner-with-valid-key accepted: err = %v, want errNonOwnerAllocate", err)
|
|
}
|
|
if n, _ := cvm.inner.state.GetAlloc(rng); n.Next != 0 {
|
|
t.Fatalf("rejected adversarial allocate moved the counter to %d, want 0", n)
|
|
}
|
|
}
|
|
|
|
// TestForgedSignatureRejected proves a forged signature is caught: the adversary
|
|
// claims the OWNER's NodeID and the OWNER's real public key (so the key-to-NodeID
|
|
// binding passes) but supplies a signature it could not have produced (a valid
|
|
// signature over DIFFERENT bytes). ML-DSA verification fails.
|
|
func TestForgedSignatureRejected(t *testing.T) {
|
|
cvm, _ := newTestVM(t)
|
|
vals := newTestValidators(t, 5)
|
|
const rng = "bucket-A"
|
|
owner := ownerVal(t, rng, vals)
|
|
|
|
const epoch = 1
|
|
fp := pinning.EpochFingerprint(epoch, membersOf(vals))
|
|
|
|
// A signature the adversary CAN make: the owner's own key over a DIFFERENT
|
|
// allocate (count 999). It will not verify against the carried count (4). Since
|
|
// the adversary does not hold the owner's key in reality, this models the best a
|
|
// forger can do — paste some otherwise-valid-looking blob.
|
|
wrongSig := signAllocateAs(t, owner, rng, 999, epoch, epoch, fp)
|
|
forged := txs.NewAllocateTx(rng, 4).WithAuthorization(
|
|
epoch, epoch, fp, owner.nodeID, uint8(ids.NodeIDSchemeMLDSA65), owner.pub.Bytes(), wrongSig,
|
|
)
|
|
|
|
withValidatorSet(t, cvm, vals, owner)
|
|
err := buildPreSigned(t, cvm, forged)
|
|
if !errors.Is(err, errBadAllocateSig) {
|
|
t.Fatalf("forged signature accepted: err = %v, want errBadAllocateSig", err)
|
|
}
|
|
if n, _ := cvm.inner.state.GetAlloc(rng); n.Next != 0 {
|
|
t.Fatalf("forged allocate moved the counter to %d, want 0", n)
|
|
}
|
|
}
|
|
|
|
// TestForeignKeyForOwnerNodeIDRejected proves the NodeID binds the key: an adversary
|
|
// claims the owner's NodeID but presents its OWN key (and a valid signature under
|
|
// that key). Re-deriving the NodeID from the carried key yields the adversary's
|
|
// NodeID, not the owner's — rejected before the signature is even checked.
|
|
func TestForeignKeyForOwnerNodeIDRejected(t *testing.T) {
|
|
cvm, _ := newTestVM(t)
|
|
vals := newTestValidators(t, 5)
|
|
const rng = "bucket-A"
|
|
owner := ownerVal(t, rng, vals)
|
|
attacker := nonOwnerVal(t, rng, vals)
|
|
|
|
const epoch = 1
|
|
fp := pinning.EpochFingerprint(epoch, membersOf(vals))
|
|
|
|
// Adversary signs with its own key (valid sig) but claims the owner's NodeID and
|
|
// carries its OWN public key.
|
|
sig := signAllocateAs(t, attacker, rng, 4, epoch, epoch, fp)
|
|
spoofed := txs.NewAllocateTx(rng, 4).WithAuthorization(
|
|
epoch, epoch, fp, owner.nodeID, uint8(ids.NodeIDSchemeMLDSA65), attacker.pub.Bytes(), sig,
|
|
)
|
|
|
|
withValidatorSet(t, cvm, vals, owner)
|
|
err := buildPreSigned(t, cvm, spoofed)
|
|
if !errors.Is(err, errSignerKeyMismatch) {
|
|
t.Fatalf("foreign-key-for-owner-NodeID accepted: err = %v, want errSignerKeyMismatch", err)
|
|
}
|
|
}
|
|
|
|
// TestTamperedAllocateRejected proves a signature does not transfer to a tampered
|
|
// tx: the owner signs an allocate for count 4; an adversary swaps the count to 8 (or
|
|
// the range) while keeping the signature. The signing bytes no longer match, so
|
|
// ML-DSA verification fails.
|
|
func TestTamperedAllocateRejected(t *testing.T) {
|
|
cvm, _ := newTestVM(t)
|
|
vals := newTestValidators(t, 5)
|
|
const rng = "bucket-A"
|
|
owner := ownerVal(t, rng, vals)
|
|
|
|
const epoch = 1
|
|
fp := pinning.EpochFingerprint(epoch, membersOf(vals))
|
|
|
|
// Owner legitimately signs count=4. Adversary keeps the signature but builds the
|
|
// tx with count=8.
|
|
sigOver4 := signAllocateAs(t, owner, rng, 4, epoch, epoch, fp)
|
|
tampered := txs.NewAllocateTx(rng, 8).WithAuthorization(
|
|
epoch, epoch, fp, owner.nodeID, uint8(ids.NodeIDSchemeMLDSA65), owner.pub.Bytes(), sigOver4,
|
|
)
|
|
|
|
withValidatorSet(t, cvm, vals, owner)
|
|
err := buildPreSigned(t, cvm, tampered)
|
|
if !errors.Is(err, errBadAllocateSig) {
|
|
t.Fatalf("tampered allocate accepted: err = %v, want errBadAllocateSig", err)
|
|
}
|
|
}
|
|
|
|
// TestEpochFingerprintMismatchRejected proves the §6.4 local-determinism guard: a
|
|
// proposer that pinned against a validator set the verifier does not hold (its
|
|
// self-attested fingerprint disagrees with the verifier's local snapshot) is
|
|
// rejected — the verifier never reaches over the network to reconcile.
|
|
func TestEpochFingerprintMismatchRejected(t *testing.T) {
|
|
cvm, _ := newTestVM(t)
|
|
vals := newTestValidators(t, 5)
|
|
const rng = "bucket-A"
|
|
owner := ownerVal(t, rng, vals)
|
|
|
|
const epoch = 1
|
|
// A fingerprint that does NOT match the real set (models pinning against a
|
|
// different/foreign validator set). The owner signs over this wrong fingerprint
|
|
// so the signature is internally consistent — the gate still rejects because the
|
|
// verifier recomputes the TRUE fingerprint from its snapshot.
|
|
wrongFP := ids.ID{0xFF, 0xEE, 0xDD, 0xCC}
|
|
sig := signAllocateAs(t, owner, rng, 4, epoch, epoch, wrongFP)
|
|
mismatched := txs.NewAllocateTx(rng, 4).WithAuthorization(
|
|
epoch, epoch, wrongFP, owner.nodeID, uint8(ids.NodeIDSchemeMLDSA65), owner.pub.Bytes(), sig,
|
|
)
|
|
|
|
withValidatorSet(t, cvm, vals, owner)
|
|
err := buildPreSigned(t, cvm, mismatched)
|
|
if !errors.Is(err, errEpochFingerprintMismatch) {
|
|
t.Fatalf("epoch-fingerprint mismatch accepted: err = %v, want errEpochFingerprintMismatch", err)
|
|
}
|
|
}
|
|
|
|
// TestEpochMismatchRejected proves cross-epoch replay is rejected: an allocate
|
|
// signed for a different epoch than the block resolves the validator set at is
|
|
// refused, so a signature valid when the signer owned the range cannot be replayed
|
|
// into an epoch where ownership may have moved.
|
|
func TestEpochMismatchRejected(t *testing.T) {
|
|
cvm, _ := newTestVM(t)
|
|
vals := newTestValidators(t, 5)
|
|
const rng = "bucket-A"
|
|
owner := ownerVal(t, rng, vals)
|
|
|
|
// Block epoch will be 1 (first block); the adversary signs/claims epoch 99.
|
|
const wrongEpoch = 99
|
|
fp := pinning.EpochFingerprint(wrongEpoch, membersOf(vals))
|
|
sig := signAllocateAs(t, owner, rng, 4, wrongEpoch, wrongEpoch, fp)
|
|
staleEpoch := txs.NewAllocateTx(rng, 4).WithAuthorization(
|
|
wrongEpoch, wrongEpoch, fp, owner.nodeID, uint8(ids.NodeIDSchemeMLDSA65), owner.pub.Bytes(), sig,
|
|
)
|
|
|
|
withValidatorSet(t, cvm, vals, owner)
|
|
err := buildPreSigned(t, cvm, staleEpoch)
|
|
if !errors.Is(err, errEpochMismatch) {
|
|
t.Fatalf("epoch-mismatched allocate accepted: err = %v, want errEpochMismatch", err)
|
|
}
|
|
}
|
|
|
|
// TestNonOwnerSignedAllocateRejected is the proposer-side counterpart: a node that
|
|
// is NOT the owner tries to allocate through the normal (auto-signing) path. Its
|
|
// signer stamps ITS OWN NodeID, which is not the HRW owner, so the build fails and
|
|
// the counter is untouched.
|
|
func TestNonOwnerSignedAllocateRejected(t *testing.T) {
|
|
cvm, _ := newTestVM(t)
|
|
vals := newTestValidators(t, 5)
|
|
const rng = "bucket-A"
|
|
withValidatorSet(t, cvm, vals, nonOwnerVal(t, rng, vals))
|
|
|
|
_, _, err := allocate(t, cvm, rng, 4)
|
|
if !errors.Is(err, errNonOwnerAllocate) {
|
|
t.Fatalf("non-owner allocate: err = %v, want errNonOwnerAllocate", err)
|
|
}
|
|
if n, _ := cvm.inner.state.GetAlloc(rng); n.Next != 0 {
|
|
t.Fatalf("rejected allocate moved the counter to %d, want 0", n)
|
|
}
|
|
}
|
|
|
|
// TestEmptyValidatorSetFailsClosed proves the gate fails CLOSED: with no validator
|
|
// set, no AllocateTx may commit (ownership cannot be proven, so we never default to
|
|
// "the proposer is the owner").
|
|
func TestEmptyValidatorSetFailsClosed(t *testing.T) {
|
|
cvm, _ := newTestVM(t)
|
|
// No BlockContextBuilder / signer installed → empty context → no members.
|
|
_, _, err := allocate(t, cvm, "any-range", 1)
|
|
if !errors.Is(err, errNoValidatorSet) {
|
|
t.Fatalf("error = %v, want errNoValidatorSet", err)
|
|
}
|
|
}
|
|
|
|
// TestVerifierAcceptsOwnerSignedBlockRegardlessOfRelay proves the signature — not an
|
|
// unverifiable proposer identity — is the trust root. A proposer (owner) builds an
|
|
// allocate block; a verifier with the SAME validator set but a DIFFERENT Proposer in
|
|
// its own context ACCEPTS it, because the gate keys on the tx's ML-DSA signer. This
|
|
// is the property the original proposer-based gate could not give: a verifying node
|
|
// can check ownership locally with no knowledge of who relayed the block.
|
|
func TestVerifierAcceptsOwnerSignedBlockRegardlessOfRelay(t *testing.T) {
|
|
ctx := context.Background()
|
|
vals := newTestValidators(t, 5)
|
|
const rng = "bucket-A"
|
|
owner := ownerVal(t, rng, vals)
|
|
|
|
// Proposer (owner) builds a signed allocate block.
|
|
proposer, _ := newTestVM(t)
|
|
withValidatorSet(t, proposer, vals, owner)
|
|
tx := txs.NewAllocateTx(rng, 4)
|
|
if err := proposer.SubmitTx(tx.Bytes()); err != nil {
|
|
t.Fatalf("SubmitTx: %v", err)
|
|
}
|
|
blk, err := proposer.BuildBlock(ctx)
|
|
if err != nil {
|
|
t.Fatalf("owner BuildBlock: %v", err)
|
|
}
|
|
blkBytes := blk.Bytes()
|
|
|
|
// Verifier reconstructs the same set but names a DIFFERENT proposer in its
|
|
// context. It must still ACCEPT — the owner's signature carries the authority.
|
|
verifier, _ := newTestVM(t)
|
|
withValidatorSet(t, verifier, vals, nonOwnerVal(t, rng, vals))
|
|
parsed, err := verifier.ParseBlock(ctx, blkBytes)
|
|
if err != nil {
|
|
t.Fatalf("verifier ParseBlock: %v", err)
|
|
}
|
|
if err := parsed.Verify(ctx); err != nil {
|
|
t.Fatalf("verifier rejected a valid owner-signed block: %v", err)
|
|
}
|
|
if err := parsed.Accept(ctx); err != nil {
|
|
t.Fatalf("verifier Accept: %v", err)
|
|
}
|
|
if n, _ := verifier.inner.state.GetAlloc(rng); n.Next != 4 {
|
|
t.Fatalf("verifier counter = %d, want 4", n)
|
|
}
|
|
}
|
|
|
|
// TestVerifierRejectsNonOwnerSignedBlock is the verifier-side safety proof: a block
|
|
// whose allocate is signed by a NON-owner (with a valid key) is rejected by an
|
|
// honest verifier that reconstructs the same frozen set — the property peers rely
|
|
// on, now enforced by the signature rather than a proposer claim.
|
|
func TestVerifierRejectsNonOwnerSignedBlock(t *testing.T) {
|
|
vals := newTestValidators(t, 5)
|
|
const rng = "bucket-A"
|
|
owner := ownerVal(t, rng, vals)
|
|
attacker := nonOwnerVal(t, rng, vals)
|
|
|
|
// A malicious proposer (the attacker) builds a block whose allocate is signed by
|
|
// its own valid key. Build it on the attacker's own VM (its signer stamps the
|
|
// attacker's NodeID) — the build fails on the attacker too, so we craft the block
|
|
// bytes directly via a pre-signed tx and a permissive owner builder to assemble
|
|
// the wire image, then verify it on an honest node.
|
|
const epoch = 1
|
|
fp := pinning.EpochFingerprint(epoch, membersOf(vals))
|
|
sig := signAllocateAs(t, attacker, rng, 4, epoch, epoch, fp)
|
|
adversarial := txs.NewAllocateTx(rng, 4).WithAuthorization(
|
|
epoch, epoch, fp, attacker.nodeID, uint8(ids.NodeIDSchemeMLDSA65), attacker.pub.Bytes(), sig,
|
|
)
|
|
|
|
verifier, _ := newTestVM(t)
|
|
withValidatorSet(t, verifier, vals, owner)
|
|
err := buildPreSigned(t, verifier, adversarial)
|
|
if !errors.Is(err, errNonOwnerAllocate) {
|
|
t.Fatalf("verifier accepted non-owner-signed block: err = %v, want errNonOwnerAllocate", err)
|
|
}
|
|
if n, _ := verifier.inner.state.GetAlloc(rng); n.Next != 0 {
|
|
t.Fatalf("rejected block leaked allocation: counter = %d, want 0", n)
|
|
}
|
|
}
|
|
|
|
// TestParallelDistinctRangesIndependentOwners proves disjoint ranges allocate
|
|
// independently through their RESPECTIVE owners: each owner's signed AllocateTx for
|
|
// its own range commits while neither touches the other's counter. This is the win
|
|
// over raft — no single leader serializes A and B.
|
|
func TestParallelDistinctRangesIndependentOwners(t *testing.T) {
|
|
vals := newTestValidators(t, 5)
|
|
mem := membersOf(vals)
|
|
|
|
// Find two ranges owned by different validators.
|
|
rngA, rngB := "", ""
|
|
ownerA, ownerB := ids.NodeID{}, ids.NodeID{}
|
|
for i := 0; i < 200 && (rngA == "" || rngB == ""); i++ {
|
|
r := "range-" + string(rune('a'+i%26)) + string(rune('0'+i/26))
|
|
o := ownerOf(t, r, mem)
|
|
if rngA == "" {
|
|
rngA, ownerA = r, o
|
|
continue
|
|
}
|
|
if o != ownerA {
|
|
rngB, ownerB = r, o
|
|
}
|
|
}
|
|
if rngB == "" {
|
|
t.Fatal("could not find two ranges with distinct owners")
|
|
}
|
|
|
|
cvmA, _ := newTestVM(t)
|
|
withValidatorSet(t, cvmA, vals, valByID(t, vals, ownerA))
|
|
baseA, nextA, err := allocate(t, cvmA, rngA, 3)
|
|
if err != nil {
|
|
t.Fatalf("owner A allocate in range A rejected: %v", err)
|
|
}
|
|
if baseA != 0 || nextA != 3 {
|
|
t.Fatalf("range A allocation = [%d,%d), want [0,3)", baseA, nextA)
|
|
}
|
|
// Owner A is not the owner of range B → it cannot even sign a valid allocate for
|
|
// B (its signer stamps A's NodeID, which is not B's owner).
|
|
if _, _, err := allocate(t, cvmA, rngB, 3); !errors.Is(err, errNonOwnerAllocate) {
|
|
t.Fatalf("owner A allocate in range B: err = %v, want errNonOwnerAllocate", err)
|
|
}
|
|
if n, _ := cvmA.inner.state.GetAlloc(rngB); n.Next != 0 {
|
|
t.Fatalf("range B counter moved on owner A's view = %d, want 0", n)
|
|
}
|
|
|
|
cvmB, _ := newTestVM(t)
|
|
withValidatorSet(t, cvmB, vals, valByID(t, vals, ownerB))
|
|
baseB, nextB, err := allocate(t, cvmB, rngB, 7)
|
|
if err != nil {
|
|
t.Fatalf("owner B allocate in range B rejected: %v", err)
|
|
}
|
|
if baseB != 0 || nextB != 7 {
|
|
t.Fatalf("range B allocation = [%d,%d), want [0,7)", baseB, nextB)
|
|
}
|
|
}
|
|
|
|
// TestSameRangeSerializesThroughOneOwner proves the same range serializes through
|
|
// its ONE owner: only the owner's signed AllocateTxs advance the counter,
|
|
// contiguously, and a non-owner's attempt is rejected — so even back-to-back
|
|
// allocations to the same range yield a single, non-overlapping id sequence.
|
|
func TestSameRangeSerializesThroughOneOwner(t *testing.T) {
|
|
cvm, _ := newTestVM(t)
|
|
vals := newTestValidators(t, 5)
|
|
const rng = "hot-bucket"
|
|
owner := ownerVal(t, rng, vals)
|
|
withValidatorSet(t, cvm, vals, owner)
|
|
|
|
if base, next, err := allocate(t, cvm, rng, 10); err != nil || base != 0 || next != 10 {
|
|
t.Fatalf("alloc 1 = [%d,%d) err=%v, want [0,10) nil", base, next, err)
|
|
}
|
|
if base, next, err := allocate(t, cvm, rng, 10); err != nil || base != 10 || next != 20 {
|
|
t.Fatalf("alloc 2 = [%d,%d) err=%v, want [10,20) nil", base, next, err)
|
|
}
|
|
|
|
// A non-owner cannot interleave a write into the same range.
|
|
withValidatorSet(t, cvm, vals, nonOwnerVal(t, rng, vals))
|
|
if _, _, err := allocate(t, cvm, rng, 5); !errors.Is(err, errNonOwnerAllocate) {
|
|
t.Fatalf("non-owner interleave: err = %v, want errNonOwnerAllocate", err)
|
|
}
|
|
if n, _ := cvm.inner.state.GetAlloc(rng); n.Next != 20 {
|
|
t.Fatalf("counter after rejected non-owner write = %d, want 20", n)
|
|
}
|
|
}
|
|
|
|
// TestStateRootCoversAllocThroughVerify proves the block state root covers the
|
|
// allocator end-to-end: an honestly-signed AllocateTx block verifies (its claimed
|
|
// root matches the recomputed root that now includes the advanced counter), and a
|
|
// block whose claimed root is tampered is rejected with errStateRootMismatch.
|
|
func TestStateRootCoversAllocThroughVerify(t *testing.T) {
|
|
ctx := context.Background()
|
|
vals := newTestValidators(t, 5)
|
|
const rng = "rooted-range"
|
|
owner := ownerVal(t, rng, vals)
|
|
|
|
good, _ := newTestVM(t)
|
|
withValidatorSet(t, good, vals, owner)
|
|
|
|
emptyRoot, err := good.inner.state.Root()
|
|
if err != nil {
|
|
t.Fatalf("empty Root: %v", err)
|
|
}
|
|
tx := txs.NewAllocateTx(rng, 4)
|
|
if err := good.SubmitTx(tx.Bytes()); err != nil {
|
|
t.Fatalf("SubmitTx: %v", err)
|
|
}
|
|
blk, err := good.BuildBlock(ctx)
|
|
if err != nil {
|
|
t.Fatalf("BuildBlock: %v", err)
|
|
}
|
|
if err := blk.Verify(ctx); err != nil {
|
|
t.Fatalf("honest allocate block rejected: %v", err)
|
|
}
|
|
if afterRoot, _ := good.inner.state.Root(); afterRoot == emptyRoot {
|
|
t.Fatal("allocate did not change the manifest+alloc state root")
|
|
}
|
|
if err := blk.Accept(ctx); err != nil {
|
|
t.Fatalf("Accept: %v", err)
|
|
}
|
|
|
|
// A block with a TAMPERED claimed root over the same allocate is rejected.
|
|
bad, _ := newTestVM(t)
|
|
withValidatorSet(t, bad, vals, owner)
|
|
tx2 := txs.NewAllocateTx(rng, 4)
|
|
if err := bad.SubmitTx(tx2.Bytes()); err != nil {
|
|
t.Fatalf("SubmitTx: %v", err)
|
|
}
|
|
badBlk, err := bad.BuildBlock(ctx)
|
|
if err != nil {
|
|
t.Fatalf("BuildBlock: %v", err)
|
|
}
|
|
concrete, ok := badBlk.(*Block)
|
|
if !ok {
|
|
t.Fatalf("block type = %T, want *Block", badBlk)
|
|
}
|
|
concrete.stateRoot = ids.ID{0xDE, 0xAD, 0xBE, 0xEF}
|
|
hash := sha256Bytes(concrete.Bytes())
|
|
copy(concrete.id[:], hash[:])
|
|
|
|
if err := concrete.Verify(ctx); !errors.Is(err, errStateRootMismatch) {
|
|
t.Fatalf("Verify error = %v, want errStateRootMismatch", err)
|
|
}
|
|
if n, _ := bad.inner.state.GetAlloc(rng); n.Next != 0 {
|
|
t.Fatalf("rejected (bad-root) block leaked an allocation: counter = %d, want 0", n)
|
|
}
|
|
}
|