mpcvm: sign with the policy's quorum, and prove 3-of-5 end to end

Signing used the key's whole participant set, so a 3-of-5 key signed with
five parties. That works, but it never exercises the quorum: a key that
was accidentally degree-3 would sign identically, which is precisely the
failure this VM exists to catch.

RunSign now selects K signers by ranking participants on
H(tag ‖ keyID ‖ digest ‖ party) and taking the first K. The selection is a
pure function of the task, so every node — signer or not — computes the
same quorum and the same ceremony id with no election. Ranking by digest
rather than taking the first K in canonical order spreads signing across
the committee instead of loading the same K parties for every transfer,
and stops an adversary steering which subset signs a message it does not
control. Nodes outside the quorum return ErrNotInQuorum and verify the
result like any other validator.

Verify deliberately does not pin WHICH K-subset signed. The signature
verifies under the group key or it does not, and an adversary who can
produce one already holds K shares, so constraining the subset buys no
security while leaving it open lets availability-aware reselection ship
later without a consensus change.

TestBridgeCustody_ThreeOfFive is the gate: five validators run a real
CGGMP21 DKG over the gossip fabric at degree 2, exactly three sign, two
decline, the signature verifies under the group key, a block carrying it
is verified and accepted by all five — including the two that never
touched the ceremony — and every node ends on the same state root.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
This commit is contained in:
zeekay
2026-07-25 14:45:13 -07:00
co-authored by Hanzo Dev
parent 54d89c8802
commit 4571c38a4a
3 changed files with 271 additions and 10 deletions
+6
View File
@@ -300,6 +300,12 @@ func (vm *VM) verifySign(op *Operation, pending map[string]*KeyRecord) error {
// The signer set must satisfy the key's own policy. K signers, not K-1: a
// set of size Degree() cannot produce a signature, so a record claiming one
// did is either a lie or a wrong-degree key.
//
// Which particular K-subset signed is deliberately NOT pinned here. The
// signature verifies under the group key or it does not, and an adversary
// able to produce one already holds K shares — constraining the subset adds
// no security, while leaving it open lets a future availability-aware
// reselection ship without a consensus change.
if len(op.Signers) < rec.Policy.K {
return fmt.Errorf("%w: %d signers for policy %s", ErrQuorumTooSmall, len(op.Signers), rec.Policy)
}
+189 -4
View File
@@ -30,6 +30,7 @@ package mpcvm
import (
"context"
"errors"
"sync"
"sync/atomic"
"testing"
@@ -40,6 +41,7 @@ import (
"github.com/luxfi/log"
"github.com/luxfi/math/set"
"github.com/luxfi/runtime"
"github.com/luxfi/threshold/pkg/quorum"
validators "github.com/luxfi/validators"
"github.com/luxfi/validators/validatorstest"
vmcore "github.com/luxfi/vm"
@@ -296,9 +298,14 @@ func TestBridgeCustody_KeygenSignAndRecordOverGossip(t *testing.T) {
Nonce: 7,
}
atts := runOnAll(t, ctx, vms, func(ctx context.Context, vm *VM) (*BridgeTransferAttestation, error) {
// Every validator is asked; only the policy's K-subset signs. With a 2-of-3
// 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) {
return vm.RequestBridgeRelease(ctx, req)
})
require.Len(t, atts, policy.K, "exactly K validators sign")
require.Equal(t, policy.N-policy.K, declined, "the rest decline rather than sign")
bt := BridgeTransfer{
SrcChainID: req.SrcChainID,
@@ -326,7 +333,7 @@ func TestBridgeCustody_KeygenSignAndRecordOverGossip(t *testing.T) {
"signature must not verify under a different destination chain")
// --- the signature becomes an auditable record in replicated state ---
commitBlock(t, ctx, vms[1], vms) // a DIFFERENT proposer: nothing depends on who builds
commitBlock(t, ctx, signerVM(t, vms, atts[0]), vms)
digest := bt.Digest()
for _, vm := range vms {
rec, err := vm.Ceremony(att.CeremonyID)
@@ -336,13 +343,13 @@ func TestBridgeCustody_KeygenSignAndRecordOverGossip(t *testing.T) {
require.Equal(t, digest[:], rec.Digest)
require.Equal(t, att.Signature, rec.Artifact, "the recorded artifact is the signature B was handed")
require.Equal(t, "B-Chain", rec.RequestingChain)
require.Len(t, rec.Signers, n)
require.Len(t, rec.Signers, policy.K, "the recorded quorum is K, not the whole committee")
}
// Replay is refused: the ceremony id is derived from (key, digest, signers),
// so asking again for the identical release is the identical ceremony — and
// recording it twice would be a double release.
_, err := vms[0].RequestBridgeRelease(ctx, req)
_, err := signerVM(t, vms, atts[0]).RequestBridgeRelease(ctx, req)
require.ErrorIs(t, err, ErrCeremonyExists)
// Proof the fabric actually carried ceremony messages between validators
@@ -377,5 +384,183 @@ func runOnAll[T any](t *testing.T, ctx context.Context, vms []*VM, fn func(conte
return out
}
// runOnQuorum invokes fn on every validator and separates the ones that signed
// from the ones that correctly declined because they are outside this task's
// K-subset. Any OTHER error is a failure: a ceremony that breaks on some nodes
// and not others is the split-brain this VM exists to prevent.
func runOnQuorum[T any](t *testing.T, ctx context.Context, vms []*VM, fn func(context.Context, *VM) (T, error)) (signed []T, declined int) {
t.Helper()
var wg sync.WaitGroup
out := make([]T, len(vms))
errs := make([]error, len(vms))
for i, vm := range vms {
wg.Add(1)
go func(i int, vm *VM) {
defer wg.Done()
out[i], errs[i] = fn(ctx, vm)
}(i, vm)
}
wg.Wait()
for i, err := range errs {
switch {
case err == nil:
signed = append(signed, out[i])
case errors.Is(err, ErrNotInQuorum):
declined++
default:
require.NoErrorf(t, err, "validator %d failed for a reason other than being outside the quorum", i)
}
}
return signed, declined
}
// signerVM returns a validator that participated in att's ceremony — i.e. one
// whose party id is in the signing quorum. Such a node has the signature staged
// (so it can propose a block carrying it) and is the node that would re-run the
// ceremony on a replay rather than declining as an outsider.
//
// Membership is read from the attestation's signer set rather than from the
// staging map, because Accept clears a ceremony from staging once its block
// lands — a post-commit lookup by staging would find nobody.
func signerVM(t *testing.T, vms []*VM, att *BridgeTransferAttestation) *VM {
t.Helper()
for _, vm := range vms {
for _, s := range att.Signers {
if s == vm.partyID {
return vm
}
}
}
t.Fatalf("no validator is in the signing quorum of ceremony %s", att.CeremonyID)
return nil
}
func mutateNonce(bt BridgeTransfer) BridgeTransfer { bt.Nonce++; return bt }
func mutateDstChain(bt BridgeTransfer) BridgeTransfer { bt.DstChainID++; return bt }
// =============================================================================
// THE GATE: a genuine 3-of-5 custody key, signing through the chain
// =============================================================================
// TestBridgeCustody_ThreeOfFive is the proof that M-Chain replaces an off-chain
// signer cluster with something strictly better than what it replaces.
//
// Five validators generate one custody key under a 3-of-5 policy. The policy is
// consensus state, so the degree is not a per-binary flag that can silently
// disagree with it — which is exactly how three live k8s quorums ended up
// 1-of-n while their configs said 3-of-5.
//
// It then proves the quorum is real in both directions:
//
// - EXACTLY THREE of the five validators run the signing ceremony and produce
// a valid signature. Not five. If the key were degree-3 (the off-by-one this
// whole change exists to prevent) three parties could not have signed at all.
// - The other two never touch the ceremony, yet still verify and accept the
// block — non-participants validate custody without trusting the signers.
func TestBridgeCustody_ThreeOfFive(t *testing.T) {
if testing.Short() {
t.Skip("threshold DKG/sign is slow; skipped in -short")
}
const n = 5
const keyID = "bridge-custody-3of5"
policy := quorum.MustNew(3, 5)
nodes := make([]ids.NodeID, n)
for i := range nodes {
nodes[i] = ids.GenerateTestNodeID()
}
vs := committeeState(nodes)
fab := newMemFabric()
vms := make([]*VM, n)
for i, nid := range nodes {
vms[i] = newFabricVM(t, fab, nid, vs)
}
ctx, cancel := ceremonyContext(t)
defer cancel()
// --- DKG at degree 2 across all five ---
keygens := runOnAll(t, ctx, vms, func(ctx context.Context, vm *VM) (*Operation, error) {
return vm.StartKeygenWithPolicy(ctx, keyID, policy, "B-Chain")
})
rec := keygens[0].Key
require.Equal(t, policy, rec.Policy)
require.Equal(t, 2, rec.Degree(),
"3-of-5 must be a degree-2 key; degree 3 would need four signers and is the classic off-by-one")
require.Len(t, rec.Participants, 5)
for _, op := range keygens {
require.Equal(t, rec.GroupPublicKey, op.Key.GroupPublicKey, "all five must agree on the group key")
}
commitBlock(t, ctx, vms[0], vms)
groupPub := rec.GroupPublicKey
// --- exactly three sign ---
var asset [32]byte
copy(asset[:], []byte("LUX"))
var recip [20]byte
copy(recip[:], []byte("recipient-3of5"))
req := BridgeReleaseRequest{
RequestingChain: "B-Chain",
KeyID: keyID,
SrcChainID: 200201,
DstChainID: 97368,
Asset: asset,
Amount: 4_200_000,
Recipient: recip,
Nonce: 11,
}
// Every validator is asked; the ones outside this task's quorum decline with
// ErrNotInQuorum. Nobody coordinates that — each node computes the same
// quorum from the task alone.
signed, declined := runOnQuorum(t, ctx, vms, func(ctx context.Context, vm *VM) (*BridgeTransferAttestation, error) {
return vm.RequestBridgeRelease(ctx, req)
})
require.Len(t, signed, 3, "exactly K=3 validators must sign a 3-of-5 key")
require.Equal(t, 2, declined, "the remaining N-K=2 validators must decline, not sign")
att := signed[0]
bt := BridgeTransfer{
SrcChainID: req.SrcChainID,
DstChainID: req.DstChainID,
Asset: req.Asset,
Amount: req.Amount,
Recipient: req.Recipient,
Nonce: req.Nonce,
}
for _, a := range signed {
require.Equal(t, att.Signature, a.Signature, "the three signers derive one identical signature")
require.True(t, VerifyBridgeAttestation(groupPub, bt, a.Signature),
"three shares of a 3-of-5 key must produce a signature that verifies under the group key")
}
require.Len(t, att.Signers, 3)
// --- the two non-signers verify and accept a block they had no part in ---
//
// The signature reaches a block via one of the three signers (a non-signer
// has nothing staged to propose). Every validator then verifies it against
// its own registry. That is the property that matters: verification does not
// require participation, so a validator that sat out the ceremony still
// polices custody rather than trusting whoever signed.
commitBlock(t, ctx, signerVM(t, vms, att), vms)
digest := bt.Digest()
for i, vm := range vms {
crec, err := vm.Ceremony(att.CeremonyID)
require.NoErrorf(t, err, "validator %d cannot read the ceremony it accepted", i)
require.Equal(t, digest[:], crec.Digest)
require.Equal(t, att.Signature, crec.Artifact)
require.Len(t, crec.Signers, 3, "the recorded quorum is three, not five")
krec, err := vm.Key(keyID)
require.NoError(t, err)
require.Equal(t, "3-of-5", krec.Policy.String())
require.Equal(t, 2, krec.Degree())
}
t.Logf("3-of-5 custody: key %s addr 0x%x | %d of %d validators signed, %d declined | signature verifies | state root %x",
keyID, rec.Address, len(signed), n, declined, vms[0].StateRoot())
}
+76 -6
View File
@@ -35,11 +35,13 @@ package mpcvm
// disagrees with a participant's own share is rejected by that participant.
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"sort"
"time"
"github.com/luxfi/ids"
@@ -53,6 +55,11 @@ import (
// id, so the two can never collide even for identical (key, participant) inputs.
const keygenDomainTag = "LUX_MPC_KEYGEN_v1"
// quorumSelectTag domain-separates the per-task quorum selection hash, so the
// ranking of a party for one message can never be reused as a ranking for
// another, nor collide with a ceremony id or a signing preimage.
const quorumSelectTag = "LUX_MPC_QUORUM_SELECT_v1"
// KindCGGMP21 names the threshold-ECDSA protocol used for bridge custody of
// external wallets. It is the value stored in KeyRecord.Kind.
const KindCGGMP21 = "cggmp21"
@@ -60,6 +67,7 @@ const KindCGGMP21 = "cggmp21"
var (
ErrNoCommittee = errors.New("mpcvm: no validator committee available")
ErrNotParticipant = errors.New("mpcvm: this node is not in the ceremony committee")
ErrNotInQuorum = errors.New("mpcvm: this node is not in this task's signing quorum")
ErrPolicyTooLarge = errors.New("mpcvm: policy requires more parties than the committee has")
)
@@ -298,12 +306,22 @@ func (vm *VM) RunSign(ctx context.Context, keyID string, digest []byte, requesti
return nil, ErrNotParticipant
}
// The signer set is the key's full participant set. Every member signs.
// Selecting a minimal K-subset under partial availability is a liveness
// optimisation, not a correctness one, and it is deliberately not done
// here: a subset chosen independently by each node would give different
// nodes different ceremony ids and no ceremony would ever converge.
signers := rec.Participants
// The signer set is a K-subset of the participants — the quorum the policy
// actually calls for, not everyone. A 3-of-5 key signs with three parties.
//
// The subset is chosen deterministically from the task itself (see
// quorumFor), because a subset chosen independently by each node would give
// different nodes different ceremony ids and no ceremony would ever
// converge. Determinism is what makes a leaderless quorum possible: every
// node computes the same answer without negotiating one.
signers := quorumFor(rec, digest)
if !containsParty(signers, selfID) {
// This node holds a share but is not in this task's quorum. That is
// normal and not an error condition for the chain: the ceremony will
// complete without it, and it will verify the result like any other
// validator when the block arrives.
return nil, ErrNotInQuorum
}
cid := ceremonyID(keyID, digest, signers)
if _, err := vm.state.GetCeremony(cid); err == nil {
@@ -455,6 +473,58 @@ func (vm *VM) drain(limit int) []*Operation {
return out
}
// quorumFor selects the K participants that sign a given task.
//
// The selection is a deterministic function of (key, digest) alone, so every
// node — signer or not — computes the same quorum and therefore the same
// ceremony id, with no election and no coordinator. Ordering the participants
// by H(tag ‖ keyID ‖ digest ‖ party) rather than taking the first K in
// canonical order spreads signing work evenly across the committee instead of
// loading the same K parties for every transfer, and it means an adversary
// cannot choose which subset will sign a message it does not control.
//
// Liveness note: the quorum is fixed per task, so a task whose selected subset
// contains an offline party stalls rather than falling back to another subset.
// Availability-aware reselection needs a deterministic epoch or view number to
// rotate on — every node must still agree — and that is a follow-up. Safety
// does not depend on it: a stalled ceremony produces no signature and no state
// transition.
func quorumFor(rec *KeyRecord, digest []byte) []party.ID {
type ranked struct {
id party.ID
rank [32]byte
}
rs := make([]ranked, 0, len(rec.Participants))
for _, p := range rec.Participants {
h := sha256.New()
writeTagged(h, quorumSelectTag)
writeField(h, []byte(rec.KeyID))
writeField(h, digest)
writeField(h, []byte(p))
var r [32]byte
copy(r[:], h.Sum(nil))
rs = append(rs, ranked{id: p, rank: r})
}
sort.Slice(rs, func(i, j int) bool {
if c := bytes.Compare(rs[i].rank[:], rs[j].rank[:]); c != 0 {
return c < 0
}
return rs[i].id < rs[j].id // total order even on a hash collision
})
k := rec.Policy.K
if k > len(rs) {
k = len(rs)
}
out := make([]party.ID, 0, k)
for _, r := range rs[:k] {
out = append(out, r.id)
}
// Canonical order is what the ceremony id and the block record are hashed
// over; the ranking above only decides membership.
return canonicalParties(out)
}
func containsParty(ps []party.ID, want party.ID) bool {
for _, p := range ps {
if p == want {