Skip to content

Commit d1589fa

Browse files
authored
feat(frost): no-coarse-fallback mode for coarse-path retirement (default off) (#4101)
## What The **reversible, un-gated** first half of coarse-path retirement (RFC-21 Phase 7.3). Adds a default-**off** `KEEP_CORE_FROST_INTERACTIVE_SIGNING_ONLY` gate; when set, the executor **refuses** to fall through to the coarse signing primitive — if interactive signing did not run (its audit gate off, or no engine registered), the attempt fails **closed** rather than silently signing over the retired coarse path. Only the `(nil signature, nil error)` "interactive not enabled → coarse" fall-through becomes a refusal; the hard-fail on a *committed* interactive failure is unchanged. ## Safety / sequencing - **Default off → production unchanged.** Coarse stays the path until an operator flips this on. - Flipping it on **is** the tECDSA→FROST cutover for that node (no coarse fallback), so it stays off until the `frost-secp256k1-tr` external audit clears and the recovery-leaf decision lands. - The **irreversible** part — deleting the transitional coarse FFI primitive + the deterministic-nonce path — is the deliberate follow-up, not in this PR. ## Tests - `TestEntry_InteractiveOnly_RefusesCoarseFallback` — orchestration active + interactive audit gate off + this flag on → the executor returns a refusal naming the env var, no signature. - `TestEntry_InteractiveSigningOnlyEnabled_ParsesFlag` — flag parsing. - Existing static-fallback executor tests unchanged (flag defaults off). Builds clean across tag combos; vet + gofmt clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
2 parents 9816ccf + e719210 commit d1589fa

7 files changed

Lines changed: 231 additions & 1 deletion

pkg/frost/signing/backend.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,16 @@ func setNativeExecutionMode(mode nativeExecutionModeValue) {
164164
}
165165

166166
func nativeExecutionFallbackAllowed() bool {
167+
// Interactive-only mode (coarse-path retirement) suppresses EVERY legacy/coarse
168+
// fallback, not only the inner FFI coarse primitive: if the native FFI path does
169+
// not yield an interactive signature - including when it is unavailable before the
170+
// adapter's own guard runs - the outer bridge/adapter must not delegate to the
171+
// legacy backend either. This is the single gate those outer fallbacks consult, so
172+
// failing it here closes them all.
173+
if InteractiveSigningOnlyEnabled() {
174+
return false
175+
}
176+
167177
executionBackendMutex.RLock()
168178
defer executionBackendMutex.RUnlock()
169179

pkg/frost/signing/backend_test.go

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,82 @@ func TestSetExecutionBackendByName(t *testing.T) {
176176
}
177177
}
178178

179+
func TestNativeExecutionFallbackAllowed_SuppressedByInteractiveOnly(t *testing.T) {
180+
// Coarse-path retirement (Codex #4101 P2): interactive-only mode must close the
181+
// OUTER native fallbacks too - the bridge/adapter consult this single gate before
182+
// delegating to the legacy backend, so the flag has to flip it closed regardless
183+
// of the execution mode, or a node with an unavailable FFI path would still sign
184+
// over the legacy/coarse delegate.
185+
previousMode := currentNativeExecutionMode()
186+
t.Cleanup(func() { setNativeExecutionMode(previousMode) })
187+
188+
setNativeExecutionMode(nativeExecutionModeFallbackAllowed)
189+
if !nativeExecutionFallbackAllowed() {
190+
t.Fatal("baseline: the fallback-allowed mode must permit the outer fallback")
191+
}
192+
193+
t.Setenv(InteractiveSigningOnlyEnvVar, "true")
194+
if nativeExecutionFallbackAllowed() {
195+
t.Fatal("interactive-only mode must suppress the outer native fallback")
196+
}
197+
}
198+
199+
func TestLegacyExecutionBackend_InteractiveOnlyRefusesTerminal(t *testing.T) {
200+
// Coarse-path retirement (Codex #4101 P2): the DEFAULT backend is the legacy
201+
// coarse/tECDSA signer, which the native bridge/adapter guards never touch. The
202+
// flag must fail closed here too, terminally, so it cannot fail open under the
203+
// documented default config.
204+
t.Setenv(InteractiveSigningOnlyEnvVar, "true")
205+
206+
_, err := newLegacyExecutionBackend().Execute(
207+
context.Background(), log.Logger("test"), &Request{Message: big.NewInt(1)},
208+
)
209+
if !errors.Is(err, ErrTerminalSigningFailure) {
210+
t.Fatalf("interactive-only must terminally refuse the legacy backend: %v", err)
211+
}
212+
}
213+
214+
type unavailableNativeAdapter struct{}
215+
216+
func (unavailableNativeAdapter) Execute(
217+
context.Context, log.StandardLogger, *Request,
218+
) (*Result, error) {
219+
return nil, ErrNativeCryptographyUnavailable
220+
}
221+
222+
func (unavailableNativeAdapter) RegisterUnmarshallers(net.BroadcastChannel) {}
223+
224+
func TestNativeExecutionBackend_InteractiveOnlyPromotesUnavailableToTerminal(t *testing.T) {
225+
// Coarse-path retirement (Codex #4101 P2): when the native interactive path is
226+
// unavailable and every fallback is suppressed, the outer refusal surfaces as a
227+
// bare ErrNativeCryptographyUnavailable - promote it to terminal so the retry loop
228+
// aborts instead of spinning.
229+
backend, err := newNativeExecutionBackend(unavailableNativeAdapter{})
230+
if err != nil {
231+
t.Fatalf("unexpected backend setup error: %v", err)
232+
}
233+
234+
t.Setenv(InteractiveSigningOnlyEnvVar, "true")
235+
_, err = backend.Execute(
236+
context.Background(), log.Logger("test"), &Request{Message: big.NewInt(1)},
237+
)
238+
if !errors.Is(err, ErrTerminalSigningFailure) {
239+
t.Fatalf("interactive-only must promote the native unavailable error to terminal: %v", err)
240+
}
241+
242+
// With the flag OFF the unavailable error passes through unpromoted (no regression).
243+
t.Setenv(InteractiveSigningOnlyEnvVar, "")
244+
_, offErr := backend.Execute(
245+
context.Background(), log.Logger("test"), &Request{Message: big.NewInt(1)},
246+
)
247+
if errors.Is(offErr, ErrTerminalSigningFailure) {
248+
t.Fatalf("must not promote to terminal when the flag is off: %v", offErr)
249+
}
250+
if !errors.Is(offErr, ErrNativeCryptographyUnavailable) {
251+
t.Fatalf("flag off must pass the native unavailable error through: %v", offErr)
252+
}
253+
}
254+
179255
func TestSetExecutionBackendByName_NativeFailureRestoresPreviousMode(
180256
t *testing.T,
181257
) {

pkg/frost/signing/legacy_backend.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,18 @@ func (leb *legacyExecutionBackend) Execute(
3131
return nil, fmt.Errorf("request is nil")
3232
}
3333

34+
if InteractiveSigningOnlyEnabled() {
35+
// Interactive-only mode (coarse-path retirement): the legacy backend is the
36+
// tECDSA/coarse signer the flag retires. Refuse at the action itself so the
37+
// safety switch holds even under the DEFAULT backend selection, where the
38+
// native bridge/adapter guards never run. Terminal so the retry loop aborts.
39+
return nil, fmt.Errorf(
40+
"%w: interactive-only signing mode (%s) is set but the legacy (coarse "+
41+
"tECDSA) backend is selected; the coarse path is retired",
42+
ErrTerminalSigningFailure, InteractiveSigningOnlyEnvVar,
43+
)
44+
}
45+
3446
if request.Attempt != nil {
3547
logger.Infof(
3648
"[member:%v] executing FROST signing attempt [%v] "+

pkg/frost/signing/native_backend.go

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package signing
22

33
import (
44
"context"
5+
"errors"
56
"fmt"
67

78
"github.com/ipfs/go-log/v2"
@@ -50,7 +51,23 @@ func (neb *nativeExecutionBackend) Execute(
5051
return nil, fmt.Errorf("request is nil")
5152
}
5253

53-
return neb.adapter.Execute(ctx, logger, request)
54+
result, err := neb.adapter.Execute(ctx, logger, request)
55+
if err != nil &&
56+
InteractiveSigningOnlyEnabled() &&
57+
errors.Is(err, ErrNativeCryptographyUnavailable) &&
58+
!errors.Is(err, ErrTerminalSigningFailure) {
59+
// Interactive-only mode (coarse-path retirement): the native interactive path
60+
// could not produce a signature and every coarse/legacy fallback is suppressed,
61+
// so an outer refusal surfaces as a bare ErrNativeCryptographyUnavailable.
62+
// Promote it to TERMINAL so the tBTC signingRetryLoop aborts immediately rather
63+
// than retrying a deterministic configuration failure until timeout.
64+
return nil, fmt.Errorf(
65+
"%w: interactive-only signing mode (%s) and the native interactive path is "+
66+
"unavailable; refusing the coarse fallback: %v",
67+
ErrTerminalSigningFailure, InteractiveSigningOnlyEnvVar, err,
68+
)
69+
}
70+
return result, err
5471
}
5572

5673
func (neb *nativeExecutionBackend) RegisterUnmarshallers(

pkg/frost/signing/native_ffi_executor_adapter.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,21 @@ func (nefea *nativeExecutionFFIExecutorAdapter) Execute(
144144
}, nil
145145
}
146146

147+
if InteractiveSigningOnlyEnabled() {
148+
// Interactive-only mode (coarse-path retirement): the orchestration produced
149+
// no interactive signature - the audit gate is off, no engine is registered,
150+
// OR any static fallback fired (readiness off, no coordinator, unsupported
151+
// material). Refuse the inner coarse primitive here; the OUTER bridge/adapter
152+
// legacy fallback is closed separately via nativeExecutionFallbackAllowed().
153+
// Mark the refusal TERMINAL so the tBTC signingRetryLoop aborts immediately
154+
// instead of retrying a deterministic configuration failure until timeout.
155+
return nil, fmt.Errorf(
156+
"%w: interactive-only signing mode (%s) is set but interactive signing did "+
157+
"not run (%s off, no engine, or static fallback); refusing the coarse fallback",
158+
ErrTerminalSigningFailure, InteractiveSigningOnlyEnvVar, InteractiveSigningOptInEnvVar,
159+
)
160+
}
161+
147162
signature, err := nefea.primitive.Sign(ctx, logger, ffiRequest)
148163
if err != nil {
149164
return nil, err

pkg/frost/signing/native_ffi_executor_adapter_test.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,78 @@ func TestNativeExecutionFFIExecutorAdapter_Execute_RejectsNilSignature(
257257
}
258258
}
259259

260+
func TestNativeExecutionFFIExecutorAdapter_Execute_InteractiveOnlyRefusesCoarse(
261+
t *testing.T,
262+
) {
263+
// Coarse-path retirement: with interactive-only mode ON, the adapter must NOT fall
264+
// through to the coarse primitive on ANY no-interactive-signature path. In the
265+
// default build attemptRoastRetryOrchestrationFromRequest is a no-op (nil,nil,nil)
266+
// - the ultimate static fallback - so this exercises exactly the gap Codex flagged:
267+
// a fall-through that an executor-level check (which runs only after orchestration
268+
// activates) would have bypassed. The adapter is the single coarse-invocation
269+
// point, so the refusal here covers every path.
270+
t.Setenv(InteractiveSigningOnlyEnvVar, "true")
271+
272+
primitive := &mockNativeExecutionFFISigningPrimitive{
273+
signature: &frost.Signature{
274+
R: [frost.SignatureComponentSize]byte{0x01},
275+
S: [frost.SignatureComponentSize]byte{0x02},
276+
},
277+
}
278+
279+
executor, err := NewNativeExecutionFFIExecutorAdapter(primitive)
280+
if err != nil {
281+
t.Fatalf("unexpected adapter setup error: [%v]", err)
282+
}
283+
284+
_, err = executor.Execute(context.Background(), nil, &Request{
285+
Message: big.NewInt(123),
286+
SessionID: "session-interactive-only",
287+
MemberIndex: 2,
288+
GroupSize: 5,
289+
DishonestThreshold: 1,
290+
SignerMaterial: &NativeSignerMaterial{
291+
Format: NativeSignerMaterialFormatFrostUniFFIV1,
292+
Payload: []byte{0xaa},
293+
},
294+
Attempt: &Attempt{
295+
Number: 3,
296+
CoordinatorMemberIndex: 1,
297+
IncludedMembersIndexes: []group.MemberIndex{1, 2, 3},
298+
},
299+
})
300+
if err == nil {
301+
t.Fatal("interactive-only mode must fail closed instead of using the coarse primitive")
302+
}
303+
if !errors.Is(err, ErrTerminalSigningFailure) {
304+
t.Fatalf("interactive-only refusal must be TERMINAL so the retry loop aborts: %v", err)
305+
}
306+
if !strings.Contains(err.Error(), InteractiveSigningOnlyEnvVar) {
307+
t.Fatalf("unexpected error (want a refusal naming %s): %v", InteractiveSigningOnlyEnvVar, err)
308+
}
309+
if primitive.signCalls != 0 {
310+
t.Fatalf(
311+
"coarse primitive must NOT be called in interactive-only mode, got %d call(s)",
312+
primitive.signCalls,
313+
)
314+
}
315+
}
316+
317+
func TestInteractiveSigningOnlyEnabled_ParsesFlag(t *testing.T) {
318+
t.Setenv(InteractiveSigningOnlyEnvVar, "")
319+
if InteractiveSigningOnlyEnabled() {
320+
t.Fatal("unset must be off")
321+
}
322+
t.Setenv(InteractiveSigningOnlyEnvVar, " TrUe ")
323+
if !InteractiveSigningOnlyEnabled() {
324+
t.Fatal("case-insensitive, trimmed true must be on")
325+
}
326+
t.Setenv(InteractiveSigningOnlyEnvVar, "false")
327+
if InteractiveSigningOnlyEnabled() {
328+
t.Fatal("false must be off")
329+
}
330+
}
331+
260332
func TestNativeExecutionFFIExecutorAdapter_RegisterUnmarshallers_Delegates(
261333
t *testing.T,
262334
) {

pkg/frost/signing/roast_interactive_signing_gate.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,31 @@ func InteractiveSigningOptInEnabled() bool {
3131
value := strings.TrimSpace(os.Getenv(InteractiveSigningOptInEnvVar))
3232
return strings.EqualFold(value, "true")
3333
}
34+
35+
// InteractiveSigningOnlyEnvVar is the no-coarse-fallback half of coarse-path
36+
// retirement (RFC-21 Phase 7.3). When set to "true", the executor REFUSES to fall
37+
// through to the coarse signing primitive: interactive signing is mandatory, and a
38+
// session where it does not run fails CLOSED rather than silently signing via the
39+
// retired coarse path. It is meant to be set ONLY together with the audit gate above
40+
// (and a registered engine) - setting it on its own makes signing fail closed.
41+
//
42+
// It stays OFF by default and is intended to remain off in production until the
43+
// frost-secp256k1-tr engine external audit clears and the tECDSA->FROST cutover is
44+
// made: flipping it on IS that cutover for this node (the coarse path is no longer
45+
// available as a fallback). Read per call, not cached, matching the audit gate.
46+
//
47+
// SCOPE: it presumes the node signs EXCLUSIVELY via the interactive tBTC-FROST path.
48+
// The refusal is format-agnostic (it fails closed for every signer format the native
49+
// executor handles, not only the tBTC-signer one); it closes BOTH the inner FFI coarse
50+
// primitive and the outer legacy fallbacks (the latter via nativeExecutionFallbackAllowed);
51+
// and in a build WITHOUT the interactive engine (no frost_native) it fails all native
52+
// signing closed. Enable it only on a node running the frost_native interactive engine
53+
// with the audit gate on.
54+
const InteractiveSigningOnlyEnvVar = "KEEP_CORE_FROST_INTERACTIVE_SIGNING_ONLY"
55+
56+
// InteractiveSigningOnlyEnabled reports whether interactive-only (no coarse
57+
// fallback) mode is currently set to "true" (case-insensitive, whitespace-trimmed).
58+
func InteractiveSigningOnlyEnabled() bool {
59+
value := strings.TrimSpace(os.Getenv(InteractiveSigningOnlyEnvVar))
60+
return strings.EqualFold(value, "true")
61+
}

0 commit comments

Comments
 (0)