mpcvm: custody seats follow stake, and two seats are no longer a quorum

Seats went to the first N validators in canonical order, and canonical order is a
lexicographic sort of node ids. A node id is a hash of the node's certificate, so
an attacker grinds certificates offline until its ids sort first -- roughly 256
hashes per leading zero byte, and a zero byte encodes as the smallest base58
digit. Validator entry is permissionless, so that bought a seat in the share set
of EVERY key the chain would ever generate, at the price of some hashing.

Under the 2-of-3 default, two such seats were a quorum. Nothing needed to be
reconstructed at that point: the holder runs the signing protocol locally with
both shares and signs whatever it likes, off-chain, with no block produced and
nothing to observe.

Seats now follow weight, which cannot be ground -- entering the set costs stake
proportional to the stake already there, the same cost as attacking consensus, so
custody stops being the cheaper target. Node id still breaks ties, but now only
between validators of equal stake, where grinding buys a position among peers you
already had to match. Selection stays a pure function of replicated state, so
every validator derives the same set without negotiating one; the ordering test
runs it repeatedly because Go randomises map iteration and a selection that leaks
it would give each validator a different set.

The default policy is 3-of-5. Two seats are deliberately not a quorum, 2K > N
still holds so the quorum is unique, and the end-to-end DKG-and-sign test now
runs the real ceremony at that policy over the gossip fabric.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-08-20 14:29:52 -07:00
co-authored by Hanzo Dev
parent 830ed497ae
commit 1df746407a
4 changed files with 204 additions and 17 deletions
+8 -6
View File
@@ -220,7 +220,7 @@ func TestBridgeCustody_KeygenSignAndRecordOverGossip(t *testing.T) {
t.Skip("threshold DKG/sign is slow; skipped in -short")
}
const n = 3
const n = 5
const keyID = "bridge-custody"
nodes := make([]ids.NodeID, n)
@@ -240,10 +240,12 @@ func TestBridgeCustody_KeygenSignAndRecordOverGossip(t *testing.T) {
// --- DKG over the gossip fabric: every validator runs the ceremony ---
//
// The chain's default policy is 2-of-3, so the committee is all three
// validators and any two of them can sign.
// The chain's default policy is 3-of-5: the custody set is five validators
// and any three of them can sign. Two seats are deliberately NOT a quorum —
// validator entry is permissionless, so a policy where two seats suffice is a
// policy an adversary can buy its way into.
policy := vms[0].Policy()
require.Equal(t, "2-of-3", policy.String())
require.Equal(t, "3-of-5", policy.String())
keygens := runOnAll(t, ctx, vms, func(ctx context.Context, vm *VM) (*Operation, error) {
return vm.StartKeygen(ctx, keyID, authenticated(vm, "B-Chain"))
@@ -255,7 +257,7 @@ func TestBridgeCustody_KeygenSignAndRecordOverGossip(t *testing.T) {
require.Len(t, first.Key.GroupPublicKey, 33, "compressed secp256k1 custody key")
require.Len(t, first.Key.Address, 20, "20-byte external-chain custody address")
require.Equal(t, policy, first.Key.Policy)
require.Equal(t, 1, first.Key.Degree(), "2-of-3 is a degree-1 key; a degree-2 key would need 3 signers")
require.Equal(t, 2, first.Key.Degree(), "3-of-5 is a degree-2 key; K signers reconstruct nothing below K")
for _, op := range keygens {
require.Equal(t, first.CeremonyID, op.CeremonyID, "every validator must derive the same ceremony id with no announce round")
require.Equal(t, first.Key.GroupPublicKey, op.Key.GroupPublicKey, "all validators must agree on the group key")
@@ -297,7 +299,7 @@ func TestBridgeCustody_KeygenSignAndRecordOverGossip(t *testing.T) {
Nonce: 7,
}
// Every validator is asked; only the policy's K-subset signs. With a 2-of-3
// Every validator is asked; only the policy's K-subset signs. With a 3-of-5
// key that is two of the three, chosen deterministically from the task, so
// the third declines without anyone coordinating that.
atts, declined := runOnQuorum(t, ctx, vms, func(ctx context.Context, vm *VM) (*BridgeTransferAttestation, error) {
+62 -8
View File
@@ -123,6 +123,67 @@ func keygenCeremonyID(keyID string, policy quorum.Policy, participants []party.I
return "mpc/keygen/" + hex.EncodeToString(h.Sum(nil))
}
// custodySet chooses the N validators that will hold shares of a key: the N with
// the most stake, ties broken by node id.
//
// Selection used to be "the first N in canonical order", and canonical order is a
// lexicographic sort of node ids. A node id is a hash of the node's certificate,
// so an attacker grinds certificates offline until its ids sort first — roughly
// 256 hashes per leading zero byte, and a zero byte encodes as the smallest
// base58 digit. Two ground certificates would then sit in the share set of EVERY
// key the chain generates, which under a 2-of-3 policy is a quorum. Nothing needs
// reconstructing at that point: the holder runs the signing protocol locally with
// both shares and signs whatever it likes, off-chain, with no block to detect.
//
// Weight cannot be ground. Entering the set costs stake proportional to the stake
// already there — the same cost as attacking consensus — so custody stops being
// the cheaper target. Selection stays a pure function of replicated state, so
// every validator derives the same set without negotiating one.
func (vm *VM) custodySet(ctx context.Context, pchainHeight uint64, n int) ([]party.ID, error) {
if vm.rt == nil || vm.rt.ValidatorState == nil {
return nil, ErrNoCommittee
}
set, err := vm.rt.ValidatorState.GetValidatorSet(ctx, pchainHeight, vm.netID)
if err != nil {
return nil, fmt.Errorf("mpcvm: validator set at height %d: %w", pchainHeight, err)
}
if len(set) < n {
return nil, fmt.Errorf("%w: need %d parties, validator set has %d",
ErrPolicyTooLarge, n, len(set))
}
type seat struct {
id party.ID
weight uint64
}
seats := make([]seat, 0, len(set))
for nodeID, v := range set {
var w uint64
if v != nil {
w = v.Weight
}
seats = append(seats, seat{id: party.ID(nodeID.String()), weight: w})
}
// Descending weight; node id breaks ties so the order is total and identical
// on every validator. The tie-break is still lexicographic, but it now orders
// only validators of EQUAL stake — grinding an id buys a position among peers
// whose stake you already had to match.
sort.Slice(seats, func(i, j int) bool {
if seats[i].weight != seats[j].weight {
return seats[i].weight > seats[j].weight
}
return seats[i].id < seats[j].id
})
out := make([]party.ID, 0, n)
for _, s := range seats[:n] {
out = append(out, s.id)
}
// The ceremony id and every later check hash this set, so it must come back in
// the one canonical order the rest of the package uses.
return canonicalParties(out), nil
}
// Committee returns the ceremony party set: every validator of this chain at
// the given P-Chain height, in canonical order.
//
@@ -170,17 +231,10 @@ func (vm *VM) runKeygen(ctx context.Context, keyID string, policy quorum.Policy,
selfID := vm.partyID
vm.mu.RUnlock()
participants, err := vm.Committee(ctx, pchainHeight)
participants, err := vm.custodySet(ctx, pchainHeight, policy.N)
if err != nil {
return nil, err
}
if len(participants) < policy.N {
return nil, fmt.Errorf("%w: policy %s needs %d parties, committee has %d",
ErrPolicyTooLarge, policy, policy.N, len(participants))
}
// The committee is the first N validators in canonical order. Deterministic
// selection, so every node picks the same N without negotiating.
participants = participants[:policy.N]
if !containsParty(participants, selfID) {
return nil, ErrNotParticipant
}
+123
View File
@@ -0,0 +1,123 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// custody_set_test.go — who holds a share of a custody key must not be decidable
// by anyone who can generate certificates.
package mpcvm
import (
"context"
"testing"
"github.com/luxfi/ids"
"github.com/luxfi/runtime"
validators "github.com/luxfi/validators"
"github.com/luxfi/validators/validatorstest"
)
// weightedState is a validator set with explicit per-node stake.
func weightedState(w map[ids.NodeID]uint64) *validatorstest.TestState {
return &validatorstest.TestState{
GetValidatorSetF: func(context.Context, uint64, ids.ID) (map[ids.NodeID]*validators.GetValidatorOutput, error) {
out := make(map[ids.NodeID]*validators.GetValidatorOutput, len(w))
for n, weight := range w {
out[n] = &validators.GetValidatorOutput{NodeID: n, Weight: weight}
}
return out, nil
},
}
}
func setVM(vs *validatorstest.TestState) *VM {
return &VM{rt: &runtime.Runtime{NodeID: ids.GenerateTestNodeID(), NetworkID: 3, ValidatorState: vs}}
}
// TestCustodySetFollowsStakeNotNodeID is the attack this selection exists to
// stop.
//
// Seats used to go to the first N node ids in lexicographic order, and a node id
// is a hash of that node's certificate — so an attacker grinds certificates
// offline until its ids sort first and lands a seat in EVERY key the chain
// generates, for the price of some hashing. Seats now follow stake, which cannot
// be ground: a minimum-stake node sorting first in id order must not displace a
// validator that actually holds stake.
func TestCustodySetFollowsStakeNotNodeID(t *testing.T) {
// Build ids and use the smallest as the attacker, standing in for a ground
// certificate: it wins every lexicographic comparison.
ids4 := make([]ids.NodeID, 0, 4)
for i := 0; i < 4; i++ {
ids4 = append(ids4, ids.GenerateTestNodeID())
}
attacker := ids4[0]
for _, n := range ids4 {
if n.String() < attacker.String() {
attacker = n
}
}
weights := map[ids.NodeID]uint64{}
for _, n := range ids4 {
weights[n] = 1_000_000 // real validators, real stake
}
weights[attacker] = 1 // minimum stake, but sorts first by id
vm := setVM(weightedState(weights))
got, err := vm.custodySet(context.Background(), 0, 3)
if err != nil {
t.Fatalf("custodySet: %v", err)
}
if len(got) != 3 {
t.Fatalf("selected %d seats, want 3", len(got))
}
for _, p := range got {
if string(p) == attacker.String() {
t.Fatal("a minimum-stake node that merely sorts first took a custody seat — " +
"grinding certificates would buy a share of every key on the chain")
}
}
}
// TestCustodySetIsIdenticalOnEveryValidator: selection must be a pure function of
// replicated state, or validators derive different sets and no ceremony id ever
// matches. Map iteration order in Go is randomised, so this catches a selection
// that leaks it.
func TestCustodySetIsIdenticalOnEveryValidator(t *testing.T) {
weights := map[ids.NodeID]uint64{}
for i := 0; i < 6; i++ {
weights[ids.GenerateTestNodeID()] = uint64(100 - i) // distinct stakes
}
vs := weightedState(weights)
first, err := setVM(vs).custodySet(context.Background(), 0, 4)
if err != nil {
t.Fatalf("custodySet: %v", err)
}
for i := 0; i < 12; i++ {
again, err := setVM(vs).custodySet(context.Background(), 0, 4)
if err != nil {
t.Fatalf("custodySet: %v", err)
}
if len(again) != len(first) {
t.Fatalf("set size differs between validators: %d vs %d", len(again), len(first))
}
for j := range first {
if first[j] != again[j] {
t.Fatalf("validators derived different custody sets at seat %d: %s vs %s",
j, first[j], again[j])
}
}
}
}
// TestCustodySetRefusesAnUndersizedValidatorSet: a policy needing more seats than
// there are validators must fail loudly, not silently select fewer and produce a
// key with a smaller quorum than the policy declares.
func TestCustodySetRefusesAnUndersizedValidatorSet(t *testing.T) {
weights := map[ids.NodeID]uint64{}
for i := 0; i < 2; i++ {
weights[ids.GenerateTestNodeID()] = 10
}
if _, err := setVM(weightedState(weights)).custodySet(context.Background(), 0, 5); err == nil {
t.Fatal("a 5-seat policy over 2 validators must refuse, not silently shrink the quorum")
}
}
+11 -3
View File
@@ -396,9 +396,17 @@ func (vm *VM) parseConfig(configBytes []byte) error {
if len(configBytes) == 0 {
// Default configuration
vm.config = ThresholdConfig{
// 2-of-3: the smallest policy that is actually a threshold policy.
// Degree 1, so one party may be corrupt or offline.
Policy: quorum.MustNew(2, 3),
// 3-of-5. Two seats no longer reach a quorum, which matters because
// seats are held by validators and validator entry is permissionless:
// under 2-of-3 an adversary that got two seats held a quorum of every
// key on the chain and could sign locally, off-chain, with nothing
// on-chain to observe. At 3-of-5 it must hold a majority of the custody
// set, and seats are allocated by stake (custodySet), so that costs
// what attacking consensus costs.
//
// Degree 2: two parties may be corrupt or offline. 2K > N holds, so the
// quorum is unique — two disjoint quorums cannot both sign.
Policy: quorum.MustNew(3, 5),
SessionTimeout: 5 * time.Minute,
MaxActiveSessions: 100,
MaxSessionsPerChain: 10,