Skip to content

Commit 5fae0df

Browse files
mswilkisonclaude
andcommitted
fix(frost): close outer native fallbacks + classify the refusal terminal
Fold of two Codex #4101 P2 findings: - P2-1 (suppress outer fallbacks): the interactive-only guard lived only inside the FFI adapter, so when the native FFI path was unavailable (ErrNativeCryptographyUnavailable before the adapter's guard) the OUTER buildTaggedNativeExecutionBridge/Adapter still delegated to the legacy backend, because nativeExecutionFallbackAllowed() stayed true. Gate that single function on the flag: interactive-only now returns false there, closing every outer legacy/coarse fallback (the bridge + adapter consult it before delegating). New backend test asserts the suppression. - P2-2 (terminal classification): the adapter's refusal returned a plain error, so the tBTC signingRetryLoop (which only aborts on ErrTerminalSigningFailure) treated this deterministic configuration failure as retryable and spun to timeout. Wrap the refusal with %w ErrTerminalSigningFailure; the adapter test now asserts errors.Is. Also folds my own review's scope notes into the gate doc: interactive-only is format-agnostic (refuses coarse for every signer format the native executor handles), closes both the inner FFI primitive and the outer fallbacks, and fails all native signing closed in a build without the interactive engine - so enable it only on a frost_native node with the audit gate on. Builds across all tag combos; full default + frost_native/frost_roast_retry suites pass; gofmt clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 57fa35a commit 5fae0df

5 files changed

Lines changed: 48 additions & 6 deletions

File tree

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: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,26 @@ 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+
179199
func TestSetExecutionBackendByName_NativeFailureRestoresPreviousMode(
180200
t *testing.T,
181201
) {

pkg/frost/signing/native_ffi_executor_adapter.go

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -148,13 +148,14 @@ func (nefea *nativeExecutionFFIExecutorAdapter) Execute(
148148
// Interactive-only mode (coarse-path retirement): the orchestration produced
149149
// no interactive signature - the audit gate is off, no engine is registered,
150150
// OR any static fallback fired (readiness off, no coordinator, unsupported
151-
// material). This is the SINGLE point where the coarse primitive is invoked,
152-
// so refusing here fails CLOSED on every fall-through path rather than
153-
// silently signing over the retired coarse path.
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.
154155
return nil, fmt.Errorf(
155-
"interactive-only signing mode (%s) is set but interactive signing did not run "+
156-
"(%s off, no engine, or static fallback); refusing the coarse fallback",
157-
InteractiveSigningOnlyEnvVar, InteractiveSigningOptInEnvVar,
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,
158159
)
159160
}
160161

pkg/frost/signing/native_ffi_executor_adapter_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,9 @@ func TestNativeExecutionFFIExecutorAdapter_Execute_InteractiveOnlyRefusesCoarse(
300300
if err == nil {
301301
t.Fatal("interactive-only mode must fail closed instead of using the coarse primitive")
302302
}
303+
if !errors.Is(err, ErrTerminalSigningFailure) {
304+
t.Fatalf("interactive-only refusal must be TERMINAL so the retry loop aborts: %v", err)
305+
}
303306
if !strings.Contains(err.Error(), InteractiveSigningOnlyEnvVar) {
304307
t.Fatalf("unexpected error (want a refusal naming %s): %v", InteractiveSigningOnlyEnvVar, err)
305308
}

pkg/frost/signing/roast_interactive_signing_gate.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,14 @@ func InteractiveSigningOptInEnabled() bool {
4343
// frost-secp256k1-tr engine external audit clears and the tECDSA->FROST cutover is
4444
// made: flipping it on IS that cutover for this node (the coarse path is no longer
4545
// 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.
4654
const InteractiveSigningOnlyEnvVar = "KEEP_CORE_FROST_INTERACTIVE_SIGNING_ONLY"
4755

4856
// InteractiveSigningOnlyEnabled reports whether interactive-only (no coarse

0 commit comments

Comments
 (0)