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
48 changes: 33 additions & 15 deletions pkg/frost/signing/native_ffi_executor_adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,21 +85,39 @@ func (nefea *nativeExecutionFFIExecutorAdapter) Execute(
return nil, fmt.Errorf("%w: [%v]", ErrNativeCryptographyUnavailable, err)
}

signature, err := nefea.primitive.Sign(
ctx,
logger,
&NativeExecutionFFISigningRequest{
Message: request.Message,
SessionID: request.SessionID,
MemberIndex: request.MemberIndex,
GroupSize: request.GroupSize,
DishonestThreshold: request.DishonestThreshold,
Channel: request.Channel,
MembershipValidator: request.MembershipValidator,
SignerMaterial: signerMaterial,
Attempt: cloneAttempt(request.Attempt),
},
)
ffiRequest := &NativeExecutionFFISigningRequest{
Message: request.Message,
SessionID: request.SessionID,
MemberIndex: request.MemberIndex,
GroupSize: request.GroupSize,
DishonestThreshold: request.DishonestThreshold,
Channel: request.Channel,
MembershipValidator: request.MembershipValidator,
SignerMaterial: signerMaterial,
Attempt: cloneAttempt(request.Attempt),
}

// RFC-21 Phase 6.3: ROAST orchestration entry. The helper
// returns (cleanup, error):
// - cleanup non-nil, error nil -> orchestration active;
// defer cleanup so success and failure return paths converge.
// - cleanup nil, error nil -> static-configuration fallback
// (env var unset, no coordinator registered, or material
// format not extractable). Proceed without orchestration; the
// receive loops use NoOp recorder semantics (Phase 5 behaviour).
// - cleanup nil, error non-nil -> RUNTIME orchestration failure.
// HARD FAIL to prevent group fracture across honest signers.
// In the default build (no frost_native tag) the helper is a
// permanent no-op returning (nil, nil).
orchCleanup, orchErr := attemptRoastRetryOrchestrationFromRequest(ffiRequest, logger)
if orchErr != nil {
return nil, orchErr
}
if orchCleanup != nil {
defer orchCleanup()
}

signature, err := nefea.primitive.Sign(ctx, logger, ffiRequest)
if err != nil {
return nil, err
}
Expand Down
26 changes: 26 additions & 0 deletions pkg/frost/signing/roast_retry_executor_entry_default_build.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
//go:build !frost_native

package signing

import "github.com/ipfs/go-log/v2"

// attemptRoastRetryOrchestrationFromRequest is the executor-adapter
// entry point for RFC-21 Phase-6 ROAST orchestration. In the
// default build (no frost_native tag) it is a permanent no-op
// stub: orchestration cannot run without the frost_native code
// path, so the executor adapter behaves exactly as in Phase 5.
//
// The function returns (cleanup, error). cleanup is non-nil when
// orchestration started successfully; the executor adapter defers
// it. error is non-nil only for RUNTIME failures the executor
// must propagate to its caller (static-configuration errors are
// logged and the cleanup is returned nil to signal "no
// orchestration; fall back to legacy receive-loop semantics").
//
// The default-build stub returns (nil, nil) unconditionally.
func attemptRoastRetryOrchestrationFromRequest(
_ *NativeExecutionFFISigningRequest,
_ log.StandardLogger,
) (func(), error) {
return nil, nil
}
96 changes: 96 additions & 0 deletions pkg/frost/signing/roast_retry_executor_entry_frost_native.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
//go:build frost_native

package signing

import (
"errors"
"fmt"

"github.com/ipfs/go-log/v2"
)

// attemptRoastRetryOrchestrationFromRequest is the executor-adapter
// entry point for RFC-21 Phase-6 ROAST orchestration. It:
//
// 1. Builds an attempt.AttemptContext from the FFI signing
// request (BuildAttemptContextFromRequest, gated frost_native).
//
// 2. If construction fails with ErrUnsupportedSignerMaterialFormat
// -- e.g. the deployment still uses FrostUniFFIV1 material --
// the failure is a STATIC configuration condition: every
// honest signer with the same deployment material observes the
// same error deterministically. Log at INFO and return
// (nil, nil) so the executor proceeds without orchestration.
//
// 3. Any other AttemptContext construction error is a RUNTIME
// failure (nil fields, malformed material payload, etc.). Per
// the RFC-21 Phase-6 orchestration error taxonomy, runtime
// errors must HARD FAIL to prevent group fracture: node A
// falling back to legacy while node B proceeds with ROAST
// would split the participant set on NextAttempt.
//
// 4. Calls BeginOrchestrationForSession with the context.
// ErrRoastRetryReadinessOptOut and
// ErrNoRoastRetryCoordinatorRegistered are static-configuration
// errors -- log at INFO and return (nil, nil). Any other error
// is treated as RUNTIME and propagated unchanged.
//
// 5. On success returns the cleanup function the executor adapter
// must defer.
//
// The function returns (cleanup, error):
// - cleanup non-nil + error nil -> orchestration active; defer cleanup.
// - cleanup nil + error nil -> static fallback; proceed legacy.
// - cleanup nil + error non-nil -> runtime failure; propagate.
func attemptRoastRetryOrchestrationFromRequest(
request *NativeExecutionFFISigningRequest,
logger log.StandardLogger,
) (func(), error) {
if logger == nil {
// Defensive: existing executor-adapter tests pass nil here.
// The helper logs static-fallback diagnostics, so a nil
// logger must not panic the executor.
logger = log.Logger("keep-frost-roast-orchestration")
}
ctx, err := BuildAttemptContextFromRequest(request)
if err != nil {
// All BuildAttemptContextFromRequest errors are treated as
// STATIC fallbacks because they are deterministic per-input:
// the same NativeExecutionFFISigningRequest produces the
// same construction outcome on every honest node, so
// every node would make the same fall-back decision. The
// RFC-21 Phase-6 hard-fail discipline applies only to
// non-deterministic RUNTIME errors that originate inside
// the Coordinator state machine (next branch).
logger.Infof(
"ROAST orchestration unavailable for session %q: %v",
request.SessionID,
err,
)
return nil, nil
}

handle, cleanup, err := BeginOrchestrationForSession(request.SessionID, ctx)
if err != nil {
switch {
case errors.Is(err, ErrRoastRetryReadinessOptOut),
errors.Is(err, ErrNoRoastRetryCoordinatorRegistered):
// Static-configuration errors -> safe to fall back.
logger.Infof(
"ROAST retry disabled for session %q: %v",
request.SessionID,
err,
)
return nil, nil
default:
// Runtime failure: HARD FAIL.
return nil, fmt.Errorf(
"ROAST orchestration: begin session %q: %w",
request.SessionID,
err,
)
}
}
_ = handle // Phase 6.4+ uses this for retry adapter invocation.
return cleanup, nil
}
121 changes: 121 additions & 0 deletions pkg/frost/signing/roast_retry_executor_entry_frost_native_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
//go:build frost_native

