Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions pkg/tbtc/signer/docs/permissioned-signer-hardening-rfc.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
102 changes: 83 additions & 19 deletions pkg/tbtc/signer/docs/phase-7-interactive-session-spec-freeze.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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<DkgRound2Package>`. 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

Expand Down
53 changes: 53 additions & 0 deletions pkg/tbtc/signer/docs/roast-coordinator-seed-derivation.md
Original file line number Diff line number Diff line change
@@ -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*
Expand Down
26 changes: 26 additions & 0 deletions pkg/tbtc/signer/docs/roast-phase-0-spec-freeze.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading