security(mpc): rip trusted-dealer threshold-BLS custody — H-2

BLS is consensus-only. The mpcd validator-key custody path generated a
threshold-BLS group key via a TRUSTED DEALER (blsThreshold.TrustedDealer:
one node, the lowest sorted party ID, generated the entire group secret
key and all shares, then distributed them) — a single point of full-key
compromise, with no DKG and no rogue-key PoP. That violates the
public/leaderless/permissionless invariant "no single node can sign or
extract a key".

Orphan-safe: this path was incomplete (no proof/sign-off doc unlike
cmp/frost; no deployment manifest provisions validator-BLS; BLS keygen
had no dispatch caller) and NOT live custody. A validator's real BLS
consensus key is its own NATIVE per-validator crypto/bls key (PoP +
aggregation, the consensus fast-path), held at the node — never
threshold-custodied here. Corona PQ-threshold custody (dealerless DKG)
stays. So a validator keyset = {native BLS (consensus, no custody) +
corona PQ-threshold (custodied)}.

Delete: bls_keygen_session.go, bls_signing_session.go, bls_config_marshal.go,
bls_session_test.go, signing_handler_bls.go. Remove KeyTypeBLS (mpc + types),
SessionTypeBLS, CreateBLS{KeyGen,Sign}Session, the BLS signing/keytype
dispatch cases, the keygen-result BLSPubKey field, and the bls: key-share
prefix. handlers_kms.go: drop the BLS-custody half of ValidatorKeySet and
the generate/sign/rotate BLS branches; KMS custodies corona only.

