diff --git a/pkg/frost/signing/native_ffi_executor_adapter.go b/pkg/frost/signing/native_ffi_executor_adapter.go index f5539f5dae..4ff3d486ea 100644 --- a/pkg/frost/signing/native_ffi_executor_adapter.go +++ b/pkg/frost/signing/native_ffi_executor_adapter.go @@ -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 } diff --git a/pkg/frost/signing/roast_retry_executor_entry_default_build.go b/pkg/frost/signing/roast_retry_executor_entry_default_build.go new file mode 100644 index 0000000000..96e21f9ba5 --- /dev/null +++ b/pkg/frost/signing/roast_retry_executor_entry_default_build.go @@ -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 +} diff --git a/pkg/frost/signing/roast_retry_executor_entry_frost_native.go b/pkg/frost/signing/roast_retry_executor_entry_frost_native.go new file mode 100644 index 0000000000..bbb79e7f33 --- /dev/null +++ b/pkg/frost/signing/roast_retry_executor_entry_frost_native.go @@ -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 +} diff --git a/pkg/frost/signing/roast_retry_executor_entry_frost_native_test.go b/pkg/frost/signing/roast_retry_executor_entry_frost_native_test.go new file mode 100644 index 0000000000..ec521b1335 --- /dev/null +++ b/pkg/frost/signing/roast_retry_executor_entry_frost_native_test.go @@ -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") + } +} diff --git a/pkg/frost/signing/roast_retry_executor_entry_frost_roast_retry_test.go b/pkg/frost/signing/roast_retry_executor_entry_frost_roast_retry_test.go new file mode 100644 index 0000000000..6329394de6 --- /dev/null +++ b/pkg/frost/signing/roast_retry_executor_entry_frost_roast_retry_test.go @@ -0,0 +1,197 @@ +//go:build frost_native && frost_roast_retry + +package signing + +import ( + "encoding/json" + "errors" + "math/big" + "testing" + + "github.com/ipfs/go-log/v2" + "github.com/keep-network/keep-core/pkg/frost/roast" + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +func newEntryRetryTestRequest(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-retry-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_ReadinessOptInUnset(t *testing.T) { + // Explicitly unset the env var. + t.Setenv(RoastRetryReadinessOptInEnvVar, "") + ResetRoastRetryRegistrationForTest() + ResetSessionHandleRegistryForTest() + t.Cleanup(ResetRoastRetryRegistrationForTest) + t.Cleanup(ResetSessionHandleRegistryForTest) + + // Register a coordinator -- the env var alone keeps us in + // fallback. + RegisterRoastRetryCoordinator(RoastRetryDeps{ + Coordinator: roast.NewInMemoryCoordinator(), + Signer: roast.NoOpSigner(), + Verifier: roast.NoOpSignatureVerifier(), + SelfMember: 1, + }) + + cleanup, err := attemptRoastRetryOrchestrationFromRequest( + newEntryRetryTestRequest(t), log.Logger("entry-no-optin"), + ) + if err != nil { + t.Fatalf("static fallback (env var unset) must not surface an error: %v", err) + } + if cleanup != nil { + t.Fatal("static fallback must not return a cleanup function") + } +} + +func TestEntry_StaticFallback_RegistryEmpty(t *testing.T) { + t.Setenv(RoastRetryReadinessOptInEnvVar, "true") + ResetRoastRetryRegistrationForTest() + ResetSessionHandleRegistryForTest() + t.Cleanup(ResetRoastRetryRegistrationForTest) + t.Cleanup(ResetSessionHandleRegistryForTest) + + // Registry is empty (no Register call). + cleanup, err := attemptRoastRetryOrchestrationFromRequest( + newEntryRetryTestRequest(t), log.Logger("entry-no-registry"), + ) + if err != nil { + t.Fatalf("static fallback (registry empty) must not surface an error: %v", err) + } + if cleanup != nil { + t.Fatal("static fallback must not return a cleanup function") + } +} + +func TestEntry_HappyPath_ActivatesOrchestration(t *testing.T) { + t.Setenv(RoastRetryReadinessOptInEnvVar, "true") + ResetRoastRetryRegistrationForTest() + ResetSessionHandleRegistryForTest() + t.Cleanup(ResetRoastRetryRegistrationForTest) + t.Cleanup(ResetSessionHandleRegistryForTest) + + RegisterRoastRetryCoordinator(RoastRetryDeps{ + Coordinator: roast.NewInMemoryCoordinator(), + Signer: roast.NoOpSigner(), + Verifier: roast.NoOpSignatureVerifier(), + SelfMember: 1, + }) + + req := newEntryRetryTestRequest(t) + cleanup, err := attemptRoastRetryOrchestrationFromRequest( + req, log.Logger("entry-happy"), + ) + if err != nil { + t.Fatalf("happy path must not error: %v", err) + } + if cleanup == nil { + t.Fatal("happy path must return a cleanup function") + } + + // Binding must exist for the session. + if _, _, ok := currentAttemptHandleForCollect(req.SessionID); !ok { + t.Fatal("binding must exist after orchestration entry") + } + cleanup() + if _, _, ok := currentAttemptHandleForCollect(req.SessionID); ok { + t.Fatal("binding must be cleared after cleanup") + } +} + +func TestEntry_HardFail_RuntimeBeginAttemptFailure(t *testing.T) { + t.Setenv(RoastRetryReadinessOptInEnvVar, "true") + ResetRoastRetryRegistrationForTest() + ResetSessionHandleRegistryForTest() + t.Cleanup(ResetRoastRetryRegistrationForTest) + t.Cleanup(ResetSessionHandleRegistryForTest) + + // Register an erroring coordinator -- BeginAttempt fails for + // runtime reasons. Per the RFC-21 taxonomy, this must HARD FAIL. + RegisterRoastRetryCoordinator(RoastRetryDeps{ + Coordinator: &erroringEntryCoordinator{ + err: errors.New("synthetic begin-attempt runtime failure"), + }, + Signer: roast.NoOpSigner(), + Verifier: roast.NoOpSignatureVerifier(), + SelfMember: 1, + }) + + cleanup, err := attemptRoastRetryOrchestrationFromRequest( + newEntryRetryTestRequest(t), log.Logger("entry-hard-fail"), + ) + if err == nil { + t.Fatal("runtime BeginAttempt error must HARD FAIL (not static fallback)") + } + if cleanup != nil { + t.Fatal("hard-fail must not return cleanup") + } + if !contains(err.Error(), "synthetic begin-attempt runtime failure") { + t.Fatalf("error must propagate underlying cause; got %v", err) + } +} + +// erroringEntryCoordinator implements roast.Coordinator with a +// synthetic BeginAttempt failure. Used to verify the HARD-FAIL +// branch of the executor-adapter entry helper. +type erroringEntryCoordinator struct { + err error +} + +func (e *erroringEntryCoordinator) BeginAttempt(_ attempt.AttemptContext) (roast.AttemptHandle, error) { + return roast.AttemptHandle{}, e.err +} +func (e *erroringEntryCoordinator) State(_ roast.AttemptHandle) (roast.AttemptState, error) { + return roast.AttemptStatePending, nil +} +func (e *erroringEntryCoordinator) SelectedCoordinator(_ roast.AttemptHandle) (group.MemberIndex, error) { + return 0, nil +} +func (e *erroringEntryCoordinator) RecordEvidence(_ roast.AttemptHandle, _ *roast.LocalEvidenceSnapshot) error { + return nil +} +func (e *erroringEntryCoordinator) AggregateBundle(_ roast.AttemptHandle) (*roast.TransitionMessage, error) { + return nil, nil +} +func (e *erroringEntryCoordinator) VerifyBundle(_ roast.AttemptHandle, _ *roast.TransitionMessage) error { + return nil +} +func (e *erroringEntryCoordinator) NextAttempt( + _ roast.AttemptHandle, _ *roast.TransitionMessage, _ uint, _ []byte, +) (attempt.AttemptContext, error) { + return attempt.AttemptContext{}, nil +} + +func contains(s, substr string) bool { + for i := 0; i+len(substr) <= len(s); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/pkg/frost/signing/roast_retry_executor_entry_test.go b/pkg/frost/signing/roast_retry_executor_entry_test.go new file mode 100644 index 0000000000..478042619d --- /dev/null +++ b/pkg/frost/signing/roast_retry_executor_entry_test.go @@ -0,0 +1,27 @@ +package signing + +import ( + "testing" + + "github.com/ipfs/go-log/v2" +) + +func TestAttemptRoastRetryOrchestrationFromRequest_DefaultBuildIsNoOp(t *testing.T) { + // In the default build, the helper is a permanent stub returning + // (nil, nil) so the executor adapter behaves exactly as in + // Phase 5: no orchestration, no error, no cleanup deferred. + // + // The tagged-build test surface + // (roast_retry_executor_entry_frost_native_test.go) exercises + // the real branching. + cleanup, err := attemptRoastRetryOrchestrationFromRequest( + &NativeExecutionFFISigningRequest{SessionID: "x"}, + log.Logger("test"), + ) + if err != nil { + t.Fatalf("default-build helper must not return an error; got %v", err) + } + if cleanup != nil { + t.Fatal("default-build helper must not return a cleanup function") + } +} diff --git a/pkg/frost/signing/roast_retry_orchestration.go b/pkg/frost/signing/roast_retry_orchestration.go index 76fca42f06..5aed7ad228 100644 --- a/pkg/frost/signing/roast_retry_orchestration.go +++ b/pkg/frost/signing/roast_retry_orchestration.go @@ -1,12 +1,27 @@ package signing import ( + "errors" "fmt" "github.com/keep-network/keep-core/pkg/frost/roast" "github.com/keep-network/keep-core/pkg/frost/roast/attempt" ) +// ErrNoRoastRetryCoordinatorRegistered is returned by +// BeginOrchestrationForSession when the package-level ROAST-retry +// registry has not been populated by a caller. The error is the +// "static configuration" class per the RFC-21 Phase-6 Resolved +// Decision on orchestration error taxonomy: it is safe to fall +// back to the legacy retry path because every honest signer +// observes the same registry state at the same node startup, so +// the fallback decision is deterministic across the group. +// +// Use errors.Is to detect. +var ErrNoRoastRetryCoordinatorRegistered = errors.New( + "roast orchestration: no coordinator registered", +) + // BeginOrchestrationForSession encapsulates the per-session // BeginAttempt + binding-population step the RFC-21 Phase 5 // orchestration layer performs. Callers in the layer above the @@ -37,7 +52,8 @@ func BeginOrchestrationForSession( deps, ok := RegisteredRoastRetryCoordinator() if !ok { return roast.AttemptHandle{}, nil, fmt.Errorf( - "roast orchestration: no coordinator registered; caller should fall back to legacy behaviour", + "%w: caller should fall back to legacy behaviour", + ErrNoRoastRetryCoordinatorRegistered, ) } if deps.Coordinator == nil {