mirror of
https://github.com/luxfi/chains.git
synced 2026-07-27 03:39:41 +00:00
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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user