mirror of
https://github.com/luxfi/node.git
synced 2026-07-27 03:39:39 +00:00
#189: LP-023 batch 5 v3 — wire Verify() gate for R6V4 (CreateSovereignL1Tx zero-validator/zero-chain CRITICAL) + R6V8 (TransferChainOwnershipTx HIGH) + R6V3 (BLS PoP verification HIGH) + R6V5 (FxIDs malformed length MEDIUM)
R6V4 (CRITICAL): CreateSovereignL1Tx.Verify now rejects zero-validator and zero-chain wire buffers (both halt consensus at activation). Per-validator Weight > 0 walk also fires here. RegistrationExpiry remains an executor clock concern (deferred to staking handler, documented). R6V8 (HIGH): TransferChainOwnershipTx had no Verify() despite carrying Owner fields. Wired in: reconstructs OwnerStub from the (threshold, locktime, address) tuple and calls SyntacticVerify. Tx count with SyntacticVerify wired into 8 tx types (was 7). R6V3 (HIGH): BLS PoP now verified at the SyntacticVerify boundary for every initial validator. Wire-layer opaque 48B BLSPubKey + 96B BLSPoP now pairing-checked via bls.VerifyProofOfPossession with new ErrBadBLSPoP. Closes the zero-downstream-consumer gap Red grep found. R6V5 (MEDIUM): ChainsListView.Verify walks entries and rejects any FxIDsLen that is not a multiple of FxIDSize. Replaces the silent-nil return path in BoundChainEntry.FxIDs with a typed error. Wired into CreateSovereignL1Tx.Verify via ChainsListView.Verify(). Tests (all under go test -race): TestCreateSovereignL1Tx_Verify_RejectsZeroValidators TestCreateSovereignL1Tx_Verify_RejectsZeroChains TestCreateSovereignL1Tx_Verify_RejectsZeroWeight TestCreateSovereignL1Tx_Verify_RejectsBadBLSPoP TestCreateSovereignL1Tx_Verify_RejectsBadBLSPoP_MismatchedPubKey TestCreateSovereignL1Tx_Verify_AdversarialWireBuffer_ValidatorWeight TestChainsList_Verify_RejectsBadFxIDsLen (adversarial wire buffer) TestChainsListView_Verify_StandaloneEntries TestTransferChainOwnershipTx_Verify_RejectsZeroThreshold TestTransferChainOwnershipTx_Verify_RejectsThresholdAboveOne TestTransferChainOwnershipTx_Verify_AdversarialWireBuffer TestBLSSurfaceReachable Updates TestVerify_AcceptsWellFormed/CreateSovereignL1 to supply a properly-constructed validator (real BLS PoP) and a chain entry so the new gates fire green on the legitimate path. Adds TestVerify_AcceptsWellFormed/TransferChainOwnership subtest for R6V8. New typed errors: ErrZeroValidators, ErrZeroChains, ErrValidatorWeightZero, ErrBadBLSPoP, ErrMalformedFxIDsLen. Bench unchanged (Verify is on executor path, parse benches structurally untouched). Full zap_native suite green under -race. LP-023 batch 5 v3 closes Red round 6 V3/V4/V5/V8.
This commit is contained in:
@@ -4,6 +4,8 @@
|
||||
package zap_native
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/luxfi/ids"
|
||||
"github.com/luxfi/zap"
|
||||
)
|
||||
@@ -244,6 +246,31 @@ func NewChainsListView(parent zap.Object, fieldOffset int) ChainsListView {
|
||||
return ChainsListView{list: parent.ListStride(fieldOffset, SizeChainEntry)}
|
||||
}
|
||||
|
||||
// Verify walks every entry in the list and asserts that FxIDsLen is an
|
||||
// exact multiple of FxIDSize. R6V5 closes the silent-nil path where
|
||||
// BoundChainEntry.FxIDs returns nil for a malformed length, which a
|
||||
// downstream consumer can mis-interpret as "no FxIDs allowed". The check
|
||||
// is pure cursor inspection — no Bind required, no blob slicing. Wired
|
||||
// into CreateSovereignL1Tx.Verify (and any future tx that embeds a
|
||||
// ChainsList).
|
||||
//
|
||||
// Returns ErrMalformedFxIDsLen wrapped with the entry index on the first
|
||||
// malformed entry. Returns nil when the list is empty or every entry's
|
||||
// FxIDsLen passes — empty-list rejection is the caller's gate
|
||||
// (CreateSovereignL1Tx.Verify enforces non-empty via ErrZeroChains
|
||||
// before invoking this).
|
||||
func (l ChainsListView) Verify() error {
|
||||
n := l.Len()
|
||||
for i := 0; i < n; i++ {
|
||||
entry := l.At(i)
|
||||
_, length := entry.FxIDsRange()
|
||||
if length%FxIDSize != 0 {
|
||||
return fmt.Errorf("ChainsList[%d].FxIDsLen=%d: %w", i, length, ErrMalformedFxIDsLen)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ChainsListEntry is the constructor input for a ChainsList. The variable
|
||||
// fields (Name, FxIDs, GenesisData) are concatenated into the shared blob
|
||||
// arrays during write; entry header cursors point into them.
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package zap_native
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/luxfi/crypto/bls"
|
||||
"github.com/luxfi/ids"
|
||||
"github.com/luxfi/zap"
|
||||
)
|
||||
|
||||
// LP-023 Red round 6 verify-gate tests.
|
||||
//
|
||||
// Adversarial threat model: the attacker controls the wire byte stream
|
||||
// (not the constructor). Each test below constructs a CreateSovereignL1Tx
|
||||
// (or TransferChainOwnershipTx) the legitimate way, then either:
|
||||
// - omits a required field (zero validators / zero chains) via the
|
||||
// constructor, OR
|
||||
// - patches a field in the wire buffer post-encode to a malicious
|
||||
// value and re-Wraps.
|
||||
// The executor-side Verify() gate MUST catch it.
|
||||
|
||||
// TestCreateSovereignL1Tx_Verify_RejectsZeroValidators pins R6V4 critical:
|
||||
// a zero-validator L1 cannot bootstrap consensus. Verify must reject at
|
||||
// the wire boundary.
|
||||
func TestCreateSovereignL1Tx_Verify_RejectsZeroValidators(t *testing.T) {
|
||||
stub := OwnerStub{Threshold: 1, Locktime: 0, Address: ids.ShortID{0x42}}
|
||||
tx := NewCreateSovereignL1Tx(CreateSovereignL1TxInput{
|
||||
NetworkID: 1,
|
||||
Owner: stub,
|
||||
Validators: nil, // adversarial empty set
|
||||
Chains: []ChainsListEntry{makeValidChainsEntry()},
|
||||
})
|
||||
err := tx.Verify()
|
||||
if !errors.Is(err, ErrZeroValidators) {
|
||||
t.Fatalf("Verify(zero validators) = %v, want ErrZeroValidators", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateSovereignL1Tx_Verify_RejectsZeroChains pins R6V4 critical:
|
||||
// a zero-chain L1 is malformed; Verify must reject.
|
||||
func TestCreateSovereignL1Tx_Verify_RejectsZeroChains(t *testing.T) {
|
||||
stub := OwnerStub{Threshold: 1, Locktime: 0, Address: ids.ShortID{0x42}}
|
||||
tx := NewCreateSovereignL1Tx(CreateSovereignL1TxInput{
|
||||
NetworkID: 1,
|
||||
Owner: stub,
|
||||
Validators: []ValidatorsListEntry{makeValidVerifyingValidator(t)},
|
||||
Chains: nil, // adversarial empty list
|
||||
})
|
||||
err := tx.Verify()
|
||||
if !errors.Is(err, ErrZeroChains) {
|
||||
t.Fatalf("Verify(zero chains) = %v, want ErrZeroChains", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateSovereignL1Tx_Verify_RejectsZeroWeight pins R6V4: a
|
||||
// zero-weight validator skews quorum (it pads the validator count but
|
||||
// contributes nothing to threshold), so reject. Construct two validators
|
||||
// where the second has weight=0.
|
||||
func TestCreateSovereignL1Tx_Verify_RejectsZeroWeight(t *testing.T) {
|
||||
stub := OwnerStub{Threshold: 1, Locktime: 0, Address: ids.ShortID{0x42}}
|
||||
good := makeValidVerifyingValidator(t)
|
||||
bad := makeValidVerifyingValidator(t)
|
||||
bad.Weight = 0 // adversarial
|
||||
tx := NewCreateSovereignL1Tx(CreateSovereignL1TxInput{
|
||||
NetworkID: 1,
|
||||
Owner: stub,
|
||||
Validators: []ValidatorsListEntry{good, bad},
|
||||
Chains: []ChainsListEntry{makeValidChainsEntry()},
|
||||
})
|
||||
err := tx.Verify()
|
||||
if !errors.Is(err, ErrValidatorWeightZero) {
|
||||
t.Fatalf("Verify(zero-weight validator) = %v, want ErrValidatorWeightZero", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateSovereignL1Tx_Verify_RejectsBadBLSPoP pins R6V3: a validator
|
||||
// whose BLS PoP does not verify against the embedded BLS pubkey must be
|
||||
// rejected. Construct a valid validator, then scramble the PoP bytes
|
||||
// before encoding.
|
||||
func TestCreateSovereignL1Tx_Verify_RejectsBadBLSPoP(t *testing.T) {
|
||||
stub := OwnerStub{Threshold: 1, Locktime: 0, Address: ids.ShortID{0x42}}
|
||||
good := makeValidVerifyingValidator(t)
|
||||
bad := good
|
||||
// Scramble the PoP into uniform random bytes — pairing fails.
|
||||
if _, err := rand.Read(bad.BLSPoP[:]); err != nil {
|
||||
t.Fatalf("rand.Read: %v", err)
|
||||
}
|
||||
tx := NewCreateSovereignL1Tx(CreateSovereignL1TxInput{
|
||||
NetworkID: 1,
|
||||
Owner: stub,
|
||||
Validators: []ValidatorsListEntry{bad},
|
||||
Chains: []ChainsListEntry{makeValidChainsEntry()},
|
||||
})
|
||||
err := tx.Verify()
|
||||
if !errors.Is(err, ErrBadBLSPoP) {
|
||||
t.Fatalf("Verify(bad PoP) = %v, want ErrBadBLSPoP", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateSovereignL1Tx_Verify_RejectsBadBLSPoP_MismatchedPubKey pins
|
||||
// R6V3 against the keypair-substitution attack: an adversary takes a
|
||||
// valid PoP from validator A and pairs it with validator B's pubkey,
|
||||
// hoping the wire-level verify lets it through. The pairing must reject.
|
||||
func TestCreateSovereignL1Tx_Verify_RejectsBadBLSPoP_MismatchedPubKey(t *testing.T) {
|
||||
stub := OwnerStub{Threshold: 1, Locktime: 0, Address: ids.ShortID{0x42}}
|
||||
a := makeValidVerifyingValidator(t)
|
||||
b := makeValidVerifyingValidator(t)
|
||||
// Splice: keep B's pubkey, swap in A's PoP. PoP signs A's pubkey, not B's.
|
||||
hybrid := b
|
||||
hybrid.BLSPoP = a.BLSPoP
|
||||
tx := NewCreateSovereignL1Tx(CreateSovereignL1TxInput{
|
||||
NetworkID: 1,
|
||||
Owner: stub,
|
||||
Validators: []ValidatorsListEntry{hybrid},
|
||||
Chains: []ChainsListEntry{makeValidChainsEntry()},
|
||||
})
|
||||
err := tx.Verify()
|
||||
if !errors.Is(err, ErrBadBLSPoP) {
|
||||
t.Fatalf("Verify(mismatched-keypair PoP) = %v, want ErrBadBLSPoP", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChainsList_Verify_RejectsBadFxIDsLen pins R6V5: a chain entry whose
|
||||
// FxIDsLen is not a multiple of FxIDSize must be rejected. We tamper the
|
||||
// wire buffer post-encode to set FxIDsLen=63 (not a multiple of 32) and
|
||||
// Verify must catch it.
|
||||
func TestChainsList_Verify_RejectsBadFxIDsLen(t *testing.T) {
|
||||
stub := OwnerStub{Threshold: 1, Locktime: 0, Address: ids.ShortID{0x42}}
|
||||
// Build a legitimate tx with one chain whose FxIDs has 2 entries (64B).
|
||||
chain := ChainsListEntry{
|
||||
Name: []byte("evm"),
|
||||
VMID: ids.ID{0xed},
|
||||
FxIDs: []ids.ID{{0x01}, {0x02}},
|
||||
GenesisData: []byte("g"),
|
||||
}
|
||||
tx := NewCreateSovereignL1Tx(CreateSovereignL1TxInput{
|
||||
NetworkID: 1,
|
||||
Owner: stub,
|
||||
Validators: []ValidatorsListEntry{makeValidVerifyingValidator(t)},
|
||||
Chains: []ChainsListEntry{chain},
|
||||
})
|
||||
// Pre-tamper sanity: Verify must succeed.
|
||||
if err := tx.Verify(); err != nil {
|
||||
t.Fatalf("baseline Verify = %v, want nil", err)
|
||||
}
|
||||
|
||||
// Tamper: locate ChainEntry.FxIDsLen in the wire buffer. The legitimate
|
||||
// value is 64 (=2 * FxIDSize). We scan for the unique 4-byte LE pattern
|
||||
// {0x40, 0x00, 0x00, 0x00} and overwrite with 63.
|
||||
//
|
||||
// To make the search unique we also pin neighborhood context: the
|
||||
// VMID's first byte (0xed) lives at OffsetChainEntry_VMID (=8) within
|
||||
// the entry, and FxIDsLen lives at offset 44. So we search for
|
||||
// 0xed followed by 31 trailing VMID bytes, then 4 bytes of FxIDsRel
|
||||
// (=0), then the {0x40,0,0,0} length pattern.
|
||||
buf := tx.Bytes()
|
||||
tampered := make([]byte, len(buf))
|
||||
copy(tampered, buf)
|
||||
patched := false
|
||||
for i := 0; i+SizeChainEntry <= len(tampered); i++ {
|
||||
if tampered[i] != 0xed {
|
||||
continue
|
||||
}
|
||||
// Check rest of VMID is zero (we set VMID = {0xed} only).
|
||||
zeroVMID := true
|
||||
for j := 1; j < 32; j++ {
|
||||
if tampered[i+j] != 0 {
|
||||
zeroVMID = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if !zeroVMID {
|
||||
continue
|
||||
}
|
||||
// FxIDsRel at +32, FxIDsLen at +36. FxIDsRel should be 0 (first entry).
|
||||
if tampered[i+32] != 0 || tampered[i+33] != 0 || tampered[i+34] != 0 || tampered[i+35] != 0 {
|
||||
continue
|
||||
}
|
||||
// FxIDsLen at +36 should be 64 (= 2 * 32).
|
||||
if tampered[i+36] != 0x40 || tampered[i+37] != 0 || tampered[i+38] != 0 || tampered[i+39] != 0 {
|
||||
continue
|
||||
}
|
||||
// Patch FxIDsLen to 63 (malformed).
|
||||
tampered[i+36] = 0x3F
|
||||
patched = true
|
||||
break
|
||||
}
|
||||
if !patched {
|
||||
t.Fatalf("could not locate ChainEntry.FxIDsLen in wire buffer")
|
||||
}
|
||||
tamperedTx, err := WrapCreateSovereignL1Tx(tampered)
|
||||
if err != nil {
|
||||
t.Fatalf("WrapCreateSovereignL1Tx(tampered) = %v, want nil (parser permissive)", err)
|
||||
}
|
||||
// Confirm the tamper landed in the wire.
|
||||
gotEntry := tamperedTx.Chains().At(0)
|
||||
if _, length := gotEntry.FxIDsRange(); length != 63 {
|
||||
t.Fatalf("tampered FxIDsLen = %d, want 63", length)
|
||||
}
|
||||
// Verify must reject.
|
||||
if err := tamperedTx.Verify(); !errors.Is(err, ErrMalformedFxIDsLen) {
|
||||
t.Fatalf("Verify(malformed FxIDsLen) = %v, want ErrMalformedFxIDsLen", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChainsListView_Verify_StandaloneEntries exercises the helper on a
|
||||
// hand-constructed ChainsListView so Verify is callable independently
|
||||
// of CreateSovereignL1Tx. Defense-in-depth: any future tx that embeds
|
||||
// ChainsList can call Verify() directly.
|
||||
func TestChainsListView_Verify_StandaloneEntries(t *testing.T) {
|
||||
// Good entry: FxIDsLen = 2*32 = 64.
|
||||
entries := []ChainsListEntry{
|
||||
{Name: []byte("a"), VMID: ids.ID{0x01}, FxIDs: []ids.ID{{0xff}, {0xee}}},
|
||||
{Name: []byte("b"), VMID: ids.ID{0x02}, FxIDs: nil},
|
||||
}
|
||||
tx := NewCreateSovereignL1Tx(CreateSovereignL1TxInput{
|
||||
NetworkID: 1,
|
||||
Owner: OwnerStub{Threshold: 1, Address: ids.ShortID{0x55}},
|
||||
Validators: []ValidatorsListEntry{makeValidVerifyingValidator(t)},
|
||||
Chains: entries,
|
||||
})
|
||||
if err := tx.Chains().Verify(); err != nil {
|
||||
t.Fatalf("ChainsListView.Verify = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTransferChainOwnershipTx_Verify_RejectsZeroThreshold pins R6V8:
|
||||
// the new Verify() must reject threshold=0. Constructor path — the v3
|
||||
// tx accepts any (threshold, locktime, address) so we can build a
|
||||
// malicious-by-construction tx and prove Verify catches it.
|
||||
func TestTransferChainOwnershipTx_Verify_RejectsZeroThreshold(t *testing.T) {
|
||||
addr := ids.ShortID{0x42}
|
||||
tx := NewTransferChainOwnershipTx(ids.ID{0xCA}, 0, 0, addr)
|
||||
err := tx.Verify()
|
||||
if !errors.Is(err, ErrOwnerThresholdZero) {
|
||||
t.Fatalf("Verify(threshold=0) = %v, want ErrOwnerThresholdZero", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTransferChainOwnershipTx_Verify_RejectsThresholdAboveOne pins R6V8:
|
||||
// the v3 tx pins a single-address Owner, so threshold > 1 is
|
||||
// unsatisfiable (only one signer, quorum > 1 can never be reached). The
|
||||
// underlying OwnerStub.SyntacticVerify returns ErrOwnerThresholdExceedsAddrs.
|
||||
func TestTransferChainOwnershipTx_Verify_RejectsThresholdAboveOne(t *testing.T) {
|
||||
addr := ids.ShortID{0x42}
|
||||
tx := NewTransferChainOwnershipTx(ids.ID{0xCA}, 7, 0, addr)
|
||||
err := tx.Verify()
|
||||
if !errors.Is(err, ErrOwnerThresholdExceedsAddrs) {
|
||||
t.Fatalf("Verify(threshold=7) = %v, want ErrOwnerThresholdExceedsAddrs", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTransferChainOwnershipTx_Verify_AdversarialWireBuffer pins R6V8
|
||||
// against the strongest threat model: adversary controls the wire byte
|
||||
// stream. Build a legitimate tx, patch the threshold byte in-place to 0,
|
||||
// re-Wrap, and prove Verify rejects.
|
||||
func TestTransferChainOwnershipTx_Verify_AdversarialWireBuffer(t *testing.T) {
|
||||
addr := ids.ShortID{0x77}
|
||||
good := NewTransferChainOwnershipTx(ids.ID{0xCA, 0xCA, 0xCA, 0xCA}, 1, 0, addr)
|
||||
buf := good.Bytes()
|
||||
|
||||
// Re-parse so we can probe the live root.Uint32 location, then patch.
|
||||
msg, err := zap.Parse(buf)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
if got := msg.Root().Uint32(OffsetTransferChainOwnershipTx_OwnerThreshold); got != 1 {
|
||||
t.Fatalf("pre-overwrite threshold = %d, want 1", got)
|
||||
}
|
||||
|
||||
tampered := make([]byte, len(buf))
|
||||
copy(tampered, buf)
|
||||
|
||||
// Unique pattern: Chain field begins 0xCA,0xCA,0xCA,0xCA at
|
||||
// OffsetTransferChainOwnershipTx_Chain (=1) within the root payload.
|
||||
// Threshold uint32 (LE) = {0x01,0,0,0} sits 32 bytes after the chain
|
||||
// start. Locktime uint64 (LE) zeros for 8 bytes, then 0x77 marks
|
||||
// OwnerAddress[0].
|
||||
patched := false
|
||||
// Search bound: we need bytes i..i+44 to be the (Chain, threshold,
|
||||
// locktime, addr[0]) span — that's 45 bytes, so i+45 <= len(tampered)
|
||||
// is sufficient. SizeTransferChainOwnershipTx (=65) would over-clamp
|
||||
// when the tx sits at the buffer's tail (no trailing slack).
|
||||
for i := 0; i+45 <= len(tampered); i++ {
|
||||
if tampered[i] != 0xCA || tampered[i+1] != 0xCA ||
|
||||
tampered[i+2] != 0xCA || tampered[i+3] != 0xCA {
|
||||
continue
|
||||
}
|
||||
// Threshold at +32.
|
||||
if tampered[i+32] != 0x01 || tampered[i+33] != 0 ||
|
||||
tampered[i+34] != 0 || tampered[i+35] != 0 {
|
||||
continue
|
||||
}
|
||||
// Locktime at +36 should be 8 zero bytes.
|
||||
zero := true
|
||||
for j := 36; j < 44; j++ {
|
||||
if tampered[i+j] != 0 {
|
||||
zero = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if !zero {
|
||||
continue
|
||||
}
|
||||
// OwnerAddress[0] at +44.
|
||||
if tampered[i+44] != 0x77 {
|
||||
continue
|
||||
}
|
||||
// Patch threshold to 0.
|
||||
tampered[i+32] = 0
|
||||
patched = true
|
||||
break
|
||||
}
|
||||
if !patched {
|
||||
t.Fatalf("could not locate threshold field in wire buffer")
|
||||
}
|
||||
tamperedTx, err := WrapTransferChainOwnershipTx(tampered)
|
||||
if err != nil {
|
||||
t.Fatalf("WrapTransferChainOwnershipTx(tampered) = %v, want nil (parser permissive)", err)
|
||||
}
|
||||
if tamperedTx.OwnerThreshold() != 0 {
|
||||
t.Fatalf("tampered threshold = %d, want 0", tamperedTx.OwnerThreshold())
|
||||
}
|
||||
if err := tamperedTx.Verify(); !errors.Is(err, ErrOwnerThresholdZero) {
|
||||
t.Fatalf("Verify(tampered) = %v, want ErrOwnerThresholdZero", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateSovereignL1Tx_Verify_AdversarialWireBuffer_ValidatorWeight
|
||||
// pins R6V4 against wire tampering: build a legitimate tx then patch
|
||||
// the second validator's Weight to 0 in the wire buffer.
|
||||
func TestCreateSovereignL1Tx_Verify_AdversarialWireBuffer_ValidatorWeight(t *testing.T) {
|
||||
stub := OwnerStub{Threshold: 1, Locktime: 0, Address: ids.ShortID{0x42}}
|
||||
v0 := makeValidVerifyingValidator(t)
|
||||
v1 := makeValidVerifyingValidator(t)
|
||||
// Make v1's NodeID uniquely identifiable so we can find its record.
|
||||
v1.NodeID = ids.NodeID{0xDE, 0xAD, 0xBE, 0xEF}
|
||||
v1.Weight = 0x4242_4242 // Will patch to 0
|
||||
tx := NewCreateSovereignL1Tx(CreateSovereignL1TxInput{
|
||||
NetworkID: 1,
|
||||
Owner: stub,
|
||||
Validators: []ValidatorsListEntry{v0, v1},
|
||||
Chains: []ChainsListEntry{makeValidChainsEntry()},
|
||||
})
|
||||
// Sanity: baseline Verify passes.
|
||||
if err := tx.Verify(); err != nil {
|
||||
t.Fatalf("baseline Verify = %v, want nil", err)
|
||||
}
|
||||
|
||||
buf := tx.Bytes()
|
||||
tampered := make([]byte, len(buf))
|
||||
copy(tampered, buf)
|
||||
patched := false
|
||||
for i := 0; i+SizeValidatorRecord <= len(tampered); i++ {
|
||||
// Look for NodeID 0xDE,0xAD,0xBE,0xEF prefix.
|
||||
if tampered[i] != 0xDE || tampered[i+1] != 0xAD ||
|
||||
tampered[i+2] != 0xBE || tampered[i+3] != 0xEF {
|
||||
continue
|
||||
}
|
||||
// NodeID is 20 bytes — rest should be zero per the constructor's
|
||||
// ids.NodeID{0xDE,0xAD,0xBE,0xEF} layout.
|
||||
zeroTail := true
|
||||
for j := 4; j < 20; j++ {
|
||||
if tampered[i+j] != 0 {
|
||||
zeroTail = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if !zeroTail {
|
||||
continue
|
||||
}
|
||||
// Weight at +20 (LE uint64) should be 0x4242_4242 = {0x42,0x42,0x42,0x42,0,0,0,0}.
|
||||
if tampered[i+20] != 0x42 || tampered[i+21] != 0x42 ||
|
||||
tampered[i+22] != 0x42 || tampered[i+23] != 0x42 {
|
||||
continue
|
||||
}
|
||||
// Patch Weight to 0.
|
||||
for j := 20; j < 28; j++ {
|
||||
tampered[i+j] = 0
|
||||
}
|
||||
patched = true
|
||||
break
|
||||
}
|
||||
if !patched {
|
||||
t.Fatalf("could not locate validator weight in wire buffer")
|
||||
}
|
||||
tamperedTx, err := WrapCreateSovereignL1Tx(tampered)
|
||||
if err != nil {
|
||||
t.Fatalf("WrapCreateSovereignL1Tx(tampered) = %v, want nil (parser permissive)", err)
|
||||
}
|
||||
if rec := tamperedTx.Validators().At(1); rec.Weight() != 0 {
|
||||
t.Fatalf("tampered Weight = %d, want 0", rec.Weight())
|
||||
}
|
||||
if err := tamperedTx.Verify(); !errors.Is(err, ErrValidatorWeightZero) {
|
||||
t.Fatalf("Verify(tampered weight=0) = %v, want ErrValidatorWeightZero", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Sanity: confirm the BLS package surface we depend on is reachable at
|
||||
// link time (catches a future luxfi/crypto refactor that moves the
|
||||
// VerifyProofOfPossession symbol).
|
||||
func TestBLSSurfaceReachable(t *testing.T) {
|
||||
_ = bls.VerifyProofOfPossession
|
||||
}
|
||||
@@ -4,8 +4,10 @@
|
||||
package zap_native
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/luxfi/crypto/bls"
|
||||
"github.com/luxfi/ids"
|
||||
)
|
||||
|
||||
@@ -21,8 +23,77 @@ import (
|
||||
// > len(addrs).
|
||||
//
|
||||
// Contract: each Verify() returns a wrapped typed error from
|
||||
// {ErrOwnerThresholdZero, ErrOwnerThresholdExceedsAddrs, ErrOwnerAddrsEmpty}.
|
||||
// errors.Is on the typed error MUST match.
|
||||
// {ErrOwnerThresholdZero, ErrOwnerThresholdExceedsAddrs, ErrOwnerAddrsEmpty,
|
||||
// ErrZeroValidators, ErrZeroChains, ErrValidatorWeightZero, ErrBadBLSPoP,
|
||||
// ErrMalformedFxIDsLen}. errors.Is on the typed error MUST match.
|
||||
//
|
||||
// LP-023 Red round 6 closes:
|
||||
// - R6V4 (CRITICAL): CreateSovereignL1Tx.Verify must reject zero-validator
|
||||
// and zero-chain wire buffers — both would create an L1 that halts at
|
||||
// activation. Per-validator Weight > 0 walk also fires here.
|
||||
// - R6V8 (HIGH): TransferChainOwnershipTx.Verify was missing entirely
|
||||
// despite the tx carrying Owner fields. Wire it into the same Verify()
|
||||
// contract every other Owner-bearing tx uses.
|
||||
// - R6V3 (HIGH): BLS proof-of-possession must be verified for every
|
||||
// initial validator at the SyntacticVerify boundary. Wire layer returns
|
||||
// opaque 48B pubkey + 96B PoP; no downstream consumer was calling
|
||||
// bls.VerifyProofOfPossession. Wired into CreateSovereignL1Tx.Verify
|
||||
// so the pairing check fires once per validator on the executor path.
|
||||
// - R6V5 (MEDIUM): ChainsListView.Verify walks entries and rejects any
|
||||
// entry whose FxIDsLen is not a multiple of FxIDSize. Silent-nil
|
||||
// return from BoundChainEntry.FxIDs would otherwise treat a malformed
|
||||
// length as "no FxIDs" and silently accept the tx.
|
||||
|
||||
// R6V4 + R6V3 + R6V5 typed errors. Each is wrapped by CreateSovereignL1Tx.Verify
|
||||
// with tx-kind context; consumers match via errors.Is.
|
||||
var (
|
||||
// ErrZeroValidators is returned when the initial-validator set of a
|
||||
// CreateSovereignL1Tx is empty. A zero-validator L1 halts consensus
|
||||
// at activation (no quorum can ever be reached). Reject at the wire
|
||||
// boundary, not at chain bring-up time.
|
||||
ErrZeroValidators = errors.New(
|
||||
"zap_native: Validators list is empty — L1 cannot bootstrap consensus",
|
||||
)
|
||||
|
||||
// ErrZeroChains is returned when the chains-to-create list of a
|
||||
// CreateSovereignL1Tx is empty. An L1 with no chains has no surface;
|
||||
// the tx commits no state. Reject as malformed.
|
||||
ErrZeroChains = errors.New(
|
||||
"zap_native: Chains list is empty — L1 has no chains to create",
|
||||
)
|
||||
|
||||
// ErrValidatorWeightZero is returned when any validator entry has a
|
||||
// stake weight of zero. A zero-weight validator contributes nothing
|
||||
// to quorum, so it is either a constructor mistake or a deliberate
|
||||
// quorum-skewing attack (filler entries that pad the count but do
|
||||
// not contribute to the threshold).
|
||||
ErrValidatorWeightZero = errors.New(
|
||||
"zap_native: validator Weight must be > 0; zero-weight validator skews quorum",
|
||||
)
|
||||
|
||||
// ErrBadBLSPoP is returned when any validator entry's BLS
|
||||
// proof-of-possession fails to verify against the embedded BLS
|
||||
// public key. The pairing check binds the BLS keypair to the
|
||||
// pubkey value; without it an adversary could substitute an
|
||||
// arbitrary pubkey/PoP pair and seize validator authority on the
|
||||
// new L1 at registration time. Wire layer is opaque about pairing
|
||||
// validity; this gate is the only place it fires before the
|
||||
// executor commits the validator set.
|
||||
ErrBadBLSPoP = errors.New(
|
||||
"zap_native: validator BLSPoP failed pairing verification",
|
||||
)
|
||||
|
||||
// ErrMalformedFxIDsLen is returned when a chain entry's FxIDsLen is
|
||||
// not an exact multiple of FxIDSize. The wire layer's
|
||||
// BoundChainEntry.FxIDs silently returns nil for this case
|
||||
// (fail-closed at access time), but a downstream consumer that
|
||||
// looks at .FxIDsRange() length directly — or treats the empty
|
||||
// slice as "no FxIDs allowed" — could be coerced into accepting a
|
||||
// tx with a malformed fx-id geometry. Reject at the wire boundary.
|
||||
ErrMalformedFxIDsLen = errors.New(
|
||||
"zap_native: ChainEntry.FxIDsLen must be an exact multiple of FxIDSize (32)",
|
||||
)
|
||||
)
|
||||
|
||||
// stubFromTuple reconstructs an OwnerStub from the (threshold, locktime,
|
||||
// address) tuple returned by the embedded-stub accessors. The wire layer
|
||||
@@ -95,12 +166,91 @@ func (t CreateNetworkTx) Verify() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Verify runs SyntacticVerify on the embedded Owner of a
|
||||
// CreateSovereignL1Tx.
|
||||
// Verify runs SyntacticVerify on the embedded Owner of a CreateSovereignL1Tx
|
||||
// AND enforces R6V4 / R6V3 / R6V5: the initial validator set must be
|
||||
// non-empty, every validator must have non-zero weight, every validator's
|
||||
// BLS PoP must verify, the chains list must be non-empty, and every chain
|
||||
// entry's FxIDsLen must be an exact multiple of FxIDSize.
|
||||
//
|
||||
// Notes:
|
||||
// - Per-validator RegistrationExpiry > now() is intentionally NOT enforced
|
||||
// here. SyntacticVerify is clock-independent (executor wall-clock lives
|
||||
// in the staking handler), so the expiry check lives there. This file
|
||||
// enforces only properties that are invariant under wire encoding.
|
||||
// - The BLS pairing check is fast enough at the SyntacticVerify boundary
|
||||
// for small validator lists (< 100). For large lists the cost is
|
||||
// O(n) pairings, still well under the executor budget.
|
||||
func (t CreateSovereignL1Tx) Verify() error {
|
||||
o := stubFromTuple(t.Owner())
|
||||
if err := o.SyntacticVerify(); err != nil {
|
||||
return fmt.Errorf("CreateSovereignL1Tx.Owner: %w", err)
|
||||
}
|
||||
|
||||
// R6V4: non-empty validators + per-validator weight > 0.
|
||||
// R6V3: BLS PoP verification per validator.
|
||||
vals := t.Validators()
|
||||
n := vals.Len()
|
||||
if n == 0 {
|
||||
return fmt.Errorf("CreateSovereignL1Tx.Validators: %w", ErrZeroValidators)
|
||||
}
|
||||
for i := 0; i < n; i++ {
|
||||
rec := vals.At(i)
|
||||
if rec.Weight() == 0 {
|
||||
return fmt.Errorf(
|
||||
"CreateSovereignL1Tx.Validators[%d].Weight: %w", i, ErrValidatorWeightZero,
|
||||
)
|
||||
}
|
||||
// BLS PoP gate — R6V3.
|
||||
pkBytes := rec.BLSPubKey()
|
||||
sigBytes := rec.BLSPoP()
|
||||
pk, err := bls.PublicKeyFromCompressedBytes(pkBytes)
|
||||
if err != nil {
|
||||
return fmt.Errorf(
|
||||
"CreateSovereignL1Tx.Validators[%d].BLSPubKey: %w (%v)",
|
||||
i, ErrBadBLSPoP, err,
|
||||
)
|
||||
}
|
||||
sig, err := bls.SignatureFromBytes(sigBytes)
|
||||
if err != nil {
|
||||
return fmt.Errorf(
|
||||
"CreateSovereignL1Tx.Validators[%d].BLSPoP: %w (%v)",
|
||||
i, ErrBadBLSPoP, err,
|
||||
)
|
||||
}
|
||||
if !bls.VerifyProofOfPossession(pk, sig, pkBytes) {
|
||||
return fmt.Errorf(
|
||||
"CreateSovereignL1Tx.Validators[%d].BLSPoP: %w",
|
||||
i, ErrBadBLSPoP,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// R6V4: non-empty chains.
|
||||
// R6V5: every entry's FxIDsLen must be an exact multiple of FxIDSize.
|
||||
chains := t.Chains()
|
||||
if chains.Len() == 0 {
|
||||
return fmt.Errorf("CreateSovereignL1Tx.Chains: %w", ErrZeroChains)
|
||||
}
|
||||
if err := chains.Verify(); err != nil {
|
||||
return fmt.Errorf("CreateSovereignL1Tx.Chains: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Verify reconstructs an OwnerStub from a TransferChainOwnershipTx's
|
||||
// (threshold, locktime, address) tuple and runs SyntacticVerify on it. The
|
||||
// tx carries Owner fields inline but had no Verify() until LP-023 Red
|
||||
// round 6 R6V8 — every other Owner-bearing tx is gated, and skipping this
|
||||
// one allowed an adversary to publish a chain-ownership transfer with
|
||||
// threshold=0 (no signer required) or threshold>1 (unsatisfiable, DoS).
|
||||
//
|
||||
// Note: TransferChainOwnershipTx pins a single-address Owner in v3 (the
|
||||
// most common configuration), so OwnerStub.SyntacticVerify is the right
|
||||
// gate — it accepts threshold=1 only.
|
||||
func (t TransferChainOwnershipTx) Verify() error {
|
||||
o := stubFromTuple(t.OwnerThreshold(), t.OwnerLocktime(), t.OwnerAddress())
|
||||
if err := o.SyntacticVerify(); err != nil {
|
||||
return fmt.Errorf("TransferChainOwnershipTx.Owner: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -7,10 +7,53 @@ import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/luxfi/crypto/bls"
|
||||
"github.com/luxfi/crypto/bls/signer/localsigner"
|
||||
"github.com/luxfi/ids"
|
||||
"github.com/luxfi/zap"
|
||||
)
|
||||
|
||||
// makeValidVerifyingValidator mints a real BLS keypair and returns a
|
||||
// ValidatorsListEntry whose PoP passes bls.VerifyProofOfPossession. Used
|
||||
// by tests that want a well-formed CreateSovereignL1Tx and need the R6V3
|
||||
// PoP gate to succeed. Not used at runtime — keep this in tests only.
|
||||
func makeValidVerifyingValidator(t *testing.T) ValidatorsListEntry {
|
||||
t.Helper()
|
||||
sk, err := bls.NewSecretKey()
|
||||
if err != nil {
|
||||
t.Fatalf("bls.NewSecretKey: %v", err)
|
||||
}
|
||||
ls, err := localsigner.FromBytes(bls.SecretKeyToBytes(sk))
|
||||
if err != nil {
|
||||
t.Fatalf("localsigner.FromBytes: %v", err)
|
||||
}
|
||||
pk := ls.PublicKey()
|
||||
pkBytes := bls.PublicKeyToCompressedBytes(pk)
|
||||
sig, err := ls.SignProofOfPossession(pkBytes)
|
||||
if err != nil {
|
||||
t.Fatalf("SignProofOfPossession: %v", err)
|
||||
}
|
||||
sigBytes := bls.SignatureToBytes(sig)
|
||||
var rec ValidatorsListEntry
|
||||
rec.NodeID = ids.NodeID{0x77}
|
||||
rec.Weight = 1_000_000
|
||||
copy(rec.BLSPubKey[:], pkBytes)
|
||||
copy(rec.BLSPoP[:], sigBytes)
|
||||
rec.RegistrationExpiry = 1_900_000_000
|
||||
return rec
|
||||
}
|
||||
|
||||
// makeValidChainsEntry returns a ChainsListEntry whose FxIDsLen is a
|
||||
// proper multiple of FxIDSize (R6V5 must pass).
|
||||
func makeValidChainsEntry() ChainsListEntry {
|
||||
return ChainsListEntry{
|
||||
Name: []byte("evm"),
|
||||
VMID: ids.ID{0xed, 0xed},
|
||||
FxIDs: []ids.ID{{0x01}},
|
||||
GenesisData: []byte(`{"config":{}}`),
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerify_AcceptsWellFormed walks every tx type that embeds an
|
||||
// OwnerStub and pins that Verify() returns nil on a well-formed tx.
|
||||
// Threshold=1, single address — the legitimate single-signer case.
|
||||
@@ -62,7 +105,22 @@ func TestVerify_AcceptsWellFormed(t *testing.T) {
|
||||
}
|
||||
})
|
||||
t.Run("CreateSovereignL1", func(t *testing.T) {
|
||||
tx := NewCreateSovereignL1Tx(CreateSovereignL1TxInput{NetworkID: 1, Owner: stub})
|
||||
// R6V4 + R6V3 + R6V5: Verify now requires non-empty validators
|
||||
// with valid BLS PoP, non-empty chains, and well-formed FxIDsLen.
|
||||
tx := NewCreateSovereignL1Tx(CreateSovereignL1TxInput{
|
||||
NetworkID: 1,
|
||||
Owner: stub,
|
||||
Validators: []ValidatorsListEntry{makeValidVerifyingValidator(t)},
|
||||
Chains: []ChainsListEntry{makeValidChainsEntry()},
|
||||
})
|
||||
if err := tx.Verify(); err != nil {
|
||||
t.Fatalf("Verify = %v, want nil", err)
|
||||
}
|
||||
})
|
||||
t.Run("TransferChainOwnership", func(t *testing.T) {
|
||||
// R6V8: Verify is now wired in. The legitimate single-signer
|
||||
// case (threshold=1) must pass.
|
||||
tx := NewTransferChainOwnershipTx(ids.ID{0xCA}, 1, 0, addr)
|
||||
if err := tx.Verify(); err != nil {
|
||||
t.Fatalf("Verify = %v, want nil", err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user