Verified: GOWORK=off go build ./... + go vet green; pkg/mpc, pkg/api,
pkg/types tests pass. Zero residual refs to deleted BLS symbols.
This commit is contained in:
2026-06-26 18:33:48 -07:00
parent f6abc3b691
commit 5c4d5cbdb0
14 changed files with 15 additions and 1358 deletions
+12 -21
View File
@@ -14,12 +14,15 @@ import (
"github.com/go-chi/chi/v5"
)
// ValidatorKeySet holds MPC wallet references for a validator's BLS and Corona keys.
// ValidatorKeySet holds the MPC wallet reference for a validator's custodied
// Corona PQ-threshold key. The validator's BLS consensus key is deliberately
// NOT here: BLS is consensus-only and lives as the validator's own native
// per-validator crypto/bls key (PoP + signature aggregation, the consensus
// fast-path), never threshold-custodied by this service. Only the Corona
// PQ-threshold key is custodied (dealerless DKG).
type ValidatorKeySet struct {
ValidatorID string `json:"validatorId"`
BLSWalletID string `json:"blsWalletId"`
CoronaWalletID string `json:"coronaWalletId"`
BLSPublicKey string `json:"blsPublicKey"`
CoronaPublicKey string `json:"coronaPublicKey"`
Threshold int `json:"threshold"`
Parties int `json:"parties"`
@@ -89,8 +92,11 @@ func (s *Server) handleKMSGenerate(w http.ResponseWriter, r *http.Request) {
}
}
blsWalletID := fmt.Sprintf("validator-%s-bls", req.ValidatorID)
blsResult, err := s.mpc.TriggerKeygen(orgID, blsWalletID)
// Custody only the Corona PQ-threshold key (dealerless DKG). The validator's
// BLS consensus key is native (held by the validator's node), never
// threshold-custodied here.
coronaWalletID := fmt.Sprintf("validator-%s-corona", req.ValidatorID)
coronaResult, err := s.mpc.TriggerKeygen(orgID, coronaWalletID)
if err != nil {
if strings.Contains(err.Error(), "already exists") {
writeError(w, http.StatusConflict, err.Error())
@@ -100,19 +106,10 @@ func (s *Server) handleKMSGenerate(w http.ResponseWriter, r *http.Request) {
return
}
coronaWalletID := fmt.Sprintf("validator-%s-corona", req.ValidatorID)
coronaResult, err := s.mpc.TriggerKeygen(orgID, coronaWalletID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
now := time.Now().UTC()
ks := &ValidatorKeySet{
ValidatorID: req.ValidatorID,
BLSWalletID: blsResult.WalletID,
CoronaWalletID: coronaResult.WalletID,
BLSPublicKey: blsResult.ECDSAPubKey,
CoronaPublicKey: coronaResult.EDDSAPubKey,
Threshold: req.Threshold,
Parties: req.Parties,
@@ -186,12 +183,10 @@ func (s *Server) handleKMSSign(w http.ResponseWriter, r *http.Request) {
var walletID string
switch req.KeyType {
case "bls":
walletID = ks.BLSWalletID
case "corona":
walletID = ks.CoronaWalletID
default:
writeError(w, http.StatusBadRequest, "key_type must be 'bls' or 'corona'")
writeError(w, http.StatusBadRequest, "key_type must be 'corona'")
return
}
@@ -229,10 +224,6 @@ func (s *Server) handleKMSRotate(w http.ResponseWriter, r *http.Request) {
return
}
if err := s.mpc.TriggerReshare(orgID, ks.BLSWalletID, req.NewThreshold, req.NewParticipants); err != nil {
writeError(w, http.StatusInternalServerError, fmt.Sprintf("bls reshare: %v", err))
return
}
if err := s.mpc.TriggerReshare(orgID, ks.CoronaWalletID, req.NewThreshold, req.NewParticipants); err != nil {
writeError(w, http.StatusInternalServerError, fmt.Sprintf("corona reshare: %v", err))
return
-1
View File
@@ -11,7 +11,6 @@ type KeygenResultEvent struct {
ECDSAPubKey []byte `json:"ecdsa_pub_key"`
EDDSAPubKey []byte `json:"eddsa_pub_key"`
SR25519PubKey []byte `json:"sr25519_pub_key,omitempty"`
BLSPubKey []byte `json:"bls_pub_key,omitempty"`
ResultType ResultType `json:"result_type"`
ErrorReason string `json:"error_reason"`
-4
View File
@@ -236,8 +236,6 @@ func (ec *eventConsumer) consumeTxSigningEvent() error {
ec.handleSigningEventFROST(&msg, natMsg)
case types.KeyTypeSR25519:
ec.handleSigningEventSR25519(&msg, natMsg)
case types.KeyTypeBLS:
ec.handleSigningEventBLS(&msg, natMsg)
default:
logger.Error("Unsupported key type for signing", nil,
"walletID", msg.WalletID,
@@ -637,8 +635,6 @@ func sessionTypeFromKeyType(keyType types.KeyType) (mpc.SessionType, error) {
return mpc.SessionTypeEDDSA, nil
case types.KeyTypeSR25519:
return mpc.SessionTypeSR25519, nil
case types.KeyTypeBLS:
return mpc.SessionTypeBLS, nil
default:
logger.Warn("Unsupported key type", "keyType", keyType)
return "", fmt.Errorf("unsupported key type: %v", keyType)
-126
View File
@@ -1,126 +0,0 @@
package eventconsumer
import (
"context"
"errors"
"fmt"
"time"
"github.com/nats-io/nats.go"
"github.com/luxfi/mpc/pkg/logger"
"github.com/luxfi/mpc/pkg/mpc"
"github.com/luxfi/mpc/pkg/types"
)
// handleSigningEventBLS handles signing events for BLS protocol (BLS12-381 threshold)
func (ec *eventConsumer) handleSigningEventBLS(msg *types.SignTxMessage, natMsg *nats.Msg) {
// Check for duplicate session and track if new
if ec.checkDuplicateSession(msg.OrgID, msg.WalletID, msg.TxID) {
duplicateErr := fmt.Errorf("duplicate signing request detected for walletID=%s txID=%s", msg.WalletID, msg.TxID)
ec.handleSigningSessionError(
msg.WalletID,
msg.TxID,
msg.NetworkInternalCode,
duplicateErr,
"Duplicate session",
natMsg,
)
return
}
// Get key info to determine signers
keyInfo, err := ec.keyinfoStore.Get(msg.WalletID)
if err != nil {
ec.handleSigningSessionError(
msg.WalletID,
msg.TxID,
msg.NetworkInternalCode,
err,
"Failed to get key info",
natMsg,
)
return
}
// Create BLS signing session
session, err := ec.node.CreateBLSSignSession(
msg.TxID, // Use TxID as sessionID
msg.WalletID,
msg.Tx, // Use transaction bytes as message hash
keyInfo.ParticipantPeerIDs, // Use all participants as signers
ec.signingResultQueue,
msg.OrgID,
)
if err != nil {
// Check if the error is due to node not being in participant list
if errors.Is(err, mpc.ErrNotInParticipantList) {
logger.Info("Node is not in participant list for this wallet, skipping signing",
"walletID", msg.WalletID,
"txID", msg.TxID,
"nodeID", ec.node.ID(),
)
return // Skip signing instead of treating as error
}
logger.Error("Failed to create BLS signing session", err)
ec.handleSigningSessionError(
msg.WalletID,
msg.TxID,
msg.NetworkInternalCode,
err,
"Failed to create signing session",
natMsg,
)
return
}
// Mark session as already processed
ec.addSession(msg.OrgID, msg.WalletID, msg.TxID)
// Initialize the session
session.Init()
ctx, done := context.WithCancel(context.Background())
// Monitor for errors
go func() {
for {
select {
case <-ctx.Done():
return
case err := <-session.ErrChan():
if err != nil {
ec.handleSigningSessionError(
msg.WalletID,
msg.TxID,
msg.NetworkInternalCode,
err,
"Failed to sign tx",
natMsg,
)
return
}
}
}
}()
// Start listening to incoming messages
session.ListenToIncomingMessageAsync()
// Small delay to ensure all nodes are ready
time.Sleep(DefaultSessionStartupDelay * time.Millisecond)
// Start processing outbound messages
go session.ProcessOutboundMessage()
// Wait for completion
go func() {
logger.Info("Waiting for BLS signing session to finish", "sessionID", msg.TxID, "walletID", msg.WalletID)
result := session.WaitForFinish()
logger.Info("BLS signing session WaitForFinish returned", "sessionID", msg.TxID, "result", result)
done()
ec.sendReplyToRemoveMsg(natMsg)
logger.Info("BLS signing session completed", "result", result)
}()
}
-108
View File
@@ -1,108 +0,0 @@
package mpc
import (
"fmt"
"github.com/fxamacker/cbor/v2"
"github.com/luxfi/crypto/bls"
"github.com/luxfi/threshold/pkg/party"
blsThreshold "github.com/luxfi/threshold/protocols/bls"
)
// blsConfigMarshal is the CBOR-friendly representation of a BLS threshold Config.
// MUST use CBOR: bls.PublicKey and bls.SecretKey do not have JSON marshalers.
type blsConfigMarshal struct {
ID party.ID
Threshold int
TotalParties int
PublicKey []byte // 48 bytes (compressed G1 point)
SecretShare []byte // 32 bytes (BLS secret key)
VerificationKeys []blsVerificationKeyMarshal
}
type blsVerificationKeyMarshal struct {
ID party.ID
Key []byte // 48 bytes (compressed G1 point)
}
// MarshalBLSConfig serializes a BLS threshold Config to CBOR bytes.
func MarshalBLSConfig(config *blsThreshold.Config) ([]byte, error) {
if config == nil {
return nil, fmt.Errorf("config is nil")
}
// Marshal public key
pkBytes := bls.PublicKeyToCompressedBytes(config.PublicKey)
if pkBytes == nil {
return nil, fmt.Errorf("failed to marshal public key")
}
// Marshal secret share
skBytes := bls.SecretKeyToBytes(config.SecretShare)
if skBytes == nil {
return nil, fmt.Errorf("failed to marshal secret share")
}
// Marshal verification keys
vks := make([]blsVerificationKeyMarshal, 0, len(config.VerificationKeys))
for id, pk := range config.VerificationKeys {
vkBytes := bls.PublicKeyToCompressedBytes(pk)
if vkBytes == nil {
return nil, fmt.Errorf("failed to marshal verification key for %s", id)
}
vks = append(vks, blsVerificationKeyMarshal{
ID: id,
Key: vkBytes,
})
}
cm := &blsConfigMarshal{
ID: config.ID,
Threshold: config.Threshold,
TotalParties: config.TotalParties,
PublicKey: pkBytes,
SecretShare: skBytes,
VerificationKeys: vks,
}
return cbor.Marshal(cm)
}
// UnmarshalBLSConfig deserializes CBOR bytes to a BLS threshold Config.
func UnmarshalBLSConfig(data []byte) (*blsThreshold.Config, error) {
cm := &blsConfigMarshal{}
if err := cbor.Unmarshal(data, cm); err != nil {
return nil, fmt.Errorf("failed to unmarshal BLS config: %w", err)
}
// Unmarshal public key
pk, err := bls.PublicKeyFromCompressedBytes(cm.PublicKey)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal public key: %w", err)
}
// Unmarshal secret share
sk, err := bls.SecretKeyFromBytes(cm.SecretShare)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal secret share: %w", err)
}
// Unmarshal verification keys
vks := make(map[party.ID]*bls.PublicKey, len(cm.VerificationKeys))
for _, vk := range cm.VerificationKeys {
key, err := bls.PublicKeyFromCompressedBytes(vk.Key)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal verification key for %s: %w", vk.ID, err)
}
vks[vk.ID] = key
}
return &blsThreshold.Config{
ID: cm.ID,
Threshold: cm.Threshold,
TotalParties: cm.TotalParties,
PublicKey: pk,
SecretShare: sk,
VerificationKeys: vks,
}, nil
}
-404
View File
@@ -1,404 +0,0 @@
package mpc
import (
"context"
"encoding/json"
"fmt"
"sort"
"sync"
"github.com/luxfi/crypto/bls"
"github.com/luxfi/threshold/pkg/party"
blsThreshold "github.com/luxfi/threshold/protocols/bls"
"github.com/nats-io/nats.go"
"github.com/rs/zerolog"
"github.com/luxfi/mpc/pkg/event"
"github.com/luxfi/mpc/pkg/identity"
"github.com/luxfi/mpc/pkg/keyinfo"
"github.com/luxfi/mpc/pkg/kvstore"
"github.com/luxfi/mpc/pkg/messaging"
"github.com/luxfi/mpc/pkg/types"
"github.com/luxfi/mpc/pkg/utils"
)
// blsShareMessage is sent from the dealer to each party with their config share.
type blsShareMessage struct {
SecretShare []byte `json:"secret_share"`
PublicKey []byte `json:"public_key"`
Threshold int `json:"threshold"`
TotalParties int `json:"total_parties"`
VerificationKeys map[party.ID][]byte `json:"verification_keys"`
}
// BLSKeygenSession interface for BLS keygen
type BLSKeygenSession interface {
Session
// GetPublicKey returns the BLS group public key after keygen completes
GetPublicKey() []byte
}
type blsKeygenSession struct {
session
config *blsThreshold.Config
resultMutex sync.Mutex
done bool
resultErr error
}
func newBLSKeygenSession(
walletID string,
pubSub messaging.PubSub,
selfPartyID party.ID,
partyIDs []party.ID,
threshold int,
kvstore kvstore.KVStore,
keyinfoStore keyinfo.Store,
resultQueue messaging.MessageQueue,
identityStore identity.Store,
orgID string,
) *blsKeygenSession {
return &blsKeygenSession{
session: session{
walletID: walletID,
orgID: orgID,
pubSub: pubSub,
selfPartyID: selfPartyID,
partyIDs: partyIDs,
subscriberList: []messaging.Subscription{},
rounds: 1, // BLS keygen is single-round (dealer distributes)
outCh: make(chan msg, 100),
errCh: make(chan error, 10),
finishCh: make(chan bool, 1),
externalFinishChan: make(chan string, 1),
threshold: threshold,
kvstore: kvstore,
keyinfoStore: keyinfoStore,
resultQueue: resultQueue,
logger: zerolog.New(utils.ZerologConsoleWriter()).With().Timestamp().Logger(),
processing: newDedupMap(),
processingLock: sync.Mutex{},
topicComposer: &TopicComposer{
ComposeBroadcastTopic: func() string {
return fmt.Sprintf("keygen:broadcast:bls:%s", walletID)
},
ComposeDirectTopic: func(nodeID string) string {
return fmt.Sprintf("keygen:direct:bls:%s:%s", nodeID, walletID)
},
},
identityStore: identityStore,
},
done: false,
}
}
// isDealer returns true if this node is the dealer (lowest sorted party ID).
func (s *blsKeygenSession) isDealer() bool {
sorted := make([]string, len(s.partyIDs))
for i, id := range s.partyIDs {
sorted[i] = string(id)
}
sort.Strings(sorted)
return sorted[0] == string(s.selfPartyID)
}
// ListenToIncomingMessageAsync subscribes to BLS keygen messages
func (s *blsKeygenSession) ListenToIncomingMessageAsync() {
// Subscribe to direct messages (non-dealer nodes receive their share here)
directTopic := s.topicComposer.ComposeDirectTopic(extractNodeID(string(s.selfPartyID)))
directSub, err := s.pubSub.Subscribe(directTopic, func(m *nats.Msg) {
s.logger.Debug().
Str("topic", directTopic).
Int("size", len(m.Data)).
Msg("BLS: Received direct message")
s.ProcessInboundMessage(m.Data)
})
if err != nil {
s.logger.Error().Err(err).Msgf("BLS: Failed to subscribe to direct topic %s", directTopic)
s.errCh <- err
return
}
s.subscriberList = append(s.subscriberList, directSub)
s.logger.Info().
Str("direct", directTopic).
Bool("isDealer", s.isDealer()).
Msg("BLS: Listening to incoming messages")
}
func (s *blsKeygenSession) Init() {
s.logger.Info().
Str("walletID", s.walletID).
Int("threshold", s.threshold).
Int("partyCount", len(s.partyIDs)).
Str("selfPartyID", string(s.selfPartyID)).
Bool("isDealer", s.isDealer()).
Msg("[BLS] Initializing BLS keygen session")
}
func (s *blsKeygenSession) ProcessInboundMessage(msgBytes []byte) {
s.processingLock.Lock()
defer s.processingLock.Unlock()
// Unmarshal wire format
inboundMessage := &types.Message{}
if err := json.Unmarshal(msgBytes, inboundMessage); err != nil {
s.logger.Error().Err(err).Msg("BLS: ProcessInboundMessage unmarshal error")
return
}
// Verify Ed25519 signature on the wire message
if err := s.verifyInboundSignature(inboundMessage); err != nil {
s.logger.Warn().Err(err).Str("sender", inboundMessage.SenderNodeID).Msg("Dropping message with invalid signature")
return
}
// Deduplication
msgHashStr := fmt.Sprintf("%x", utils.GetMessageHash(inboundMessage.Body))
if s.processing.seen(msgHashStr) {
return
}
// Parse the share message
var shareMsg blsShareMessage
if err := json.Unmarshal(inboundMessage.Body, &shareMsg); err != nil {
s.logger.Error().Err(err).Msg("BLS: Failed to unmarshal share message")
return
}
// Reconstruct the BLS config from the share message
sk, err := bls.SecretKeyFromBytes(shareMsg.SecretShare)
if err != nil {
s.logger.Error().Err(err).Msg("BLS: Failed to unmarshal secret share")
s.errCh <- err
return
}
pk, err := bls.PublicKeyFromCompressedBytes(shareMsg.PublicKey)
if err != nil {
s.logger.Error().Err(err).Msg("BLS: Failed to unmarshal public key")
s.errCh <- err
return
}
vks := make(map[party.ID]*bls.PublicKey, len(shareMsg.VerificationKeys))
for id, vkBytes := range shareMsg.VerificationKeys {
vk, err := bls.PublicKeyFromCompressedBytes(vkBytes)
if err != nil {
s.logger.Error().Err(err).Str("partyID", string(id)).Msg("BLS: Failed to unmarshal verification key")
s.errCh <- err
return
}
vks[id] = vk
}
s.resultMutex.Lock()
s.config = blsThreshold.NewConfig(
s.selfPartyID,
shareMsg.Threshold,
shareMsg.TotalParties,
sk,
pk,
vks,
)
s.done = true
s.resultMutex.Unlock()
s.finishCh <- true
}
func (s *blsKeygenSession) ProcessOutboundMessage() {
s.logger.Info().Msgf("BLS: ProcessOutboundMessage started: %s", s.walletID)
if s.isDealer() {
// Dealer: generate shares and distribute
s.runDealer()
}
// Wait for finish (dealer finishes after distributing, non-dealer after receiving)
select {
case <-s.finishCh:
s.logger.Info().Msg("BLS: Received finish message during ProcessOutboundMessage")
s.publishResult()
return
case err := <-s.errCh:
s.logger.Error().Err(err).Msg("BLS: Received error during ProcessOutboundMessage")
s.resultMutex.Lock()
s.resultErr = err
s.resultMutex.Unlock()
s.publishResult()
return
}
}
func (s *blsKeygenSession) runDealer() {
ctx := context.Background()
dealer := &blsThreshold.TrustedDealer{
Threshold: s.threshold,
TotalParties: len(s.partyIDs),
}
shares, groupPK, err := dealer.GenerateShares(ctx, s.partyIDs)
if err != nil {
s.logger.Error().Err(err).Msg("BLS: Dealer failed to generate shares")
s.resultMutex.Lock()
s.resultErr = err
s.resultMutex.Unlock()
s.finishCh <- true
return
}
// Get verification keys
vks := blsThreshold.GetVerificationKeys(shares)
// Serialize verification keys once
vkBytes := make(map[party.ID][]byte, len(vks))
for id, vk := range vks {
vkBytes[id] = bls.PublicKeyToCompressedBytes(vk)
}
pkBytes := bls.PublicKeyToCompressedBytes(groupPK)
// Send each party its share
for _, partyID := range s.partyIDs {
sk := shares[partyID]
shareMsg := blsShareMessage{
SecretShare: bls.SecretKeyToBytes(sk),
PublicKey: pkBytes,
Threshold: s.threshold,
TotalParties: len(s.partyIDs),
VerificationKeys: vkBytes,
}
body, err := json.Marshal(shareMsg)
if err != nil {
s.logger.Error().Err(err).Str("partyID", string(partyID)).Msg("BLS: Failed to marshal share for party")
s.resultMutex.Lock()
s.resultErr = err
s.resultMutex.Unlock()
s.finishCh <- true
return
}
if partyID == s.selfPartyID {
// Dealer stores its own config directly
s.resultMutex.Lock()
s.config = blsThreshold.NewConfig(
s.selfPartyID,
s.threshold,
len(s.partyIDs),
sk,
groupPK,
vks,
)
s.done = true
s.resultMutex.Unlock()
continue
}
// Send to other parties via direct message
wireMsg := &types.Message{
SessionID: s.walletID,
SenderID: string(s.selfPartyID),
RecipientIDs: []string{string(partyID)},
Body: body,
IsBroadcast: false,
}
s.sendMsg(wireMsg)
s.logger.Info().
Str("partyID", string(partyID)).
Msg("BLS: Sent share to party")
}
// Dealer is done
s.finishCh <- true
}
func (s *blsKeygenSession) publishResult() {
s.resultMutex.Lock()
defer s.resultMutex.Unlock()
if s.resultErr != nil {
s.logger.Error().Err(s.resultErr).Msg("BLS: keygen failed with error")
failureEvent := event.CreateKeygenFailure(
s.walletID,
map[string]any{
"error": s.resultErr.Error(),
"protocol": "BLS",
},
)
evtData, _ := json.Marshal(failureEvent)
if err := s.resultQueue.Enqueue(fmt.Sprintf("mpc.mpc_keygen_result.%s", s.walletID), evtData, nil); err != nil {
s.logger.Error().Err(err).Msg("BLS: failed to publish keygen failure event")
}
// Always send to externalFinishChan so WaitForFinish() doesn't block forever
s.externalFinishChan <- ""
return
}
if s.config == nil {
s.logger.Error().Msg("BLS: No config available after keygen completion")
s.externalFinishChan <- ""
return
}
// Save key share with bls prefix using CBOR
shareBytes, err := MarshalBLSConfig(s.config)
if err != nil {
s.logger.Error().Err(err).Msg("BLS: Failed to marshal key share")
s.externalFinishChan <- ""
return
}
blsKey := fmt.Sprintf("bls:%s", s.walletID)
if err := s.kvstore.Put(OrgScopedKey(s.orgID, blsKey), shareBytes); err != nil {
s.logger.Error().Err(err).Msgf("BLS: Failed to save key share for wallet %s", s.walletID)
s.externalFinishChan <- ""
return
}
// Get public key hex
var pubKeyHex string
pkBytes := bls.PublicKeyToCompressedBytes(s.config.PublicKey)
if len(pkBytes) > 0 {
pubKeyHex = fmt.Sprintf("%x", pkBytes)
s.logger.Info().
Int("configPubKeyLen", len(pkBytes)).
Str("pubKeyHex", pubKeyHex).
Msg("[BLS-PUBLISH] PublicKey available")
} else {
s.logger.Warn().Msg("[BLS-PUBLISH] PublicKey is empty!")
}
// Notify via external finish channel
s.externalFinishChan <- pubKeyHex
s.logger.Info().
Str("walletID", s.walletID).
Str("publicKey", pubKeyHex).
Msg("BLS keygen completed successfully")
}
func (s *blsKeygenSession) Stop() {
close(s.outCh)
close(s.errCh)
}
func (s *blsKeygenSession) WaitForFinish() string {
return <-s.externalFinishChan
}
// GetPublicKey returns the BLS group public key after keygen completes
func (s *blsKeygenSession) GetPublicKey() []byte {
s.resultMutex.Lock()
defer s.resultMutex.Unlock()
if s.config != nil && s.config.PublicKey != nil {
return bls.PublicKeyToCompressedBytes(s.config.PublicKey)
}
return nil
}
-219
View File
@@ -1,219 +0,0 @@
package mpc
import (
"context"
"testing"
"github.com/luxfi/crypto/bls"
"github.com/luxfi/threshold/pkg/party"
blsThreshold "github.com/luxfi/threshold/protocols/bls"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestKeyTypeBLSConstant(t *testing.T) {
assert.Equal(t, KeyType("bls"), KeyTypeBLS)
}
func TestSessionTypeBLSConstant(t *testing.T) {
assert.Equal(t, SessionType("bls"), SessionTypeBLS)
}
func TestBLSConfigMarshalRoundtrip(t *testing.T) {
ctx := context.Background()
partyA := party.ID("node-a")
partyB := party.ID("node-b")
partyC := party.ID("node-c")
parties := []party.ID{partyA, partyB, partyC}
// Generate real BLS threshold shares
dealer := &blsThreshold.TrustedDealer{
Threshold: 2,
TotalParties: 3,
}
shares, groupPK, err := dealer.GenerateShares(ctx, parties)
require.NoError(t, err)
require.NotNil(t, groupPK)
vks := blsThreshold.GetVerificationKeys(shares)
// Create config for party A
config := blsThreshold.NewConfig(partyA, 2, 3, shares[partyA], groupPK, vks)
require.NotNil(t, config)
// Marshal
data, err := MarshalBLSConfig(config)
require.NoError(t, err)
require.NotEmpty(t, data)
// Unmarshal
config2, err := UnmarshalBLSConfig(data)
require.NoError(t, err)
require.NotNil(t, config2)
// Verify roundtrip
assert.Equal(t, config.ID, config2.ID)
assert.Equal(t, config.Threshold, config2.Threshold)
assert.Equal(t, config.TotalParties, config2.TotalParties)
// Verify public key roundtrip
pkBytes1 := bls.PublicKeyToCompressedBytes(config.PublicKey)
pkBytes2 := bls.PublicKeyToCompressedBytes(config2.PublicKey)
assert.Equal(t, pkBytes1, pkBytes2)
// Verify secret share roundtrip
skBytes1 := bls.SecretKeyToBytes(config.SecretShare)
skBytes2 := bls.SecretKeyToBytes(config2.SecretShare)
assert.Equal(t, skBytes1, skBytes2)
// Verify verification keys roundtrip
for id, vk := range config.VerificationKeys {
vk2, ok := config2.VerificationKeys[id]
require.True(t, ok, "missing verification key for %s", id)
vkBytes1 := bls.PublicKeyToCompressedBytes(vk)
vkBytes2 := bls.PublicKeyToCompressedBytes(vk2)
assert.Equal(t, vkBytes1, vkBytes2, "verification key mismatch for %s", id)
}
}
func TestBLSConfigMarshalNilConfig(t *testing.T) {
_, err := MarshalBLSConfig(nil)
assert.Error(t, err)
assert.Contains(t, err.Error(), "config is nil")
}
func TestBLSConfigUnmarshalInvalidData(t *testing.T) {
_, err := UnmarshalBLSConfig([]byte("invalid cbor data"))
assert.Error(t, err)
}
func TestBLSSignAndVerifyWithMarshaledConfig(t *testing.T) {
ctx := context.Background()
parties := []party.ID{
party.ID("party1"),
party.ID("party2"),
party.ID("party3"),
}
// Generate threshold shares
dealer := &blsThreshold.TrustedDealer{
Threshold: 2,
TotalParties: 3,
}
shares, groupPK, err := dealer.GenerateShares(ctx, parties)
require.NoError(t, err)
vks := blsThreshold.GetVerificationKeys(shares)
// Create configs, marshal/unmarshal each one (simulating storage roundtrip)
configs := make(map[party.ID]*blsThreshold.Config, 3)
for _, id := range parties {
original := blsThreshold.NewConfig(id, 2, 3, shares[id], groupPK, vks)
// Marshal and unmarshal to simulate BadgerDB storage
data, err := MarshalBLSConfig(original)
require.NoError(t, err)
restored, err := UnmarshalBLSConfig(data)
require.NoError(t, err)
configs[id] = restored
}
// Sign with each party
message := []byte("threshold BLS signing test via CBOR roundtrip")
sigShares := make([]*blsThreshold.SignatureShare, 0, 3)
for _, id := range parties {
share, err := configs[id].Sign(message)
require.NoError(t, err, "party %s signing failed", id)
require.NotNil(t, share.Signature)
sigShares = append(sigShares, share)
}
// Verify partial signatures
for _, share := range sigShares {
valid := configs[parties[0]].VerifyPartialSignature(share, message)
require.True(t, valid, "partial signature from %s should be valid", share.PartyID)
}
// Aggregate with threshold (2 of 3)
aggSig, err := blsThreshold.AggregateSignatures(sigShares[:2], 2)
require.NoError(t, err)
require.NotNil(t, aggSig)
// Verify aggregate signature
valid := configs[parties[0]].VerifyAggregateSignature(message, aggSig)
require.True(t, valid, "aggregate signature should verify against group key")
// Wrong message should fail
require.False(t, configs[parties[0]].VerifyAggregateSignature([]byte("wrong message"), aggSig))
}
func TestBLSKeygenSessionCreation(t *testing.T) {
walletID := "test-wallet-bls"
partyIDs := []party.ID{"node-a", "node-b", "node-c"}
selfPartyID := party.ID("node-a")
session := newBLSKeygenSession(
walletID,
nil, // pubSub - nil for unit test
selfPartyID,
partyIDs,
2, // threshold
nil, // kvstore
nil, // keyinfoStore
nil, // resultQueue
nil, // identityStore
"", // orgID
)
require.NotNil(t, session)
assert.Equal(t, walletID, session.walletID)
assert.Equal(t, selfPartyID, session.selfPartyID)
assert.Equal(t, 2, session.threshold)
assert.Equal(t, 1, session.rounds) // BLS keygen is single-round
assert.False(t, session.done)
assert.Nil(t, session.config)
assert.Nil(t, session.resultErr)
}
func TestBLSKeygenSessionIsDealer(t *testing.T) {
partyIDs := []party.ID{"node-c", "node-a", "node-b"}
// node-a is the smallest sorted, so it should be the dealer
sessionA := newBLSKeygenSession(
"wallet1",
nil,
party.ID("node-a"),
partyIDs,
2,
nil, nil, nil, nil, "",
)
assert.True(t, sessionA.isDealer())
// node-b is not the smallest
sessionB := newBLSKeygenSession(
"wallet1",
nil,
party.ID("node-b"),
partyIDs,
2,
nil, nil, nil, nil, "",
)
assert.False(t, sessionB.isDealer())
// node-c is not the smallest
sessionC := newBLSKeygenSession(
"wallet1",
nil,
party.ID("node-c"),
partyIDs,
2,
nil, nil, nil, nil, "",
)
assert.False(t, sessionC.isDealer())
}
-384
View File
@@ -1,384 +0,0 @@
package mpc
import (
"encoding/hex"
"encoding/json"
"fmt"
"sync"
"github.com/luxfi/crypto/bls"
"github.com/luxfi/threshold/pkg/party"
blsThreshold "github.com/luxfi/threshold/protocols/bls"
"github.com/nats-io/nats.go"
"github.com/rs/zerolog"
"github.com/luxfi/mpc/pkg/encoding"
"github.com/luxfi/mpc/pkg/event"
"github.com/luxfi/mpc/pkg/identity"
"github.com/luxfi/mpc/pkg/keyinfo"
"github.com/luxfi/mpc/pkg/kvstore"
"github.com/luxfi/mpc/pkg/messaging"
"github.com/luxfi/mpc/pkg/types"
"github.com/luxfi/mpc/pkg/utils"
)
// blsSignShareMessage is broadcast by each signer with their partial signature.
type blsSignShareMessage struct {
PartyID party.ID `json:"party_id"`
Signature []byte `json:"signature"` // BLS signature bytes (96 bytes, compressed G2)
}
// BLSSignSession is the interface for BLS signing sessions
type BLSSignSession interface {
Session
}
type blsSigningSession struct {
session
config *blsThreshold.Config
messageHash []byte
signerIDs []party.ID
resultMutex sync.Mutex
done bool
resultErr error
// Collected signature shares from all signers
shares []*blsThreshold.SignatureShare
sharesLock sync.Mutex
}
func newBLSSigningSession(
sessionID string,
walletID string,
messageHash []byte,
pubSub messaging.PubSub,
selfPartyID party.ID,
signerIDs []party.ID,
kvstore kvstore.KVStore,
keyinfoStore keyinfo.Store,
resultQueue messaging.MessageQueue,
identityStore identity.Store,
orgID string,
) (*blsSigningSession, error) {
// Load and unmarshal key share inside withSecretErasure so that raw
// share bytes on the stack are zeroed after parsing completes.
blsKey := fmt.Sprintf("bls:%s", walletID)
var config *blsThreshold.Config
var loadErr error
withSecretErasure(func() {
shareBytes, err := GetKeyShareWithFallback(kvstore, orgID, blsKey)
if err != nil {
loadErr = fmt.Errorf("failed to get BLS key share: %w", err)
return
}
defer func() {
for i := range shareBytes {
shareBytes[i] = 0
}
}()
config, err = UnmarshalBLSConfig(shareBytes)
if err != nil {
loadErr = fmt.Errorf("failed to unmarshal BLS key share: %w", err)
}
})
if loadErr != nil {
return nil, loadErr
}
return &blsSigningSession{
session: session{
walletID: walletID,
sessionID: sessionID,
pubSub: pubSub,
selfPartyID: selfPartyID,
partyIDs: signerIDs,
subscriberList: []messaging.Subscription{},
rounds: 1, // BLS signing is single-round (sign + aggregate)
outCh: make(chan msg, 100),
errCh: make(chan error, 10),
finishCh: make(chan bool, 1),
externalFinishChan: make(chan string, 1),
threshold: config.Threshold,
kvstore: kvstore,
keyinfoStore: keyinfoStore,
resultQueue: resultQueue,
logger: zerolog.New(utils.ZerologConsoleWriter()).With().Timestamp().Logger(),
processing: newDedupMap(),
processingLock: sync.Mutex{},
topicComposer: &TopicComposer{
ComposeBroadcastTopic: func() string {
return fmt.Sprintf("sign:broadcast:bls:%s", sessionID)
},
ComposeDirectTopic: func(nodeID string) string {
return fmt.Sprintf("sign:direct:bls:%s:%s", nodeID, sessionID)
},
},
identityStore: identityStore,
},
config: config,
messageHash: messageHash,
signerIDs: signerIDs,
shares: make([]*blsThreshold.SignatureShare, 0, len(signerIDs)),
done: false,
}, nil
}
// ListenToIncomingMessageAsync subscribes to BLS signing messages
func (s *blsSigningSession) ListenToIncomingMessageAsync() {
// Subscribe to broadcast messages (all signers broadcast their partial sig)
broadcastTopic := s.topicComposer.ComposeBroadcastTopic()
broadcastSub, err := s.pubSub.Subscribe(broadcastTopic, func(m *nats.Msg) {
s.logger.Debug().
Str("topic", broadcastTopic).
Int("size", len(m.Data)).
Msg("BLS: Received broadcast message")
s.ProcessInboundMessage(m.Data)
})
if err != nil {
s.logger.Error().Err(err).Msgf("BLS: Failed to subscribe to broadcast topic %s", broadcastTopic)
s.errCh <- err
return
}
s.subscriberList = append(s.subscriberList, broadcastSub)
s.logger.Info().
Str("broadcast", broadcastTopic).
Msg("BLS: Listening to incoming signing messages")
}
func (s *blsSigningSession) Init() {
s.logger.Info().
Str("sessionID", s.sessionID).
Str("walletID", s.walletID).
Hex("messageHash", s.messageHash).
Interface("signerIDs", s.signerIDs).
Msg("Initializing BLS signing session")
}
func (s *blsSigningSession) ProcessInboundMessage(msgBytes []byte) {
s.processingLock.Lock()
defer s.processingLock.Unlock()
// Unmarshal wire format
inboundMessage := &types.Message{}
if err := json.Unmarshal(msgBytes, inboundMessage); err != nil {
s.logger.Error().Err(err).Msg("BLS: ProcessInboundMessage unmarshal error")
return
}
// Verify Ed25519 signature on the wire message
if err := s.verifyInboundSignature(inboundMessage); err != nil {
s.logger.Warn().Err(err).Str("sender", inboundMessage.SenderNodeID).Msg("Dropping message with invalid signature")
return
}
// Deduplication
msgHashStr := fmt.Sprintf("%x", utils.GetMessageHash(inboundMessage.Body))
if s.processing.seen(msgHashStr) {
return
}
// Parse the signature share message
var shareMsg blsSignShareMessage
if err := json.Unmarshal(inboundMessage.Body, &shareMsg); err != nil {
s.logger.Error().Err(err).Msg("BLS: Failed to unmarshal signature share")
return
}
// Reconstruct the BLS signature
sig, err := bls.SignatureFromBytes(shareMsg.Signature)
if err != nil {
s.logger.Error().Err(err).Str("partyID", string(shareMsg.PartyID)).Msg("BLS: Invalid signature share")
return
}
// Verify the partial signature before accepting
share := &blsThreshold.SignatureShare{
PartyID: shareMsg.PartyID,
Signature: sig,
}
if !s.config.VerifyPartialSignature(share, s.messageHash) {
s.logger.Warn().
Str("partyID", string(shareMsg.PartyID)).
Msg("BLS: Partial signature verification failed, rejecting")
return
}
s.sharesLock.Lock()
s.shares = append(s.shares, share)
shareCount := len(s.shares)
s.sharesLock.Unlock()
s.logger.Info().
Str("partyID", string(shareMsg.PartyID)).
Int("shareCount", shareCount).
Int("threshold", s.config.Threshold).
Msg("BLS: Accepted verified signature share")
// Check if we have enough shares to aggregate
if shareCount >= s.config.Threshold {
s.resultMutex.Lock()
if !s.done {
s.done = true
s.resultMutex.Unlock()
s.finishCh <- true
} else {
s.resultMutex.Unlock()
}
}
}
func (s *blsSigningSession) ProcessOutboundMessage() {
s.logger.Info().Msgf("BLS ProcessOutboundMessage started: %s", s.sessionID)
// Sign locally with our share and broadcast
share, err := s.config.Sign(s.messageHash)
if err != nil {
s.logger.Error().Err(err).Msg("BLS: Failed to create partial signature")
s.resultMutex.Lock()
s.resultErr = err
s.resultMutex.Unlock()
s.publishResult()
return
}
// Add our own share
s.sharesLock.Lock()
s.shares = append(s.shares, share)
shareCount := len(s.shares)
s.sharesLock.Unlock()
// Broadcast our signature share
shareMsg := blsSignShareMessage{
PartyID: s.selfPartyID,
Signature: bls.SignatureToBytes(share.Signature),
}
body, err := json.Marshal(shareMsg)
if err != nil {
s.logger.Error().Err(err).Msg("BLS: Failed to marshal signature share")
s.resultMutex.Lock()
s.resultErr = err
s.resultMutex.Unlock()
s.publishResult()
return
}
wireMsg := &types.Message{
SessionID: s.sessionID,
SenderID: string(s.selfPartyID),
Body: body,
IsBroadcast: true,
}
s.sendMsg(wireMsg)
s.logger.Info().
Str("partyID", string(s.selfPartyID)).
Int("shareCount", shareCount).
Msg("BLS: Broadcast our signature share")
// Check if our share alone meets threshold (unlikely but possible for t=1)
if shareCount >= s.config.Threshold {
s.resultMutex.Lock()
if !s.done {
s.done = true
s.resultMutex.Unlock()
s.finishCh <- true
} else {
s.resultMutex.Unlock()
}
}
// Wait for enough shares to aggregate
select {
case <-s.finishCh:
s.logger.Info().Msg("BLS: Received finish - aggregating signatures")
s.publishResult()
return
case err := <-s.errCh:
s.logger.Error().Err(err).Msg("BLS: Received error during ProcessOutboundMessage")
s.resultMutex.Lock()
s.resultErr = err
s.resultMutex.Unlock()
s.publishResult()
return
}
}
func (s *blsSigningSession) publishResult() {
s.resultMutex.Lock()
defer s.resultMutex.Unlock()
if s.resultErr != nil {
failureEvent := event.CreateSignFailure(
s.sessionID,
s.walletID,
map[string]any{
"error": s.resultErr.Error(),
"protocol": "BLS",
},
)
evtData, _ := encoding.StructToJsonBytes(failureEvent)
if err := s.resultQueue.Enqueue(fmt.Sprintf("%s.%s", event.SigningResultTopicBase, s.walletID), evtData, nil); err != nil {
s.logger.Error().Err(err).Msg("BLS: failed to publish sign failure event")
}
s.externalFinishChan <- ""
return
}
// Aggregate collected shares
s.sharesLock.Lock()
shares := make([]*blsThreshold.SignatureShare, len(s.shares))
copy(shares, s.shares)
s.sharesLock.Unlock()
aggSig, err := blsThreshold.AggregateSignatures(shares, s.config.Threshold)
if err != nil {
s.logger.Error().Err(err).Msg("BLS: Failed to aggregate signatures")
s.externalFinishChan <- ""
return
}
// Verify the aggregated signature
if !s.config.VerifyAggregateSignature(s.messageHash, aggSig) {
s.logger.Error().Msg("BLS: Aggregated signature verification failed")
s.externalFinishChan <- ""
return
}
sigBytes := bls.SignatureToBytes(aggSig)
s.logger.Info().
Str("sessionID", s.sessionID).
Str("walletID", s.walletID).
Int("sigLen", len(sigBytes)).
Msg("BLS signing completed successfully")
// Create success event - use the Signature field (same as EdDSA pattern)
successEvent := event.SigningResultEvent{
ResultType: event.ResultTypeSuccess,
WalletID: s.walletID,
TxID: s.sessionID,
Signature: sigBytes,
}
evtData, _ := encoding.StructToJsonBytes(successEvent)
if err := s.resultQueue.Enqueue(fmt.Sprintf("%s.%s", event.SigningResultTopicBase, s.walletID), evtData, nil); err != nil {
s.logger.Error().Err(err).Msg("BLS: failed to publish sign success event")
}
// Send to externalFinishChan so WaitForFinish() unblocks
s.externalFinishChan <- hex.EncodeToString(sigBytes)
}
func (s *blsSigningSession) Stop() {
close(s.outCh)
close(s.errCh)
}
func (s *blsSigningSession) WaitForFinish() string {
return <-s.externalFinishChan
}
-1
View File
@@ -6,5 +6,4 @@ const (
KeyTypeSecp256k1 KeyType = "secp256k1"
KeyTypeEd25519 KeyType = "ed25519"
KeyTypeSR25519 KeyType = "sr25519"
KeyTypeBLS KeyType = "bls"
)
+2 -2
View File
@@ -181,7 +181,7 @@ func (k *KMSEnabledKVStore) Delete(key string) error {
// isKeyShare checks if a key represents an MPC key share that requires KMS encryption.
// Key shares are stored under org-scoped paths ("org:<orgID>:<walletID>") or with
// known key-type prefixes (frost:, bls:, sr25519:, tfhe:). Metadata keys that do not
// known key-type prefixes (frost:, sr25519:, tfhe:). Metadata keys that do not
// match these patterns pass through to regular storage unencrypted.
func isKeyShare(key string) bool {
// Org-scoped keys are always key shares
@@ -189,7 +189,7 @@ func isKeyShare(key string) bool {
return true
}
// Known key-type prefixed shares
for _, prefix := range []string{"frost:", "bls:", "sr25519:", "tfhe:"} {
for _, prefix := range []string{"frost:", "sr25519:", "tfhe:"} {
if strings.HasPrefix(key, prefix) {
return true
}
-85
View File
@@ -270,91 +270,6 @@ func (p *Node) CreateSR25519SignSession(
return session, nil
}
// CreateBLSKeyGenSession creates a BLS threshold keygen session
func (p *Node) CreateBLSKeyGenSession(
walletID string,
threshold int,
resultQueue messaging.MessageQueue,
orgID string,
) (BLSKeygenSession, error) {
if !p.peerRegistry.ArePeersReady() {
return nil, fmt.Errorf(
"peers are not ready yet. ready: %d, expected: %d",
p.peerRegistry.GetReadyPeersCount(),
len(p.peerIDs)+1,
)
}
readyPeerIDs := p.peerRegistry.GetReadyPeersIncludeSelf()
// BLS uses version 0 for raw party IDs without suffixes
selfPartyID, allPartyIDs := p.generatePartyIDs(PurposeKeygen, readyPeerIDs, 0)
session := newBLSKeygenSession(
walletID,
p.pubSub,
selfPartyID,
allPartyIDs,
threshold,
p.kvstore,
p.keyinfoStore,
resultQueue,
p.identityStore,
orgID,
)
// Note: Init() is called by the caller (keygen handler)
return session, nil
}
// CreateBLSSignSession creates a BLS threshold signing session
func (p *Node) CreateBLSSignSession(
sessionID string,
walletID string,
messageHash []byte,
signerPeerIDs []string,
resultQueue messaging.MessageQueue,
orgID string,
) (BLSSignSession, error) {
// Check if we have enough signers
keyInfo, err := p.keyinfoStore.Get(walletID)
if err != nil {
return nil, fmt.Errorf("failed to get key info: %w", err)
}
if len(signerPeerIDs) < keyInfo.Threshold+1 {
return nil, ErrNotEnoughParticipants
}
// Check if this node is in the signer list
if !contains(signerPeerIDs, p.nodeID) {
return nil, ErrNotInParticipantList
}
// Generate party IDs for signers
// BLS uses version 0 for raw party IDs without suffixes
selfPartyID, signerPartyIDs := p.generatePartyIDs(PurposeKeygen, signerPeerIDs, 0)
session, err := newBLSSigningSession(
sessionID,
walletID,
messageHash,
p.pubSub,
selfPartyID,
signerPartyIDs,
p.kvstore,
p.keyinfoStore,
resultQueue,
p.identityStore,
orgID,
)
if err != nil {
return nil, err
}
// Note: Init() is called by the caller (sign handler)
return session, nil
}
func (p *Node) CreateSignSession(
sessionID string,
walletID string,
-1
View File
@@ -100,7 +100,6 @@ const (
SessionTypeECDSA SessionType = "ecdsa"
SessionTypeEDDSA SessionType = "eddsa"
SessionTypeSR25519 SessionType = "sr25519"
SessionTypeBLS SessionType = "bls"
)
var (
+1 -1
View File
@@ -3,7 +3,7 @@
//
// The orchestrator is the integration point for the
// "MPC node signs only if it independently verifies the policy bundle"
// control. Every signing entry point — CGGMP21, FROST, LSS, BLS,
// control. Every signing entry point — CGGMP21, FROST, LSS,
// SR25519 — must call SignGate.AuthorizeSign before its session emits
// a share. Failure FAILS CLOSED: no fallback to "trust the coordinator".
//
-1
View File
@@ -8,7 +8,6 @@ const (
KeyTypeSecp256k1 KeyType = "secp256k1"
KeyTypeEd25519 KeyType = "ed25519"
KeyTypeSR25519 KeyType = "sr25519"
KeyTypeBLS KeyType = "bls"
)
// InitiatorMessage is anything that carries a payload to verify and its signature.