security(thresholdd): remove trusted-dealer BLS scheme — H-2

The thresholdd daemon registered a "bls" scheme whose keygen ran a
TrustedDealer in-process (one node mints the whole group secret key) —
the same single-point-of-compromise trusted-dealer BLS custody removed
from mpc. BLS is consensus-only (native per-validator keys), never a
custodied threshold key. Remove the daemon's bls scheme so the dispatcher
can no longer mint a trusted-dealer BLS key.

- Delete pkg/thresholdd/bls.go (blsScheme / TrustedDealer keygen).
- server.go: drop s.schemes["bls"] + the bls keygen/sign/verify procs.
- zap_schema.go: drop ProcBLS{Keygen,Sign,Verify} opcodes.
- Tests: delete the bls-specific round-trip + benchmarks; rewire the
  auth-gate tests (which used bls only as a fast keygen driver) to frost;
  drop bls from the sign_ctx unsupported-scheme loop.
- Scrub stale bls mentions from doc comments.

NOT touched: protocols/bls (the library primitive) stays — luxfi/chains
(a non-owned repo) consumes bls.TrustedDealer/AggregateSignatures
directly. Deleting protocols/bls must be a coordinated change with the
chains owner; flagged, not forced here.

Verified: GOWORK=off go build ./... green; affected thresholdd tests
(auth-gate, ProcOpcode, ZapShares, FrostRoundTrip, sign_ctx unsupported)
pass without -short. Zero bls refs remain in thresholdd.
This commit is contained in:
zeekay
2026-06-26 18:46:02 -07:00
parent 90e087aff0
commit d92cad5638
10 changed files with 39 additions and 332 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
// Command thresholdd exposes all luxfi/threshold protocols
// (cggmp21, frost, pulsar, corona, magnetar, bls, doerner) over a
// (cggmp21, frost, pulsar, corona, magnetar, doerner) over a
// single process-local ZAP byte-passthrough endpoint.
//
// Wire shape (see ~/work/lux/threshold/pkg/thresholdd/zap_schema.go):
-123
View File
@@ -1,123 +0,0 @@
package thresholdd
import (
"context"
"encoding/hex"
"fmt"
"sync"
luxbls "github.com/luxfi/crypto/bls"
"github.com/luxfi/threshold/pkg/party"
tbls "github.com/luxfi/threshold/protocols/bls"
)
// blsScheme wires luxfi/threshold/protocols/bls (Shamir t-of-n with
// Lagrange interpolation over BLS12-381 G2 signatures) into the
// dispatcher's scheme surface. Keygen runs a TrustedDealer in-process
// — that is the canonical luxfi/threshold path (see protocols/bls/bls_test.go).
type blsScheme struct {
mu sync.Mutex
sessions map[string]*blsSession
}
type blsSession struct {
threshold int
configs map[party.ID]*tbls.Config
partyIDs []party.ID
}
func newBLSScheme() *blsScheme {
return &blsScheme{sessions: make(map[string]*blsSession)}
}
func (s *blsScheme) Keygen(p keygenParams) (keygenResult, error) {
if err := validateKeygenParams(p); err != nil {
return keygenResult{}, err
}
ids := partyIDs(p.Participants)
dealer := &tbls.TrustedDealer{
Threshold: p.Threshold,
TotalParties: p.Participants,
}
shares, groupPK, err := dealer.GenerateShares(context.Background(), ids)
if err != nil {
return keygenResult{}, fmt.Errorf("bls keygen: %w", err)
}
vks := tbls.GetVerificationKeys(shares)
configs := make(map[party.ID]*tbls.Config, len(ids))
for _, id := range ids {
configs[id] = tbls.NewConfig(id, p.Threshold, p.Participants, shares[id], groupPK, vks)
}
pkBytes := luxbls.PublicKeyToCompressedBytes(groupPK)
pkHex := hex.EncodeToString(pkBytes)
shareHex := make([]string, 0, len(ids))
for _, id := range ids {
shareHex = append(shareHex, hex.EncodeToString(luxbls.SecretKeyToBytes(shares[id])))
}
s.mu.Lock()
s.sessions[pkHex] = &blsSession{
threshold: p.Threshold,
configs: configs,
partyIDs: ids,
}
s.mu.Unlock()
return keygenResult{PublicKey: pkHex, Shares: shareHex}, nil
}
func (s *blsScheme) Sign(p signParams) (signResult, error) {
msg, err := hex.DecodeString(p.MessageHex)
if err != nil {
return signResult{}, fmt.Errorf("messageHex: %w", err)
}
s.mu.Lock()
sess, ok := s.sessions[p.PubKeyHex]
s.mu.Unlock()
if !ok {
return signResult{}, fmt.Errorf("bls sign: unknown pubKeyHex (keygen first)")
}
signers := sess.partyIDs[:sess.threshold]
parts := make([]*tbls.SignatureShare, 0, len(signers))
for _, id := range signers {
share, err := sess.configs[id].Sign(msg)
if err != nil {
return signResult{}, fmt.Errorf("party %s sign: %w", id, err)
}
parts = append(parts, share)
}
sig, err := tbls.AggregateSignatures(parts, sess.threshold)
if err != nil {
return signResult{}, fmt.Errorf("aggregate: %w", err)
}
sigBytes := luxbls.SignatureToBytes(sig)
return signResult{SignatureHex: hex.EncodeToString(sigBytes)}, nil
}
func (s *blsScheme) Verify(p verifyParams) (verifyResult, error) {
msg, err := hex.DecodeString(p.MessageHex)
if err != nil {
return verifyResult{}, fmt.Errorf("messageHex: %w", err)
}
sigBytes, err := hex.DecodeString(p.SignatureHex)
if err != nil {
return verifyResult{}, fmt.Errorf("signatureHex: %w", err)
}
pkBytes, err := hex.DecodeString(p.PubKeyHex)
if err != nil {
return verifyResult{}, fmt.Errorf("pubKeyHex: %w", err)
}
pk, err := luxbls.PublicKeyFromCompressedBytes(pkBytes)
if err != nil {
return verifyResult{OK: false}, nil
}
sig, err := luxbls.SignatureFromBytes(sigBytes)
if err != nil {
return verifyResult{OK: false}, nil
}
return verifyResult{OK: luxbls.Verify(pk, sig, msg)}, nil
}
+2 -2
View File
@@ -27,8 +27,8 @@ import (
// Trust model on keygen:
//
// - The dispatcher runs the trusted-dealer GenerateKeys path
// in-process (matches the BLS scheme: this is the dispatcher
// contract, NOT the on-chain production path). The Pedersen-DKG
// in-process (this is the dispatcher contract for off-chain test
// harnesses, NOT the on-chain production path). The Pedersen-DKG
// no-trusted-dealer path lives at luxfi/corona/keyera.Bootstrap and
// is what consensus drives at chain genesis. The dispatcher exists
// for off-chain test harnesses, MPC bus integration tests, and
+1 -1
View File
@@ -1,6 +1,6 @@
// Package thresholdd is the ZAP-native dispatcher that exposes every
// luxfi/threshold protocol (cggmp21, frost, pulsar, corona, magnetar,
// bls, doerner) on a single byte-passthrough transport.
// doerner) on a single byte-passthrough transport.
//
// One wire, one transport: the historical HTTP+JSON+hex path was
// removed in favour of ZAP byte-passthrough. There is no JSON-RPC
+2 -2
View File
@@ -18,8 +18,8 @@ import (
// listed party and returns the final per-party result map.
//
// This is the production path the daemon uses for the protocols that
// actually need a round-based exchange (cmp / frost / doerner). Pulsar,
// corona, and bls have direct single-process keygen functions and skip
// actually need a round-based exchange (cmp / frost / doerner). Pulsar
// and corona have direct single-process keygen functions and skip
// this runner.
func runMultiparty(
partyIDs []party.ID,
+2 -6
View File
@@ -65,7 +65,7 @@ type ZapServerConfig struct {
}
// NewZapServer constructs a ZapServer with the canonical scheme set
// (cggmp21, frost, pulsar, corona, magnetar, bls, doerner).
// (cggmp21, frost, pulsar, corona, magnetar, doerner).
func NewZapServer(cfg ZapServerConfig) (*ZapServer, error) {
if cfg.Logger == nil {
cfg.Logger = slog.Default()
@@ -84,7 +84,6 @@ func NewZapServer(cfg ZapServerConfig) (*ZapServer, error) {
s.schemes["pulsar"] = newPulsarScheme()
s.schemes["corona"] = newCoronaScheme()
s.schemes["magnetar"] = newMagnetarScheme()
s.schemes["bls"] = newBLSScheme()
s.schemes["doerner"] = newDoernerScheme()
s.node = zap.NewNode(zap.NodeConfig{
@@ -143,9 +142,6 @@ var allProcedures = []procedureBinding{
{name: ProcMagnetarSign, scheme: "magnetar", op: "sign"},
{name: ProcMagnetarSignCtx, scheme: "magnetar", op: "sign_ctx"},
{name: ProcMagnetarVerify, scheme: "magnetar", op: "verify"},
{name: ProcBLSKeygen, scheme: "bls", op: "keygen"},
{name: ProcBLSSign, scheme: "bls", op: "sign"},
{name: ProcBLSVerify, scheme: "bls", op: "verify"},
{name: ProcDoernerKeygen, scheme: "doerner", op: "keygen"},
{name: ProcDoernerSign, scheme: "doerner", op: "sign"},
{name: ProcDoernerVerify, scheme: "doerner", op: "verify"},
@@ -314,7 +310,7 @@ func (s *ZapServer) dispatchSignCtx(reqFlags uint16, sch scheme, resolver ChainP
ChainID: chainID,
}
var (
res signResult
res signResult
oerr error
)
if pas, ok := sch.(profileAwareCtxSigner); ok {
-155
View File
@@ -2,11 +2,7 @@
package thresholdd
import (
"context"
"fmt"
"net"
"testing"
"time"
)
// server_test.go — ZAP-side correctness + benchmark tests. The prior
@@ -14,62 +10,6 @@ import (
// correctness across the ZAP wire is asserted by thresholdd_test.go
// (full per-scheme round-trips) and the dedicated tests here.
// TestZapServer_BLSRoundTrip exercises keygen + sign + verify end-
// to-end over the ZAP wire. BLS keygen is fast (no Paillier safe-
// prime sampling) so this is the cheapest smoke test.
func TestZapServer_BLSRoundTrip(t *testing.T) {
if testing.Short() {
t.Skip("skipping BLS round-trip under -short")
}
addr, stop := startTestServer(t)
defer stop()
ctx := context.Background()
c, err := ConnectZap(ctx, addr, WithZapCallTimeout(20*time.Second))
if err != nil {
t.Fatalf("ConnectZap: %v", err)
}
defer c.Close()
pubKey, shares, err := c.Keygen(ctx, "bls", 2, 3)
if err != nil {
t.Fatalf("Keygen: %v", err)
}
if len(pubKey) == 0 {
t.Fatalf("empty pubKey")
}
if len(shares) != 3 {
t.Fatalf("shares=%d want=3", len(shares))
}
msg := []byte("zap-bls-roundtrip-message")
sig, err := c.Sign(ctx, "bls", msg, pubKey)
if err != nil {
t.Fatalf("Sign: %v", err)
}
if len(sig) == 0 {
t.Fatalf("empty signature")
}
ok, err := c.Verify(ctx, "bls", msg, sig, pubKey)
if err != nil {
t.Fatalf("Verify: %v", err)
}
if !ok {
t.Fatalf("Verify failed: round-trip signature rejected")
}
// Forgery: different message must verify false.
wrong := []byte("zap-bls-other-message")
bad, err := c.Verify(ctx, "bls", wrong, sig, pubKey)
if err != nil {
t.Fatalf("Verify (forgery): %v", err)
}
if bad {
t.Fatalf("Verify accepted forgery")
}
}
// TestZapShares_RoundTrip covers the EncodeShares / DecodeShares
// helper. Trivial layout, but a regression here would corrupt every
// keygen response, so pin it.
@@ -131,98 +71,3 @@ func TestProcOpcode_StableAndNonReserved(t *testing.T) {
seen[op] = p.name
}
}
// BenchmarkZapServer_BLSSign measures end-to-end Sign() latency over
// the ZAP transport. Reports ns/op + ops/sec.
func BenchmarkZapServer_BLSSign(b *testing.B) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
b.Fatalf("probe listen: %v", err)
}
_, portStr, _ := net.SplitHostPort(ln.Addr().String())
ln.Close()
var port int
fmt.Sscanf(portStr, "%d", &port)
srv, err := NewZapServer(ZapServerConfig{NodeID: "bench-zap", Port: port})
if err != nil {
b.Fatalf("NewZapServer: %v", err)
}
if err := srv.Start(); err != nil {
b.Fatalf("Start: %v", err)
}
defer srv.Stop()
time.Sleep(20 * time.Millisecond)
addr := fmt.Sprintf("127.0.0.1:%d", port)
ctx := context.Background()
c, err := ConnectZap(ctx, addr, WithZapCallTimeout(30*time.Second))
if err != nil {
b.Fatalf("ConnectZap: %v", err)
}
defer c.Close()
pubKey, _, err := c.Keygen(ctx, "bls", 2, 3)
if err != nil {
b.Fatalf("Keygen: %v", err)
}
msg := []byte("bench-message-zap")
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := c.Sign(ctx, "bls", msg, pubKey)
if err != nil {
b.Fatalf("Sign iter %d: %v", i, err)
}
}
}
// BenchmarkZapServer_BLSVerify measures end-to-end Verify() latency
// over the ZAP transport.
func BenchmarkZapServer_BLSVerify(b *testing.B) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
b.Fatalf("probe: %v", err)
}
_, portStr, _ := net.SplitHostPort(ln.Addr().String())
ln.Close()
var port int
fmt.Sscanf(portStr, "%d", &port)
srv, err := NewZapServer(ZapServerConfig{NodeID: "bench-zap-v", Port: port})
if err != nil {
b.Fatalf("NewZapServer: %v", err)
}
if err := srv.Start(); err != nil {
b.Fatalf("Start: %v", err)
}
defer srv.Stop()
time.Sleep(20 * time.Millisecond)
addr := fmt.Sprintf("127.0.0.1:%d", port)
ctx := context.Background()
c, err := ConnectZap(ctx, addr, WithZapCallTimeout(30*time.Second))
if err != nil {
b.Fatalf("ConnectZap: %v", err)
}
defer c.Close()
pubKey, _, err := c.Keygen(ctx, "bls", 2, 3)
if err != nil {
b.Fatalf("Keygen: %v", err)
}
msg := []byte("bench-verify-msg")
sig, err := c.Sign(ctx, "bls", msg, pubKey)
if err != nil {
b.Fatalf("Sign: %v", err)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
ok, err := c.Verify(ctx, "bls", msg, sig, pubKey)
if err != nil {
b.Fatalf("Verify iter %d: %v", i, err)
}
if !ok {
b.Fatalf("Verify iter %d: rejected own signature", i)
}
}
}
+2 -2
View File
@@ -262,11 +262,11 @@ func TestSign_Ctx_UnsupportedSchemeReturnsMethodNotFound(t *testing.T) {
}
defer c.Close()
// cggmp21/frost/bls/corona/doerner do NOT register sign_ctx
// cggmp21/frost/corona/doerner do NOT register sign_ctx
// procedures in allProcedures, so knownProcedure rejects on the
// client side. That is the canonical method-not-found path: the
// client refuses to round-trip a procedure it does not know.
for _, sch := range []string{"cggmp21", "frost", "bls", "corona", "doerner"} {
for _, sch := range []string{"cggmp21", "frost", "corona", "doerner"} {
_, err := c.SignCtx(ctx, sch, []byte{0}, []byte{0}, []byte{0}, "")
if err == nil {
t.Fatalf("%s.sign_ctx: expected error, got success", sch)
+5 -13
View File
@@ -196,14 +196,6 @@ func TestMagnetarRoundTrip(t *testing.T) {
roundtrip(t, "magnetar", 1, 1)
}
func TestBLSRoundTrip(t *testing.T) {
t.Parallel()
if testing.Short() {
t.Skip("skipping BLS round-trip under -short")
}
roundtrip(t, "bls", 2, 3)
}
// TestDoernerExplicitlyBroken asserts the daemon surfaces the upstream
// breakage clearly rather than silently returning bad data. See
// doerner.go header for why this is the test surface today.
@@ -271,7 +263,7 @@ func TestAuthTokenRejectsWrongPeer(t *testing.T) {
t.Fatalf("ConnectZap: %v", err)
}
defer c.Close()
_, _, err = c.Keygen(ctx, "bls", 2, 3)
_, _, err = c.Keygen(ctx, "frost", 2, 3)
if err == nil {
t.Fatalf("expected unauthorized error, got success")
}
@@ -285,7 +277,7 @@ func TestAuthTokenRejectsWrongPeer(t *testing.T) {
func TestAuthTokenAcceptsValid(t *testing.T) {
t.Parallel()
if testing.Short() {
t.Skip("skipping auth-token positive path under -short (drives BLS keygen)")
t.Skip("skipping auth-token positive path under -short (drives frost keygen)")
}
addr, stop := startTestServerWithConfig(t, ZapServerConfig{AuthToken: "secret-token"})
defer stop()
@@ -298,7 +290,7 @@ func TestAuthTokenAcceptsValid(t *testing.T) {
t.Fatalf("ConnectZap: %v", err)
}
defer c.Close()
pubKey, _, err := c.Keygen(ctx, "bls", 2, 3)
pubKey, _, err := c.Keygen(ctx, "frost", 2, 3)
if err != nil {
t.Fatalf("Keygen with valid token: %v", err)
}
@@ -314,7 +306,7 @@ func TestAuthTokenAcceptsValid(t *testing.T) {
func TestAuthTokenEmptyAllowsAnonymous(t *testing.T) {
t.Parallel()
if testing.Short() {
t.Skip("skipping anonymous-auth positive path under -short (drives BLS keygen)")
t.Skip("skipping anonymous-auth positive path under -short (drives frost keygen)")
}
addr, stop := startTestServer(t)
defer stop()
@@ -325,7 +317,7 @@ func TestAuthTokenEmptyAllowsAnonymous(t *testing.T) {
t.Fatalf("ConnectZap: %v", err)
}
defer c.Close()
pubKey, _, err := c.Keygen(ctx, "bls", 2, 3)
pubKey, _, err := c.Keygen(ctx, "frost", 2, 3)
if err != nil {
t.Fatalf("Keygen with empty token: %v", err)
}
+24 -27
View File
@@ -38,29 +38,26 @@ import (
// Server.Register and force a name change at build time, never
// silent routing.
const (
ProcCggmp21Keygen = "cggmp21.keygen"
ProcCggmp21Sign = "cggmp21.sign"
ProcCggmp21Verify = "cggmp21.verify"
ProcFrostKeygen = "frost.keygen"
ProcFrostSign = "frost.sign"
ProcFrostVerify = "frost.verify"
ProcPulsarKeygen = "pulsar.keygen"
ProcPulsarSign = "pulsar.sign"
ProcPulsarSignCtx = "pulsar.sign_ctx"
ProcPulsarVerify = "pulsar.verify"
ProcCoronaKeygen = "corona.keygen"
ProcCoronaSign = "corona.sign"
ProcCoronaVerify = "corona.verify"
ProcMagnetarKeygen = "magnetar.keygen"
ProcMagnetarSign = "magnetar.sign"
ProcMagnetarSignCtx = "magnetar.sign_ctx"
ProcMagnetarVerify = "magnetar.verify"
ProcBLSKeygen = "bls.keygen"
ProcBLSSign = "bls.sign"
ProcBLSVerify = "bls.verify"
ProcDoernerKeygen = "doerner.keygen"
ProcDoernerSign = "doerner.sign"
ProcDoernerVerify = "doerner.verify"
ProcCggmp21Keygen = "cggmp21.keygen"
ProcCggmp21Sign = "cggmp21.sign"
ProcCggmp21Verify = "cggmp21.verify"
ProcFrostKeygen = "frost.keygen"
ProcFrostSign = "frost.sign"
ProcFrostVerify = "frost.verify"
ProcPulsarKeygen = "pulsar.keygen"
ProcPulsarSign = "pulsar.sign"
ProcPulsarSignCtx = "pulsar.sign_ctx"
ProcPulsarVerify = "pulsar.verify"
ProcCoronaKeygen = "corona.keygen"
ProcCoronaSign = "corona.sign"
ProcCoronaVerify = "corona.verify"
ProcMagnetarKeygen = "magnetar.keygen"
ProcMagnetarSign = "magnetar.sign"
ProcMagnetarSignCtx = "magnetar.sign_ctx"
ProcMagnetarVerify = "magnetar.verify"
ProcDoernerKeygen = "doerner.keygen"
ProcDoernerSign = "doerner.sign"
ProcDoernerVerify = "doerner.verify"
)
// Fixed-payload layouts. The unsigned-integer fields and the offset
@@ -116,10 +113,10 @@ const (
// constants below) so callers can branch on well-defined error
// classes. RefusedStrictPQ is the strict-PQ refusal signal — a
// separate flag so the consumer can errors.Is(ErrRefusedUnderStrictPQ).
zapErrRespOffCode = 0
zapErrRespOffMsg = 8
zapErrRespOffStrictPQ = 16
zapErrRespSize = 24
zapErrRespOffCode = 0
zapErrRespOffMsg = 8
zapErrRespOffStrictPQ = 16
zapErrRespSize = 24
)
// ZAP message flag layout: the upper byte carries the message kind