Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions pkg/frost/signing/roast_interactive_signing_frost_native.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,18 @@ func registeredInteractiveSigningEngine() interactiveSigningEngine {
return provider()
}

// InteractiveSigningReady reports whether this process has every pre-wallet
// prerequisite needed to sign material produced by distributed DKG: both
// operator opt-ins, the ROAST transition producer, and an interactive engine.
// It intentionally does not require a wallet-scoped coordinator registration;
// that registration can happen only after DKG has persisted the wallet's key
// group and is enforced by the signing path when the wallet is used.
func InteractiveSigningReady() bool {
return InteractiveSigningOptInEnabled() &&
RoastRetryInfrastructureReady() &&
registeredInteractiveSigningEngine() != nil
}

// ResetInteractiveSigningEngineProviderForTest clears the registered provider.
// Tests defer it so a registration does not leak into other tests.
func ResetInteractiveSigningEngineProviderForTest() {
Expand Down
38 changes: 38 additions & 0 deletions pkg/frost/signing/roast_interactive_signing_frost_native_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,44 @@ func TestRegisterInteractiveSigningEngineProvider(t *testing.T) {
}
}

func TestInteractiveSigningReady_RequiresCompletePath(t *testing.T) {
ResetInteractiveSigningEngineProviderForTest()
t.Cleanup(ResetInteractiveSigningEngineProviderForTest)

t.Setenv(InteractiveSigningOptInEnvVar, "true")
t.Setenv(RoastRetryReadinessOptInEnvVar, "true")
RegisterInteractiveSigningEngineProvider(
func() interactiveSigningEngine { return newFakeInteractiveSigningEngine() },
)

if actual, expected := InteractiveSigningReady(), roastTransitionProducerAvailable(); actual != expected {
t.Fatalf(
"unexpected complete-path readiness\nexpected: [%t]\nactual: [%t]",
expected,
actual,
)
}

t.Setenv(InteractiveSigningOptInEnvVar, "")
if InteractiveSigningReady() {
t.Fatal("interactive signing must not be ready without its operator opt-in")
}

t.Setenv(InteractiveSigningOptInEnvVar, "true")
t.Setenv(RoastRetryReadinessOptInEnvVar, "")
if InteractiveSigningReady() {
t.Fatal("interactive signing must not be ready without ROAST retry opt-in")
}

t.Setenv(RoastRetryReadinessOptInEnvVar, "true")
RegisterInteractiveSigningEngineProvider(
func() interactiveSigningEngine { return nil },
)
if InteractiveSigningReady() {
t.Fatal("interactive signing must not be ready without an engine")
}
}