package signing

import (
"encoding/json"
"math/big"
"testing"

"github.com/ipfs/go-log/v2"
"github.com/keep-network/keep-core/pkg/protocol/group"
)

func newEntryTestRequest(t *testing.T) *NativeExecutionFFISigningRequest {
t.Helper()
const hexKey = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"
payload, _ := json.Marshal(&nativeFROSTUniFFIV2SignerMaterial{
KeyPackage: &NativeFROSTKeyPackage{
Identifier: "id",
Data: []byte{0x01},
},
PublicKeyPackage: &NativeFROSTPublicKeyPackage{
VerifyingKey: hexKey,
},
})
return &NativeExecutionFFISigningRequest{
Message: new(big.Int).SetBytes([]byte{0xab, 0xcd}),
SessionID: "executor-entry-test",
MemberIndex: 1,
SignerMaterial: &NativeSignerMaterial{
Format: NativeSignerMaterialFormatFrostUniFFIV2,
Payload: payload,
},
Attempt: &Attempt{
Number: 1,
CoordinatorMemberIndex: 1,
IncludedMembersIndexes: []group.MemberIndex{1, 2, 3, 4, 5},
},
}
}

func TestEntry_StaticFallback_NoCoordinatorRegistered_TaggedBuild(t *testing.T) {
// Without the frost_roast_retry build tag this is exercised by
// the default-build test (which always falls through). Under the
// frost_native build alone, the helper still treats the absence
// of a registered coordinator as a static fallback because
// BeginOrchestrationForSession returns
// ErrNoRoastRetryCoordinatorRegistered (in the default build it
// is the stub no-op-return-true).
//
// The helper must return (nil, nil) regardless: the executor
// adapter proceeds without orchestration, matching Phase 5
// receive semantics.
logger := log.Logger("entry-static-test")
cleanup, err := attemptRoastRetryOrchestrationFromRequest(
newEntryTestRequest(t), logger,
)
if err != nil {
t.Fatalf("static fallback must not surface an error: %v", err)
}
if cleanup != nil {
t.Fatal("static fallback must not return a cleanup function")
}
}

func TestEntry_StaticFallback_UnsupportedSignerFormat(t *testing.T) {
// FrostUniFFIV1 material -> ExtractDkgGroupPublicKeyFromMaterial
// returns ErrUnsupportedSignerMaterialFormat. The helper must
// treat this as STATIC (deterministic across deployments) and
// fall back without surfacing an error.
req := newEntryTestRequest(t)
req.SignerMaterial = &NativeSignerMaterial{
Format: NativeSignerMaterialFormatFrostUniFFIV1,
Payload: []byte("{}"),
}
cleanup, err := attemptRoastRetryOrchestrationFromRequest(
req, log.Logger("entry-v1-test"),
)
if err != nil {
t.Fatalf("V1 material must be a static fallback: %v", err)
}
if cleanup != nil {
t.Fatal("static fallback must not return a cleanup function")
}
}

func TestEntry_StaticFallback_OnNilSignerMaterial(t *testing.T) {
// Nil signer material is a deterministic, per-input
// construction-precondition failure: every honest node with
// the same request would observe it identically. Treated as a
// STATIC fallback so the executor adapter proceeds without
// orchestration. The HARD-FAIL discipline is reserved for
// non-deterministic Coordinator state-machine errors.
req := newEntryTestRequest(t)
req.SignerMaterial = nil
cleanup, err := attemptRoastRetryOrchestrationFromRequest(
req, log.Logger("entry-nil-mat-test"),
)
if err != nil {
t.Fatalf("nil signer material must be a STATIC fallback; got %v", err)
}
if cleanup != nil {
t.Fatal("static fallback must not return cleanup")
}
}

func TestEntry_StaticFallback_OnZeroAttemptNumber(t *testing.T) {
// Zero attempt number is also a deterministic precondition
// failure; treated as STATIC fallback.
req := newEntryTestRequest(t)
req.Attempt.Number = 0
cleanup, err := attemptRoastRetryOrchestrationFromRequest(
req, log.Logger("entry-zero-attempt-test"),
)
if err != nil {
t.Fatalf("zero attempt number must be a STATIC fallback; got %v", err)
}
if cleanup != nil {
t.Fatal("static fallback must not return cleanup")
}
}
Loading
Loading