Files
zooqueen 861db0f621 a request nobody hears is not a quorum problem, and a secret needs a way in
Two halves of opening a secret were missing, and both showed up the first time
this ran against the real ring rather than an in-process one.

The request reached no party. Parties subscribed to "mpc.reveal_request.*" and
the requester published to "mpc.reveal_request.<session>". This transport
delivers by exact map lookup on the topic — it has no wildcards — so the
subscription matched nothing, every node stayed silent, and the opening failed
as "0 of 2 answered" with all three shares present and nobody having been asked.
That reads like a quorum fault and is a routing one. Both sides name one literal
now; the session rides in the body, where a reader wants it anyway. The pattern
came from the NATS-era topics next door, where wildcards do work.

And nothing could seal. mpcrek requires a sealed root to bring and no tool in
the estate produced one, so the protocol had no beginning. reveal.Seal takes the
published group key and a secret — no share, no node, no round, because writing
to the committee needs none of that — and `mpcd seal` is that from a shell, with
the secret on stdin so it never becomes an argument other processes can read.

Sealing to the identity is refused. It decodes as a valid point, so the decoder
lets it through, and every answer verifies against it: a secret sealed there is
readable by anyone. A test asked for that and the first version did not do it.
2026-08-21 00:57:48 -07:00

186 lines
6.3 KiB
Go