func TestInteractiveRoastSigningThreshold(t *testing.T) {
// The persisted DKG threshold is returned verbatim - NOT derived from the
// per-attempt dishonest threshold.
Expand Down
26 changes: 16 additions & 10 deletions pkg/frost/signing/roast_retry_readiness.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,20 @@ func RoastRetryReadinessOptInEnabled() bool {
return strings.EqualFold(value, "true")
}

// RoastRetryInfrastructureReady reports whether this process can support the
// ROAST retry path before any wallet-scoped coordinator exists: the operator
// readiness opt-in is set AND the transition producer is present in this build.
// The producer requires both frost_native and frost_roast_retry.
//
// This is deliberately weaker than RoastRetryActive: a newly selected DKG member
// must verify that the signing infrastructure exists before creating a wallet,
// but that wallet's coordinators cannot be registered until its DKG material has
// been persisted. Once the wallet exists, the active gates below additionally
// require the appropriate coordinator registration.
func RoastRetryInfrastructureReady() bool {
return RoastRetryReadinessOptInEnabled() && roastTransitionProducerAvailable()
}

// RoastRetryActive reports whether ROAST retry orchestration is runtime-active:
// the readiness opt-in is set, a coordinator is registered, AND this build
// contains the transition producer (frost_native). It is the deterministic,
Expand All @@ -77,16 +91,8 @@ func RoastRetryReadinessOptInEnabled() bool {
// can never be created instead of using the uniform legacy shuffle (Codex P2-1).
// Always false in builds without the frost_roast_retry tag (the registration and
// producer default stubs both report unavailable).
// readinessAndProducerReady is the build+env prefix shared by RoastRetryActive and
// RoastRetryActiveForKeyGroupMember: the readiness opt-in is set AND the transition
// producer is built in (frost_native). Both gates additionally require a registered
// coordinator (any entry / this wallet-seat's).
func readinessAndProducerReady() bool {
return RoastRetryReadinessOptInEnabled() && roastTransitionProducerAvailable()
}

func RoastRetryActive() bool {
if !readinessAndProducerReady() {
if !RoastRetryInfrastructureReady() {
return false
}
_, ok := RegisteredRoastRetryCoordinator()
Expand All @@ -106,7 +112,7 @@ func RoastRetryActive() bool {
// that cross-wallet fracture on the numbering path.) Always false in builds without
// the frost_roast_retry tag.
func RoastRetryActiveForKeyGroupMember(keyGroupID string, member group.MemberIndex) bool {
if !readinessAndProducerReady() {
if !RoastRetryInfrastructureReady() {
return false
}
_, ok := RegisteredRoastRetryCoordinatorForKeyGroupMember(keyGroupID, member)
Expand Down
16 changes: 16 additions & 0 deletions pkg/frost/signing/roast_retry_readiness_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,22 @@ func TestRoastRetryReadinessOptInEnabled_MirrorsEnsureResult(t *testing.T) {
}
}

func TestRoastRetryInfrastructureReady_RequiresOptInAndProducer(t *testing.T) {
t.Setenv(RoastRetryReadinessOptInEnvVar, "true")
if actual, expected := RoastRetryInfrastructureReady(), roastTransitionProducerAvailable(); actual != expected {
t.Fatalf(
"unexpected infrastructure readiness with opt-in enabled\nexpected: [%t]\nactual: [%t]",
expected,
actual,
)
}

t.Setenv(RoastRetryReadinessOptInEnvVar, "")
if RoastRetryInfrastructureReady() {
t.Fatal("infrastructure must not be ready without the operator opt-in")
}
}

func TestRoastRetryReadinessOptInEnvVar_MatchesRFC(t *testing.T) {
const expected = "KEEP_CORE_FROST_ROAST_RETRY_ENABLED"
if RoastRetryReadinessOptInEnvVar != expected {
Expand Down
21 changes: 15 additions & 6 deletions pkg/frost/signing/roast_runner_bus_net_frost_native.go
Original file line number Diff line number Diff line change
Expand Up @@ -159,13 +159,15 @@ type broadcastChannelRunnerBus struct {

mu sync.Mutex
subscribers []*RunnerBusSubscriber
startOnce sync.Once
}

// NewBroadcastChannelRunnerBus returns a RunnerBus over the given wallet signing
// broadcast channel. It registers the runner message unmarshalers and installs
// a receive handler for the lifetime of ctx; cancel ctx (e.g. at session end)
// to stop receiving. The channel and membershipValidator are the ones the
// existing signing.Request already carries; this adapter does not create them.
// broadcast channel. It registers the runner message unmarshalers; the first
// Subscribe installs the receive handler for the lifetime of ctx, after that
// subscriber has been registered. Cancel ctx (e.g. at session end) to stop
// receiving. The channel and membershipValidator are the ones the existing
// signing.Request already carries; this adapter does not create them.
func NewBroadcastChannelRunnerBus(
ctx context.Context,
logger log.StandardLogger,
Expand Down Expand Up @@ -195,14 +197,17 @@ func NewBroadcastChannelRunnerBus(
}

registerRunnerTransportUnmarshalers(channel)
channel.Recv(ctx, b.handleMessage)

return b, nil
}

// Subscribe registers a receiver and returns its typed streams. Subscribing
// before any Broadcast is the caller's responsibility (the runner subscribes in
// its constructor).
// its constructor). The first subscription starts inbound delivery only AFTER
// the subscriber is visible to handleMessage. Starting Recv in the bus
// constructor would leave a window where a message is handled with zero
// subscribers and dropped; pkg/net would then suppress its retransmissions as
// already seen, permanently losing it for this signing attempt.
func (b *broadcastChannelRunnerBus) Subscribe() *RunnerBusSubscriber {
s := &RunnerBusSubscriber{
commitments: make(chan RunnerMessage, b.streamBuffer),
Expand All @@ -217,6 +222,10 @@ func (b *broadcastChannelRunnerBus) Subscribe() *RunnerBusSubscriber {
b.subscribers = append(b.subscribers, s)
b.mu.Unlock()

b.startOnce.Do(func() {
b.channel.Recv(b.ctx, b.handleMessage)
})

return s
}

Expand Down
76 changes: 76 additions & 0 deletions pkg/frost/signing/roast_runner_bus_net_frost_native_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,37 @@ type fakeNetMessage struct {
payload interface{}
}

// immediateRecvBroadcastChannel invokes its receive handler synchronously from
// Recv. It models the worst-case ordering for the production adapter: an
// already-arrived peer message is ready at the exact instant receiving starts.
// If Recv starts before Subscribe registers its stream, that message is lost.
type immediateRecvBroadcastChannel struct {
message net.Message
recvCalls int
}

func (c *immediateRecvBroadcastChannel) Name() string { return "runner-bus-test" }
func (c *immediateRecvBroadcastChannel) Send(
context.Context,
net.TaggedMarshaler,
...net.RetransmissionStrategy,
) error {
return nil
}
func (c *immediateRecvBroadcastChannel) Recv(
_ context.Context,
handler func(net.Message),
) {
c.recvCalls++
if c.message != nil {
handler(c.message)
}
}
func (c *immediateRecvBroadcastChannel) SetUnmarshaler(func() net.TaggedUnmarshaler) {}
func (c *immediateRecvBroadcastChannel) SetFilter(net.BroadcastChannelFilter) error {
return nil
}

func (m fakeNetMessage) TransportSenderID() net.TransportIdentifier { return nil }
func (m fakeNetMessage) SenderPublicKey() []byte { return m.senderPublicKey }
func (m fakeNetMessage) Payload() interface{} { return m.payload }
Expand All @@ -42,6 +73,7 @@ func (m fakeNetMessage) Type() string {
// authenticated public-key bytes and an outsider's key (not in the group).
type runnerBusAuthFixture struct {
bus *broadcastChannelRunnerBus
validator *group.MembershipValidator
operatorA []byte // seats 1 and 3
operatorB []byte // seat 2
outsider []byte // not selected
Expand Down Expand Up @@ -71,20 +103,64 @@ func newRunnerBusAuthFixture(t *testing.T, streamSize int) runnerBusAuthFixture
)

bus := &broadcastChannelRunnerBus{
ctx: context.Background(),
logger: &testutils.MockLogger{},
channel: &immediateRecvBroadcastChannel{},
membershipValidator: validator,
streamBuffer: streamSize,
seenBound: defaultRunnerBusSeenBound,
}
return runnerBusAuthFixture{
bus: bus,
validator: validator,
operatorA: operatorA,
operatorB: operatorB,
outsider: outsider,
streamSize: streamSize,
}
}

func TestBroadcastChannelRunnerBus_StartsReceivingAfterSubscribe(t *testing.T) {
f := newRunnerBusAuthFixture(t, 8)
channel := &immediateRecvBroadcastChannel{
message: shareMessage(1, f.operatorA, "already-arrived-share"),
}

bus, err := NewBroadcastChannelRunnerBus(
context.Background(),
&testutils.MockLogger{},
channel,
f.validator,
)
if err != nil {
t.Fatalf("new runner bus: %v", err)
}
if channel.recvCalls != 0 {
t.Fatalf("constructor started receiving before a subscriber existed")
}

// Recv synchronously delivers channel.message. Subscribe must register the
// stream before it starts Recv or this message would be dropped forever.
sub := bus.Subscribe()
if channel.recvCalls != 1 {
t.Fatalf("expected Subscribe to start receiving once, got [%d] calls", channel.recvCalls)
}
select {
case msg := <-sub.Shares():
if string(msg.Payload) != "already-arrived-share" {
t.Fatalf("unexpected payload: [%q]", msg.Payload)
}
default:
t.Fatal("message delivered as Recv started was lost before subscription")
}

// Additional subscribers must not install duplicate handlers.
bus.Subscribe()
if channel.recvCalls != 1 {
t.Fatalf("expected receiving to start once, got [%d] calls", channel.recvCalls)
}
}

func shareMessage(sender group.MemberIndex, authorPublicKey []byte, payload string) fakeNetMessage {
return fakeNetMessage{
senderPublicKey: authorPublicKey,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ func ConsumeRoastTransitionForSelection(
// Build/env readiness is group-uniform (readiness opt-in AND the transition
// producer is built in). If not ready, a uniform legacy fallback every honest node
// makes identically.
if !readinessAndProducerReady() {
if !RoastRetryInfrastructureReady() {
return nil, nil, ErrRoastSelectionFallBackToLegacy
}

Expand Down
19 changes: 11 additions & 8 deletions pkg/tbtc/frost_dkg_execution_frost_native.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,18 +42,21 @@ func executeFrostDKGIfPossible(
return false
}

// Distributed DKG produces signing material usable ONLY via the interactive
// path; with interactive signing disabled, signing would route to the coarse
// path, which cannot reconstruct a distributed key, and every signature would
// fail. Refuse to run rather than create an unsignable wallet, coupling the DKG
// switch to the signing switch.
if !frostsigning.InteractiveSigningOptInEnabled() {
// Distributed DKG produces signing material usable ONLY via the complete
// interactive ROAST path. The interactive audit flag alone is insufficient:
// without the ROAST readiness opt-in or a build containing the transition
// producer, orchestration takes its static fallback and reaches the removed
// coarse primitive. Refuse to run rather than create an unsignable wallet.
if !frostsigning.InteractiveSigningReady() {
logger.Errorf(
"FROST DKG with seed [0x%x] selected this operator, but the distributed "+
"DKG requires interactive signing (%s) to be enabled; refusing to run to "+
"avoid creating a wallet that cannot sign",
"DKG requires the complete interactive ROAST signing path (%s=true, "+
"%s=true, a frost_native+frost_roast_retry build, and a registered "+
"interactive engine); refusing to run to avoid creating a wallet that "+
"cannot sign",
event.Seed,
frostsigning.InteractiveSigningOptInEnvVar,
frostsigning.RoastRetryReadinessOptInEnvVar,
)
return false
}
Expand Down
31 changes: 31 additions & 0 deletions pkg/tbtc/frost_dkg_execution_frost_native_no_roast_retry_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
//go:build frost_native && !frost_roast_retry

package tbtc

import (
"context"
"math/big"
"testing"

frostsigning "github.com/keep-network/keep-core/pkg/frost/signing"
"github.com/keep-network/keep-core/pkg/protocol/group"
)

func TestExecuteFrostDKGIfPossible_RequiresRoastRetryBuild(t *testing.T) {
t.Setenv(frostsigning.InteractiveSigningOptInEnvVar, "true")
t.Setenv(frostsigning.RoastRetryReadinessOptInEnvVar, "true")
registerFrostDKGReadinessTestEngine(t)

executed := executeFrostDKGIfPossible(
context.Background(),
nil,
nil,
&FrostDKGStartedEvent{Seed: big.NewInt(100)},
[]group.MemberIndex{1},
nil,
)

if executed {
t.Fatal("DKG must not execute in a build without the ROAST retry path")
}
}
Loading
Loading