diff --git a/pkg/tbtc/signer/docs/permissioned-signer-hardening-rfc.md b/pkg/tbtc/signer/docs/permissioned-signer-hardening-rfc.md index c5de3ffd97..b1eb2330c8 100644 --- a/pkg/tbtc/signer/docs/permissioned-signer-hardening-rfc.md +++ b/pkg/tbtc/signer/docs/permissioned-signer-hardening-rfc.md @@ -56,6 +56,85 @@ TEEs as prerequisites, while remaining compatible with either in future. | `P2-M2` Implementation diversity + differential fuzzing | Independent verification path or secondary implementation checks, differential/fuzz harnesses, divergence triage workflow | Security + Protocol | Differential CI runs continuously with no unresolved critical divergence | | `P2-M3` Canary rollout + instant rollback controls | 10%-50%-100% rollout policy, signer cohort canaries, one-command rollback and config pinning | Platform + Ops | Canary progression is automated by SLO gates; rollback is validated under incident drill | +### P0-M3 Rate-Limit Specification (Decision 7 extension) + +The P0-M3 milestone introduced per-call value/script-class rate +controls for the signing path (`BuildTaprootTx` and its peers), +mounted via the signing policy firewall. Decision 7 of the PR #4005 +review extends the same rate-limit discipline to the interactive +session entry points — `InteractiveSessionOpen` and +`InteractiveRound1` — because both are the canonical per-operator +amplifiers: a hostile or misconfigured operator can otherwise +inflate attempt throughput on a single key group without bound. +The shape mirrors the existing `BuildTaprootTx` rate-limit +configuration (token-bucket refill, env-var tunable, fail-closed +rejection) but uses two buckets per operation rather than one, so +that the per-caller and per-key-group budgets are independently +observable. + +#### `InteractiveSessionOpen` rate-limit buckets + +| Bucket | Scope | Trigger | Reason code on exhaustion | Env-var knob | Default | +| --- | --- | --- | --- | --- | --- | +| Primary | per-`(sender, key_group)`, includes the attempt-context fingerprint so each fresh attempt has independent budget (replay protection) | `InteractiveSessionOpen` for a given `(sender, key_group)` exceeds the budget | `interactive_rate_limit_exceeded` | `TBTC_SIGNER_INTERACTIVE_OPEN_RATE_LIMIT_PER_MINUTE` | 60/min | +| Cross-operator | per-`(member, key_group)`, aggregates across attempts to bound a member's effective work rate on a given wallet | Sum of Open calls for a `(member, key_group)` exceeds the cross-operator cap | `interactive_cross_operator_cap_exceeded` | `TBTC_SIGNER_INTERACTIVE_OPEN_CROSS_OPERATOR_CAP_PER_MINUTE` | 5/min | + +Both buckets are enforced at `InteractiveSessionOpen` (charged in order: +primary bucket first, then cross-operator cap), implemented by +`enforce_interactive_open_rate_limit` and +`enforce_interactive_open_cross_operator_cap` in `src/engine/policy.rs`. +The primary bucket is the per-caller throttle; the cross-operator cap is +the per-`(member, key_group)` cap that prevents a single operator from +inflating the effective budget by rotating `sender` identifiers or +attempt contexts. + +#### `InteractiveRound1` rate-limit bucket + +`InteractiveRound1` has its own independent primary bucket — it does +NOT reuse the Open cross-operator bucket. Implemented by +`enforce_interactive_round1_rate_limit` in `src/engine/policy.rs`. + +| Bucket | Scope | Trigger | Reason code on exhaustion | Env-var knob | Default | +| --- | --- | --- | --- | --- | --- | +| Primary | per-`(sender, key_group)`, includes the attempt-context fingerprint | `InteractiveRound1` for a given `(sender, key_group)` exceeds the budget | `interactive_round1_rate_limit_exceeded` | `TBTC_SIGNER_INTERACTIVE_ROUND1_RATE_LIMIT_PER_MINUTE` | 60/min | + +There is no separate cross-operator cap on `InteractiveRound1` today — +only the per-`(sender, key_group)` primary bucket. A cross-operator cap +on Round1 (mirroring Open's) is a candidate future hardening, not +implemented in Decision 7. + +#### Operator knobs and defaults + +All three knobs (`TBTC_SIGNER_INTERACTIVE_OPEN_RATE_LIMIT_PER_MINUTE`, +`TBTC_SIGNER_INTERACTIVE_OPEN_CROSS_OPERATOR_CAP_PER_MINUTE`, +`TBTC_SIGNER_INTERACTIVE_ROUND1_RATE_LIMIT_PER_MINUTE`) are env-var +tunable, follow the existing `TBTC_SIGNER_*_ENV` pattern from +`src/engine/config.rs`, and are parsed with the same bounded-parse +rule as the rest of the policy surface. Defaults: 60/min for both +primary buckets, 5/min for the cross-operator cap — deliberately much +tighter than the primary bucket, since a compromised or misbehaving +operator rotating `sender`/attempt identities is the threat this cap +specifically targets. Each rejection emits a structured policy event +with the `(sender, key_group)` / `(member, key_group)` tuple and the +active bucket state at the time of rejection; the rejection is +fail-closed (no exception carve-out for the host). All rate-limit +state is process-local (in-memory token buckets) and resets on signer +restart — it is never persisted or durable. + +#### Cumulative-rejection budget (Decision 8) + +The P0-M3 rate-limit discipline is independent of the +`wallet_deadline_exceeded` terminal error class from Decision 8. +Rate-limit rejections consume the per-(sender, key_group) and +cross-operator buckets but do NOT consume the wallet-level attempt +budget — a flood of rejects from a hostile operator therefore does +not exhaust the wallet's deadline. The wallet-level deadline is +only consumed by attempts that were admitted by all rate-limit +buckets and produced a signing-flow outcome. This separation is +load-bearing: keeping the rate-limit and the wallet-level deadline +independent means a rate-limited operator cannot DoS the wallet's +attempts by spending the deadline on its own rejections. + ## Acceptance Test Catalog ### P0 acceptance tests diff --git a/pkg/tbtc/signer/docs/phase-7-interactive-session-spec-freeze.md b/pkg/tbtc/signer/docs/phase-7-interactive-session-spec-freeze.md index f392841b18..8efbce8d56 100644 --- a/pkg/tbtc/signer/docs/phase-7-interactive-session-spec-freeze.md +++ b/pkg/tbtc/signer/docs/phase-7-interactive-session-spec-freeze.md @@ -21,11 +21,12 @@ included member can veto an attempt. Redemption signings adopt the path first (slashing-backed deadlines; gates-doc decision 5). Non-goals of this spec: bounded `n-t+1` concurrent attempts -(fast-follow — section 8 reserves the room it needs), DKG redesign -(the interactive DKG primitives ship as-is for now), and the wallet -recovery-leaf question (explicitly open; nothing here may bake in a -key-path-only assumption — the session layer takes the Taproot -merkle root as an input, as today). +(fast-follow — section 8 reserves the room it needs), DKG redesign: +DKG custody now follows the same opaque-handle design as the signing +path (per the section-4 extension below); the wallet recovery-leaf +question (explicitly open; nothing here may bake in a key-path-only +assumption — the session layer takes the Taproot merkle root as an +input, as today). ## 2. Inherited decisions (settled; cite, do not relitigate) @@ -122,20 +123,83 @@ the FFI and never persist.** atomically. This is also the audit story for the FFI boundary — scoped -precisely: after Phase 7, no secret material of the **signing -path** (key shares already env/command-only; now nonces too) -transits the Go/Rust interface in either direction. The interactive -**DKG** primitives are explicitly out of this spec's scope and -still hand secret round packages to the host (`dkg_part1` returns -`secret_package_hex`; `dkg_part2` accepts it back). DKG custody is -a named follow-up with the same design shape as section 4; until it -lands, the audit scope statement must describe the DKG boundary -as-is rather than inheriting this section's claim. - -> **FFI boundary (explicit):** the **DKG path is OUTSIDE this secret-boundary**. -> `dkg_part1` still produces `secret_package_hex` that crosses the FFI; -> subsequent DKG parts operate on hex deserialisation back to in-memory structs. -> The non-persistence guarantee is enforced only for the interactive signing path. +precisely: after Phase 7, **no secret material of the signing path +transits the Go/Rust interface in either direction**, and the +interactive **DKG** path now follows the same design shape (Decision +5): the secret round package for DKG is held inside the engine under +an opaque handle, and only the handle (a `u64`) crosses the FFI. +There is no longer an exception carved out for DKG in the audit +scope. + +### DKG custody, opaque handle (Decision 5) + +The distributed-DKG primitives (`dkg_part1` / `dkg_part2` / `dkg_part3`) +now keep the secret round package inside the engine process. The wire +shape is: + +* `DkgPart1Result` adds `session_handle: u64` alongside the public + `package: DkgRound1Package`. The handle is generated by an + incrementing counter at the moment `dkg_part1` succeeds; the + `secret_package_hex` is moved into the in-process table + `DKG_SESSION_STORAGE` in `src/engine/frost_ops.rs`, keyed by the + handle, and is **not** returned to the host. +* `DkgPart2Request` and `DkgPart3Request` take `session_handle: u64` + instead of `secret_package_hex`. The engine resolves the held + secret round package from `DKG_SESSION_STORAGE` by the handle and + uses it in-process; the host never re-serializes the round-1 + secret back into hex. +* `DkgPart2Result` adds `session_handle: u64` alongside the public + round-2 `packages: Vec`. The handle for the + carried secret round-2 package is the same counter type as + `DkgPart1Result`; the handle is returned to the host only so the + subsequent `DkgPart3Request` can carry it back. +* `dkg_part3` consumes the handle: on success, the entry in + `DKG_SESSION_STORAGE` for the consumed handle is removed + (consumption-marker pattern, see below). The function never + returns a handle, and the resulting `DkgPart3Result` carries only + the public `key_package` / `public_key_package`. + +The handle is a monotonic counter, scoped to the engine process. It +is not durable: a signer restart loses any in-flight DKG handles by +construction (mirroring the nonce-custody rule above), so the +operator restart pattern is the same as for a crashed interactive +attempt — the in-flight DKG must be re-initiated from `dkg_part1` +after the restart. The handle table is single-process: two engines +in the same process should not see the same handle counter value in +practice, and the table is not coordinated across processes. + +### Consumption-marker pattern (replay protection) + +Once `dkg_part3` is called with a handle, that handle is removed +from `DKG_SESSION_STORAGE` and cannot be reused. A second call to +`dkg_part3` with the same handle — by the same host or by a hostile +host that captured the request bytes — fails closed with a +structured `DKG_SESSION_HANDLE_NOT_FOUND` / the equivalent +structured rejection in `src/engine/frost_ops.rs`. The same +consumption rule applies to the round-1 and round-2 handles +indirectly: each `dkg_part*` consumes its own handle input on +success, so a replayed request that succeeds in any part is followed +by a failed successor rather than a second successful emission. +This is the DKG analogue of the `consumed_nonce_replay` error in +the interactive signing path and is the load-bearing piece of the +no-replay story for the DKG custody boundary. + +The marker is process-local and is not written to durable state +(mirroring the "markers-only" rule from §10.4 for the signing +path). A restart re-creates the empty table on engine init; the +audit story is the same: two clones cannot both complete a DKG +under the same round-1 package, because only the holder of the +process-local handle can call `dkg_part2` and `dkg_part3` against +that secret, and the consumption rule prevents a valid retry once +the handle has been removed. + +> **FFI boundary (updated):** the DKG path is now INSIDE the +> secret-boundary. The `session_handle: u64` is the only DKG-side +> field that crosses the FFI for the secret package; the secret +> package itself is held in `DKG_SESSION_STORAGE` and never +> serialized back to the host. The non-persistence guarantee is +> enforced for both the interactive signing path and the interactive +> DKG path with the same consumption-marker pattern. ## 5. Session model and API contract diff --git a/pkg/tbtc/signer/docs/roast-coordinator-seed-derivation.md b/pkg/tbtc/signer/docs/roast-coordinator-seed-derivation.md index 844ff46d1d..5db87abf60 100644 --- a/pkg/tbtc/signer/docs/roast-coordinator-seed-derivation.md +++ b/pkg/tbtc/signer/docs/roast-coordinator-seed-derivation.md @@ -1,5 +1,58 @@ # Coordinator-shuffle seed derivation (RFC-21 Annex A mirror) +## Status: HKDF+CSPRNG migration reverted (Decision 2) + +The HKDF-SHA256 + CSPRNG coordinator-shuffle migration proposed in the +PR #4005 review (originally decided as the new normative derivation) +was attempted and reverted during implementation. The reverted attempt +is documented here for the audit trail; the rest of this file describes +the **current** derivation, which is unchanged from the prior +unification-PR state. + +**What happened.** The migration's HKDF pull-in surfaced a +`digest` crate version conflict between `sha2` (pinned at `0.10` in +`Cargo.toml`) and the `hkdf` crate that the migration would have +required, with the resolver landing on a configuration that broke +downstream `frost-secp256k1-tr` compilation. Decision 2 was reverted +rather than repinned, because the cost of reshuffling the ciphersuite +dependency chain for a derivation change that was not security-binding +on `frost-core` 3.x was disproportionate to the benefit. + +**What the current derivation actually is.** The current derivation +remains the Go-`math/rand` port: `GoMathRandShuffle` in +`src/go_math_rand.rs`, the 607-element `RNG_COOKED` table, and the +four-line `roast_attempt_shuffle_seed` derivation in `src/engine/roast.rs` +described in the "Derivation" and "Conformance vectors" sections +below. No behavior changed. + +**What the reverted migration would have kept.** The design called for +a single `COORDINATOR_SHUFFLE_VERSION: u8 = 1` byte (the prior unified +state) to be fed into the attempt-context hash as a version-pin. This +byte does not exist in the current derivation — `roast_attempt_shuffle_seed` +in `src/engine/roast.rs` hashes only `key_group || session_id || +rfc21_message_digest`, with no version byte. The design intent is that +the byte would be intentionally inert at version `1` — it would not +alter the computed seed or the selected coordinator — but would +establish the versioning slot so a future real migration could bump to +`2` (or higher) without confusing the conformance corpus. Bumping the +version byte would deliberately drop compatibility with the existing +test vectors and force a documented re-pinning, which is the whole +point of the pin. **None of this is implemented today; it is a design +note for the eventual HKDF-SHA256 + CSPRNG migration, not a description +of current behavior.** + +**Open follow-up.** The HKDF-SHA256 + CSPRNG migration itself is +tracked as a follow-up to PR #4005. Until that follow-up lands and +bumps `COORDINATOR_SHUFFLE_VERSION`, the normative derivation is the +Go-port described below; the "Status" note at the top of this file +is the load-bearing caveat for any reader cross-referencing the +PR #4005 review's original Decision 2. + +This status note is appended to the top of the file (rather than +replacing the body) so that pre-revert readers and the +`coordinator_seed_derivation_matches_cross_language_vectors` +conformance pinning remain diff-stable. + The normative definition of the ROAST coordinator-shuffle seed lives in keep-core's RFC-21, *Annex A (normative): coordinator-shuffle seed derivation* diff --git a/pkg/tbtc/signer/docs/roast-phase-0-spec-freeze.md b/pkg/tbtc/signer/docs/roast-phase-0-spec-freeze.md index ab082e88b0..748b46c8fd 100644 --- a/pkg/tbtc/signer/docs/roast-phase-0-spec-freeze.md +++ b/pkg/tbtc/signer/docs/roast-phase-0-spec-freeze.md @@ -140,6 +140,32 @@ Mapping guidance: `attempt_id_mismatch`, and `attempt_transition_unauthorized` as non-retriable for that attempt payload. +### Addendum: Phase 7 + PR #4005 decisions (append-only) + +The codes below are appended to the Phase 0 error taxonomy by +Phase 7 and by the PR #4005 review decisions. They are listed +separately from the frozen Phase 0 table above so that the Phase 0 +contract remains diff-stable for any reader still pinning the +original table. Mapping guidance and the non-retriable guidance +from the original §8 apply unchanged to the addendum entries. + +| Code | Meaning | Decision / phase | +| --- | --- | --- | +| `consumed_nonce_replay` | A second `InteractiveRound2` call against a `(session_id, attempt_id, member_identifier)` tuple whose engine-held nonces have already been consumed (signature share released, or consumption marker durably committed). Caller must mint a fresh attempt; the engine will never release a second share under one nonce pair. Stable code in `src/errors.rs`; produced by `EngineError::ConsumedNonceReplay`. | Phase 7 §4 (frozen) | +| `interactive_attempt_already_aggregated` | `InteractiveAggregate` invoked again for an attempt that already produced an aggregate signature in this session. The per-attempt "aggregated" marker is durable; re-aggregation is rejected rather than recomputed (a lost signature is recovered with a fresh attempt, not by replay). Stable code in `src/errors.rs`; produced by `EngineError::InteractiveAttemptAlreadyAggregated`. Distinct from `consumed_nonce_replay` because the marker is "aggregated", not "nonce consumed". | Phase 7 §5 (frozen) | +| `wallet_deadline_exceeded` | New terminal error class. The cumulative ROAST attempt budget for the wallet/session has been exceeded across the entire retry chain — the *wallet-level* deadline, not the per-attempt `attempt_exhausted` recoverable cap. Distinct from `attempt_exhausted`, which is recoverable (the caller can mint a new attempt within the cap); `wallet_deadline_exceeded` is terminal for the signing request and the wallet must be re-armed (e.g., via the operator's `persist_distributed_dkg_key_package` reset pathway or an explicit wallet re-arming procedure) before any further attempt is accepted. Surfaced as a structured rejection with the wallet-identifying context. | Decision 8 (PR #4005) | +| `interactive_rate_limit_exceeded` | Policy-rejection code returned by `InteractiveSessionOpen` when the per-`(sender, key_group)` primary bucket is exhausted. Distinct from the existing `rate_limit_per_minute_exceeded` reason used for `BuildTaprootTx`; this addendum code carries the `(sender, key_group)` tuple in the structured reject payload so the Go host can surface the per-key-group attribution. The cross-operator `(member, key_group)` cap is enforced at the same entry point and surfaces as `interactive_cross_operator_cap_exceeded` (see below). | Decision 7 (PR #4005) | +| `interactive_round1_rate_limit_exceeded` | Policy-rejection code returned by `InteractiveRound1` when its OWN independent per-`(sender, key_group)` primary bucket is exhausted. This is NOT the cross-operator cap — the cross-operator `(member, key_group)` cap is enforced at `InteractiveSessionOpen` (charged in order: primary bucket, then cross-operator cap) and surfaces as `interactive_cross_operator_cap_exceeded`, not as this code. `InteractiveRound1` has no cross-operator cap of its own. Both buckets are fail-closed and consume the rate-limit decrement before the reject is returned. | Decision 7 (PR #4005) | +| `interactive_cross_operator_cap_exceeded` | Policy-rejection code returned by `InteractiveSessionOpen` when the per-`(member, key_group)` cross-operator cap is exceeded. The cross-operator cap aggregates across attempts to bound a member's effective work rate on a given wallet even when the member rotates `sender` identifiers or attempt contexts, so the primary per-`(sender, key_group)` bucket alone cannot police it. Charged at `InteractiveSessionOpen` only, never at `InteractiveRound1`. | Decision 7 (PR #4005) | +| `frost_shadow_mode_advisory` | Audit signal (not an error code emitted as a rejection) emitted when a FROST signing output is gated to advisory-only under the FROST shadow mode (Decision 1). The signal is emitted on every gated output regardless of the final success/failure of the surrounding handshake; downstream observers consume the signal to confirm the shadow mode is active and to attribute the gated output to the caller. Pairs with the `TBTC_SIGNER_FROST_SHADOW_MODE` env var and the three-condition disjunction documented in `roast-phase-5-security-rollout-gates.md`. | Decision 1 (PR #4005) | + +`wallet_deadline_exceeded` is **terminal**; the other five Phase 7 +and Decision 7 codes are **recoverable** in the same sense as the +existing `attempt_exhausted` (the caller may mint a new attempt +subject to the budget). `frost_shadow_mode_advisory` is an audit +signal, not a rejection — it is never returned in the response +status, only emitted on the audit channel. + ## 9. Replay, Restart, And Concurrency Invariants 1. Attempt id is single-use for a given `(session_id, message, cohort)` flow. diff --git a/pkg/tbtc/signer/docs/roast-phase-5-security-rollout-gates.md b/pkg/tbtc/signer/docs/roast-phase-5-security-rollout-gates.md index 451b18853e..adced3cd2f 100644 --- a/pkg/tbtc/signer/docs/roast-phase-5-security-rollout-gates.md +++ b/pkg/tbtc/signer/docs/roast-phase-5-security-rollout-gates.md @@ -43,8 +43,20 @@ Required before stage 1 canary: Recommended stages: 1. Stage 1: 10% signer fleet / limited wallet cohort, hold for 24h. + The Stage 1 cohort ships under the `advisory_only` FROST shadow mode + (see the FROST Shadow Mode section below) — FROST-derived signatures + are gated to advisory output and the legacy path remains the + release-able channel. Operators do not change the shadow mode for + Stage 1. 2. Stage 2: 50% signer fleet / broader cohort, hold for 24h. 3. Stage 3: 100% rollout after Phase 5 acceptance criteria remain green. + Flipping Stage 3 to the `production` FROST shadow mode is gated by + the FROST Shadow Mode section's three-condition disjunction (audit + report / enforced TEE attestation / keep-core PR #4044); the + cohort-percent promotion and the shadow-mode promotion are + independent decisions and Stage 3 may roll forward at 100% with + `advisory_only` for the cohort-percent leg if the shadow-mode + disjunction is not yet satisfied. The executable promotion gate requires, for each stage, at least 100 successful samples from Interactive Round1, Interactive Round2, and Interactive Aggregate, @@ -55,6 +67,85 @@ stage rebuilds its window. Fast failures and idempotent replays are excluded. Operators can tune the bounded minimum/window and the three per-operation p95 thresholds with the `TBTC_SIGNER_CANARY_*` knobs documented in `README.md`. +## FROST Shadow Mode + +Decision 1 introduced a software-side rollout gate for the FROST signing +output that is conceptually distinct from the cohort-based canary of +Gate 3: `FROST_SHADOW_MODE` controls how the engine treats a signing +output that is FROST-derived rather than the legacy ECDSA-derived +one. The mode is selected by the operator at process start; it is +**not** hot-reloaded (env vars are parsed at init only), and the +existing provenance gate already enforces an operator-supplied +attestation set on every load. + +### Mode values + +| Mode | Default? | Behavior | When appropriate | +| --- | --- | --- | --- | +| `advisory_only` | yes | FROST signing outputs are produced under their normal handshake, but the resulting signature is **not** released to the host. The engine emits a `frost_shadow_mode_advisory` audit signal (Decision 1) for every gated output and the host continues with the legacy ECDSA path. This is the **fail-closed** default. | All canary cohorts of Gate 3 Stage 1 and Stage 2 (testnet and pre-ECDSA-retirement production rehearsal). | +| `internal_canary` | no | FROST signing outputs are produced and released but tagged with the `internal_canary` shadow marker for downstream observers; the legacy path is retained as a backstop for the canary window. Capacity-controlled; the operator is expected to keep this mode on a bounded cohort. | Internal testnet and post-audit staging, when a downstream observer exists to consume the `internal_canary` marker. | +| `production` | no | FROST signing outputs are produced and released without the shadow marker; the legacy path is dropped from the output chain. | Production, only after ALL THREE gate conditions below are met. | + +### Gate conditions for `production` + +The `production` mode is **only** appropriate when AT LEAST ONE of the +following holds. (This is a disjunction-of-hard-gates, not a graded +score; "we have one of the three" is sufficient to flip the mode, but +operators should still prefer the strongest available justification.) + +1. **External audit report.** A completed external audit covering + `frost-core` 3.x and the `frost-secp256k1-tr` ciphersuite, as + required by the Decision Log entry 1 "external audit = hard gate + for ECDSA retirement" clause. The audit report must be attached to + the release decision (the Evidence Checklist, item 5). +2. **Enforced TEE attestation.** TEE enforcement is active, the + per-call measurement check passes, and the operator's trust roots + ratify the active attestation set (P0-M2 and the future + `pkg/tbtc/signer/docs/tee-whitelisted-signer-enforcement-plan.md`). + Without TEE enforcement actually running on the cohort, an + attestation set alone is not enough. +3. **keep-core PR #4044 proof-carrying blame.** The proof-carrying + blame stack from PR #4044 (the `EquivocationEvidence` + instrumentation + retention condition; see Decision Log entry 4) + is merged and shipping in the active keep-core build, so an + equivocating coordinator is detected and attributable rather than + merely observed. + +Until at least one of these three holds, `production` MUST NOT be +selected — the engine will accept the env var but the resulting +signature carries no FROST-side blame attribution, which is the whole +point of the migration. The `advisory_only` default is the safe +starting state for every new operator install. + +### Operator procedure for transitioning modes + +The mode is selected by the `TBTC_SIGNER_FROST_SHADOW_MODE` env var. +The recognized values are `advisory_only`, `internal_canary`, and +`production`; any other value is treated as `advisory_only` (fail +closed). The env var is read once at signer init; **env vars are not +hot-reloaded**. To change the mode, an operator: + +1. Edits the operator-supplied unit/env file to set + `TBTC_SIGNER_FROST_SHADOW_MODE` to the desired mode. +2. Restarts the signer process (the env var is consumed during init, + then frozen for the lifetime of the process). +3. Verifies the new mode in the engine's startup log line + (`frost_shadow_mode_active=…`) — the line is emitted on every + protected-operation entry, so an operator can confirm the active + mode without dumping the full env. The log line is sanitized via + `sanitize_policy_log_field` (no identifier cross-leakage). + +### Testnet canary currently ships under `advisory_only` + +The testnet canary cohort of Gate 3 Stage 1 currently ships under +the default `advisory_only` mode. Operators should not change the +mode for the testnet canary cohort until the audit/TEE blame +conditions in the table above are met for the testnet cohort +specifically. Bumping the mode for a canary cohort that does not +yet have blame attribution defeats the purpose of the canary and +reintroduces the F1-class bug surface (round-nonce-v3) that the +shadow mode is designed to guard against. + ## Cryptographic Dependency Audit Status (Gate 1 Input) The signer pins `frost-secp256k1-tr = "=3.0.0"` (`Cargo.toml`), the Zcash