diff --git a/pkg/frost/signing/native_ffi_primitive_transitional_frost_native.go b/pkg/frost/signing/native_ffi_primitive_transitional_frost_native.go index 6bdac5a854..9e82310a85 100644 --- a/pkg/frost/signing/native_ffi_primitive_transitional_frost_native.go +++ b/pkg/frost/signing/native_ffi_primitive_transitional_frost_native.go @@ -158,6 +158,28 @@ func (btlcnnefsp *buildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive) return nil, err } + // Scaffold persistence-vs-execution gate. The resolver in #3959 refuses to + // BUILD scaffold-era signer material without the env opt-in, but material + // persisted from a previous opted-in session can still drive this signing + // path on later runs after the operator has unset the flag. Refuse to + // enter the FFI scaffold path (which feeds placeholder participant + // pubkeys into RunDKG) when the payload is scaffold-era and the operator + // has not actively opted in for this process. The check is per-call (not + // cached) so flipping the env back unset recovers fail-closed behavior + // without a restart, matching the contract documented on + // AcceptScaffoldKeyGroupEnvVar. + if payload.KeyGroupSource == NativeTBTCSignerKeyGroupSourceLegacyWalletPubKey && + !AcceptScaffoldKeyGroupEnabled() { + return nil, fmt.Errorf( + "%w: refusing to drive the tbtc-signer FFI signing path with "+ + "scaffold-era %q signer material; set %s=true to opt in for "+ + "local/CI use only, never in production", + ErrNativeCryptographyUnavailable, + NativeTBTCSignerKeyGroupSourceLegacyWalletPubKey, + AcceptScaffoldKeyGroupEnvVar, + ) + } + legacyPrivateKeyShare, err := decodeBuildTaggedTBTCSignerLegacyPrivateKeyShare(payload) if err != nil { return nil, err diff --git a/pkg/frost/signing/native_ffi_primitive_transitional_frost_native_test.go b/pkg/frost/signing/native_ffi_primitive_transitional_frost_native_test.go index 3bcce62fc3..adf5f8e187 100644 --- a/pkg/frost/signing/native_ffi_primitive_transitional_frost_native_test.go +++ b/pkg/frost/signing/native_ffi_primitive_transitional_frost_native_test.go @@ -1730,6 +1730,8 @@ func TestBuildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive_Sign_TBTC func TestBuildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive_Sign_TBTCSignerPath_BootstrapVersion_InvalidCoarseSignatureFallsBack( t *testing.T, ) { + t.Setenv(AcceptScaffoldKeyGroupEnvVar, "true") + engine := &mockBuildTaggedTBTCSignerEngine{ version: "tbtc-signer/0.1.0-bootstrap", finalizeSignature: []byte{0xaa}, @@ -1832,6 +1834,98 @@ func TestBuildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive_Sign_TBTC } } +func TestBuildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive_Sign_TBTCSignerPath_RefusesScaffoldMaterialWithoutOptIn( + t *testing.T, +) { + // Closes the persistence-vs-execution gap: even when the native + // tbtc-signer engine is registered (scaffold material on disk from a + // previous opted-in session is enough to reach this point), the FFI + // signing path must refuse to feed RunDKG placeholder participant + // pubkeys without an active operator opt-in. Force the env var off so + // any value inherited from the test runner's containing process cannot + // suppress the refusal. + t.Setenv(AcceptScaffoldKeyGroupEnvVar, "") + + engine := &mockBuildTaggedTBTCSignerEngine{ + version: "tbtc-signer/0.1.0-bootstrap", + runDKGResult: &NativeTBTCSignerDKGResult{ + SessionID: "session-scaffold-refused", + KeyGroup: "group-from-dkg", + ParticipantCount: 3, + Threshold: 2, + CreatedAtUnix: 1, + }, + finalizeSignature: buildTaggedTBTCSignerValidTestSignature(0x22), + } + UnregisterNativeTBTCSignerEngine() + UnregisterNativeTBTCSignerCoarseSignatureObserver() + t.Cleanup(UnregisterNativeTBTCSignerEngine) + t.Cleanup(UnregisterNativeTBTCSignerCoarseSignatureObserver) + + if err := RegisterNativeTBTCSignerEngine(engine); err != nil { + t.Fatalf("unexpected registration error: [%v]", err) + } + + primitive := &buildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive{} + + signature, err := primitive.Sign(nil, nil, &NativeExecutionFFISigningRequest{ + Message: big.NewInt(123), + SessionID: "session-scaffold-refused", + MemberIndex: 1, + GroupSize: 3, + DishonestThreshold: 1, + SignerMaterial: &NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostTBTCSignerV1, + Payload: []byte( + `{"keyGroup":"legacy-wallet-derived","keyGroupSource":"legacy-wallet-pubkey"}`, + ), + }, + }) + if err == nil { + t.Fatal("expected scaffold-refusal error from FFI signing path") + } + if signature != nil { + t.Fatal("expected nil signature when scaffold path is refused") + } + + if !errors.Is(err, ErrNativeCryptographyUnavailable) { + t.Fatalf( + "expected ErrNativeCryptographyUnavailable wrap; got: [%v]", + err, + ) + } + if !strings.Contains(err.Error(), AcceptScaffoldKeyGroupEnvVar) { + t.Fatalf( + "expected scaffold-refusal error to reference %s; got: [%v]", + AcceptScaffoldKeyGroupEnvVar, + err, + ) + } + if !strings.Contains(err.Error(), NativeTBTCSignerKeyGroupSourceLegacyWalletPubKey) { + t.Fatalf( + "expected scaffold-refusal error to reference %s; got: [%v]", + NativeTBTCSignerKeyGroupSourceLegacyWalletPubKey, + err, + ) + } + + if engine.runDKGCalled { + t.Fatal( + "RunDKG must not be called when the scaffold opt-in flag is unset; " + + "refusing before the placeholder participant pubkeys are built " + + "is the whole point of the fence", + ) + } + if engine.startCalled { + t.Fatal("StartSignRound must not be called when the scaffold path is refused") + } + if engine.finalizeCalled { + t.Fatal( + "FinalizeSignRound must not be called when the scaffold path is refused", + ) + } +} + func TestBuildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive_Sign_TBTCSignerPath_BootstrapVersion_LegacyKeyGroupSourceUsesRunDKGResult( t *testing.T, ) { @@ -2022,6 +2116,11 @@ func TestBuildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive_Sign_TBTC func TestBuildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive_Sign_TBTCSignerPath_NoEngineNoLegacyShare( t *testing.T, ) { + // Scaffold-era signing path requires explicit operator opt-in; this test + // exercises the engine-unavailable + no-legacy-share branch which lives + // past the scaffold fence. + t.Setenv(AcceptScaffoldKeyGroupEnvVar, "true") + UnregisterNativeTBTCSignerEngine() UnregisterNativeTBTCSignerFallbackObserver() UnregisterNativeTBTCSignerCoarseSignatureObserver() @@ -2094,6 +2193,8 @@ func TestBuildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive_Sign_TBTC func TestBuildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive_Sign_TBTCSignerPath_AttemptVariationRunDKGConflictFallsBack( t *testing.T, ) { + t.Setenv(AcceptScaffoldKeyGroupEnvVar, "true") + UnregisterNativeTBTCSignerEngine() UnregisterNativeTBTCSignerFallbackObserver() UnregisterNativeTBTCSignerCoarseSignatureObserver() @@ -2217,6 +2318,8 @@ func TestBuildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive_Sign_TBTC func TestBuildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive_Sign_TBTCSignerPath_BootstrapVersion_AttemptVariationStartSignRoundConflictFallsBack( t *testing.T, ) { + t.Setenv(AcceptScaffoldKeyGroupEnvVar, "true") + UnregisterNativeTBTCSignerEngine() UnregisterNativeTBTCSignerFallbackObserver() UnregisterNativeTBTCSignerCoarseSignatureObserver() @@ -2399,6 +2502,11 @@ func TestBuildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive_Sign_TBTC func TestBuildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive_Sign_TBTCSignerPath_InvalidAttemptPolicy_DoesNotFallback( t *testing.T, ) { + // Scaffold-era signing path requires explicit operator opt-in; this test + // exercises the FFI flow's invalid-attempt-policy branch, which lives + // past the scaffold fence. + t.Setenv(AcceptScaffoldKeyGroupEnvVar, "true") + fixtures, err := tecdsatest.LoadPrivateKeyShareTestFixtures(3) if err != nil { t.Fatalf("failed loading key share fixtures: [%v]", err) @@ -2550,6 +2658,11 @@ func TestBuildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive_Sign_TBTC func TestBuildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive_Sign_TBTCSignerPath_ConsumedAttemptReplay_DoesNotFallback( t *testing.T, ) { + // Scaffold-era signing path requires explicit operator opt-in; this test + // exercises the FFI flow's consumed-attempt-replay branch, which lives + // past the scaffold fence. + t.Setenv(AcceptScaffoldKeyGroupEnvVar, "true") + fixtures, err := tecdsatest.LoadPrivateKeyShareTestFixtures(3) if err != nil { t.Fatalf("failed loading key share fixtures: [%v]", err)