mpcvm: no ceremony primitive outside the authorized wrapper

RunSign and RunKeygen stayed exported through the authorization change, still
taking the chain-name string that change removed. The wrapper became unforgeable
while the thing it wraps was reachable beside it, consulting no permission, no
quota and no Caller. Lowercased; the only callers were in-package.

A permission entry whose chainId is a LABEL binds to no chain, exactly like an
empty one, because a label never equals a base58 chain id. Counting it as bound
made a stock node report five authorized chains while authorizing none -- which is
the misreading the boot warning was added to prevent, reintroduced by the warning
itself. Bound now means the chainId parses as a chain id.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-08-20 13:55:29 -07:00
co-authored by Hanzo Dev
parent d87600fbef
commit 830ed497ae
3 changed files with 50 additions and 7 deletions
+37
View File
@@ -84,3 +84,40 @@ func TestCaller_PayloadCannotNameItself(t *testing.T) {
t.Fatal("a self-declared name must not produce a Caller")
}
}
// TestUnboundLabelIsNotCounted is F10: an entry whose chainId is a LABEL binds to
// no chain, exactly like an empty one, because a label never equals a base58 chain
// id. Counting it as bound made a stock node report five authorized chains while
// authorizing none — the precise misreading the boot warning exists to prevent.
func TestUnboundLabelIsNotCounted(t *testing.T) {
real := ids.GenerateTestID()
entries := map[string]*ChainPermissions{
"B-Chain": {ChainID: "B-Chain"}, // a label: binds to nothing
"C-Chain": {ChainID: ""}, // empty: binds to nothing
"X-Chain": {ChainID: real.String()}, // an actual chain id
}
bound := 0
for _, p := range entries {
if p != nil && p.ChainID != "" {
if _, err := ids.FromString(p.ChainID); err == nil {
bound++
}
}
}
if bound != 1 {
t.Fatalf("bound = %d, want 1 — only an entry carrying a real chain id is bound", bound)
}
}
// TestUnexportedCeremonyPrimitives is F6: the authorized wrapper was made
// unforgeable while the primitive it wraps stayed exported and still took the very
// chain-name string the change removed. A caller outside this package must have no
// way to run a ceremony at all.
func TestUnexportedCeremonyPrimitives(t *testing.T) {
var vm *VM
// These compile only because this test is IN the package. The property under
// test is that the identifiers are lowercase; if either is re-exported, the
// grep in CI and this comment are the record of why it must not be.
_ = vm.runKeygen
_ = vm.runSign
}
+2 -2
View File
@@ -161,7 +161,7 @@ func (vm *VM) Committee(ctx context.Context, height uint64) ([]party.ID, error)
// Every participant runs this and stages an identical operation. Non-blocking
// on the caller's behalf is the caller's business; this returns when the
// ceremony is done.
func (vm *VM) RunKeygen(ctx context.Context, keyID string, policy quorum.Policy, requestingChain string) (*Operation, error) {
func (vm *VM) runKeygen(ctx context.Context, keyID string, policy quorum.Policy, requestingChain string) (*Operation, error) {
if !policy.Valid() {
return nil, fmt.Errorf("mpcvm: keygen for %s: undeployable policy %s", keyID, policy)
}
@@ -286,7 +286,7 @@ func (vm *VM) RunKeygen(ctx context.Context, keyID string, policy quorum.Policy,
// caller's behalf: the caller knows its own signing domain (an Ethereum tx
// hash, a bridge release digest), and a chain that re-hashes would produce
// signatures over a preimage the caller never authorised.
func (vm *VM) RunSign(ctx context.Context, keyID string, digest []byte, requestingChain string) (*Operation, error) {
func (vm *VM) runSign(ctx context.Context, keyID string, digest []byte, requestingChain string) (*Operation, error) {
if len(digest) != 32 {
return nil, fmt.Errorf("mpcvm: sign %s: digest is %d bytes, want 32", keyID, len(digest))
}
+11 -5
View File
@@ -321,9 +321,15 @@ func (vm *VM) Initialize(
bound, unbound := 0, make([]string, 0, len(vm.config.AuthorizedChains))
for name, p := range vm.config.AuthorizedChains {
// A chainId that is not a chain id binds to nothing. The stock entries carry
// labels ("B-Chain"), which never equal a node's base58 chain id, so counting
// them as bound reported five authorized chains on a node where none were —
// the exact reading this warning exists to prevent.
if p != nil && p.ChainID != "" {
bound++
continue
if _, err := ids.FromString(p.ChainID); err == nil {
bound++
continue
}
}
unbound = append(unbound, name)
}
@@ -340,7 +346,7 @@ func (vm *VM) Initialize(
// is not an identity — until an operator sets chainId, the entry grants
// custody to nobody. Silence here would read as "custody is configured"
// right up until the first release fails.
vm.log.Warn("custody entries name a chain but bind to none — set chainId on each to grant it",
vm.log.Warn("custody entries bind to no chain id — set chainId to a real chain id on each to grant it",
log.Strings("chains", unbound),
)
}
@@ -503,7 +509,7 @@ func (vm *VM) StartKeygenWithPolicy(ctx context.Context, keyID string, policy qu
requestedBy := by.name
ctx, cancel := context.WithTimeout(ctx, vm.sessionTimeout())
defer cancel()
op, err := vm.RunKeygen(ctx, keyID, policy, requestedBy)
op, err := vm.runKeygen(ctx, keyID, policy, requestedBy)
if err != nil {
return nil, err
}
@@ -539,7 +545,7 @@ func (vm *VM) RequestSignature(ctx context.Context, by Caller, keyID string, mes
ctx, cancel := context.WithTimeout(ctx, vm.sessionTimeout())
defer cancel()
op, err := vm.RunSign(ctx, keyID, messageHash, requestingChain)
op, err := vm.runSign(ctx, keyID, messageHash, requestingChain)
if err != nil {
return nil, err
}