package main
// The two halves of an opening, from this node's side.
//
// serveReveal is what this node does when someone else asks: load the share,
// answer, say so if it cannot. TriggerReveal is what this node does when it is
// the one asking: publish, wait for a quorum, combine.
//
// Every node runs both, because any node may be asked and any node may ask.
// Neither half is privileged and neither holds anything the other does not —
// the asking node learns the secret because it is the one that wanted it, and
// learns nothing about any share.
import (
"context"
"encoding/json"
"fmt"
"sync"
"time"
"github.com/nats-io/nats.go"
"github.com/luxfi/mpc/pkg/event"
"github.com/luxfi/mpc/pkg/kvstore"
"github.com/luxfi/mpc/pkg/logger"
"github.com/luxfi/mpc/pkg/messaging"
"github.com/luxfi/mpc/pkg/reveal"
)
// revealDeadline bounds how long a request waits for its quorum. Long enough
// that a node under load still answers, short enough that a caller blocked on
// its own root key finds out rather than hanging: this runs at boot, and a
// process that cannot start should say so.
const revealDeadline = 20 * time.Second
// serveReveal answers opening requests for as long as ctx lives.
func (b *ConsensusMPCBackend) serveReveal(ctx context.Context) error {
return serveReveal(ctx, b.pubSub, b.factory.KVStore(), b.nodeID)
}
// serveReveal is the answering half, over the pieces it actually needs: a bus
// to hear on, a store to read a share from, and a name to answer under.
func serveReveal(ctx context.Context, bus messaging.PubSub, store kvstore.KVStore, nodeID string) error {
sub, err := bus.Subscribe(event.RevealRequestTopic, func(natMsg *nats.Msg) {
var req event.RevealRequest
if err := json.Unmarshal(natMsg.Data, &req); err != nil {
logger.Warn("reveal: unreadable request", "error", err.Error())
return
}
answerReveal(bus, store, nodeID, req)
})
if err != nil {
return fmt.Errorf("reveal: subscribe: %w", err)
}
go func() {
<-ctx.Done()
_ = sub.Unsubscribe()
}()
logger.Info("reveal: answering opening requests", "node", nodeID)
return nil
}
// answerReveal computes this node's contribution and publishes it.
//
// A node that holds no share for the key publishes that fact rather than
// staying quiet. Silence and refusal look identical to a caller counting
// answers, and they are not: one means wait longer, the other means never.
func answerReveal(bus messaging.PubSub, store kvstore.KVStore, nodeID string, req event.RevealRequest) {
out := event.RevealAnswer{SessionID: req.SessionID, PartyID: nodeID}
answer, err := reveal.Answer(store, req.OrgID, req.KeyID, req.Ciphertext)
if err != nil {
out.Error = err.Error()
logger.Info("reveal: cannot answer", "key", req.KeyID, "reason", err.Error())
} else {
out.Answer = answer
}
body, err := json.Marshal(out)
if err != nil {
return
}
topic := fmt.Sprintf("%s.%s", event.RevealAnswerTopicBase, req.SessionID)
if err := bus.Publish(topic, body); err != nil {
logger.Warn("reveal: cannot publish an answer", "error", err.Error())
}
}
// TriggerReveal opens ciphertext under the share set recorded for keyID.
//
// The ciphertext comes from the caller. This ring stores shares and answers
// with them; it holds nothing that needs opening, so there is no sealed
// material here to guard and a caller may keep its own wherever it likes.
func (b *ConsensusMPCBackend) TriggerReveal(orgID, keyID string, ciphertext []byte) ([]byte, error) {
return openReveal(b.pubSub, b.factory.KVStore(), b.nodeID, orgID, keyID, ciphertext)
}
// openReveal is the asking half, over the pieces it actually needs.
func openReveal(bus messaging.PubSub, store kvstore.KVStore, nodeID, orgID, keyID string, ciphertext []byte) ([]byte, error) {
if orgID == "" || keyID == "" {
return nil, fmt.Errorf("reveal: org and key are required")
}
if len(ciphertext) == 0 {
return nil, fmt.Errorf("reveal: nothing to open")
}
// How many answers this key needs, read from the share set before asking for
// any — so the wait is for a real quorum and not for whoever turns up.
quorum, err := reveal.Quorum(store, orgID, keyID)
if err != nil {
return nil, err
}
sessionID := fmt.Sprintf("reveal-%s-%d", nodeID, time.Now().UnixNano())
// One answer per party, counted under a lock. A party that answers twice —
// a redelivery, a duplicated subscription — must not count twice, or a
// quorum is reached by repetition rather than by agreement. Open refuses
// such a set anyway; this keeps the wait honest as well.
var (
mu sync.Mutex
answers = map[string][]byte{}
refusals []string
once sync.Once
done = make(chan struct{})
)
unsub, err := bus.Subscribe(fmt.Sprintf("%s.%s", event.RevealAnswerTopicBase, sessionID), func(natMsg *nats.Msg) {
var in event.RevealAnswer
if err := json.Unmarshal(natMsg.Data, &in); err != nil {
return
}
mu.Lock()
defer mu.Unlock()
if in.Error != "" {
refusals = append(refusals, fmt.Sprintf("%s: %s", in.PartyID, in.Error))
return
}
if in.PartyID == "" || answers[in.PartyID] != nil {
return
}
answers[in.PartyID] = in.Answer
if len(answers) >= quorum {
once.Do(func() { close(done) })
}
})
if err != nil {
return nil, fmt.Errorf("reveal: await answers: %w", err)
}
defer unsub.Unsubscribe()
req := event.RevealRequest{SessionID: sessionID, OrgID: orgID, KeyID: keyID, Ciphertext: ciphertext}
body, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("reveal: encode request: %w", err)
}
if err := bus.Publish(event.RevealRequestTopic, body); err != nil {
return nil, fmt.Errorf("reveal: publish request: %w", err)
}
select {
case <-done:
case <-time.After(revealDeadline):
}
mu.Lock()
collected := make([][]byte, 0, len(answers))
for _, a := range answers {
collected = append(collected, a)
}
why := append([]string(nil), refusals...)
mu.Unlock()
if len(collected) < quorum {
// Say who refused and why. A quorum that never forms because two nodes
// hold no share is a different problem from one that never forms
// because two nodes are down, and only the nodes know which.
if len(why) > 0 {
return nil, fmt.Errorf("reveal: %d of %d answered for %s; refusals: %v", len(collected), quorum, keyID, why)
}
return nil, fmt.Errorf("reveal: %d of %d answered for %s within %s", len(collected), quorum, keyID, revealDeadline)
}
return reveal.Combine(store, orgID, keyID, ciphertext, collected)
}