Skip to content

Commit e4a8734

Browse files
authored
feat(tbtc/signer): Phase 7.1 hardened interactive signing session (#4051)
## What Phase 7.1 of the frozen spec ([#4049](#4049), `docs/phase-7-interactive-session-spec-freeze.md` §§4–5): the engine session layer for the interactive two-round signing path, with **engine-held nonce custody** as the defining property. ## The custody contract (spec §4) - Round-1 nonces: OS randomness, in-memory only, bound to `(session_id, attempt_id)`, zeroized on consumption/abort/expiry/replacement, **never** in any request, response, or persisted state. - **Consumption-before-release**: the durable per-attempt marker persists BEFORE the share leaves the engine. Persist failure → marker rolled back, nonces stay live, no share escaped (pinned by a persist-fault-injection test). Marker-without-share → attempt dead, fail closed. - Restart can never yield a second share under one nonce pair; the cloned-state nonce-reuse class is structurally eliminated. Pinned by a restart test: the marker survives reload and rejects the consumed attempt at every entry point while a fresh attempt proceeds. ## The API (spec §5, strict-mode only) | Call | Contract highlights | |---|---| | `InteractiveSessionOpen` | Key package once per session (validated against member); idempotent by fingerprint; conflicting reopen fails closed; **newer attempt implicitly aborts the prior live one**; consumed attempts rejected | | `InteractiveRound1` | Fresh nonces + commitments; idempotent until consumed | | `InteractiveRound2` | **All verification precedes consumption**: message binding, subset ⊆ included set, exactly-`t` size, own membership, and check (f) — own-commitment byte-identity, defeating coordinator framing of honest members | | `InteractiveSessionAbort` | Idempotent; destroys nonces without a marker (never-consumed attempts may reopen with fresh nonces) | Live-state bounds per the spec: fail-closed capacity cap (`TBTC_SIGNER_MAX_LIVE_INTERACTIVE_SESSIONS`, default 64) + lazy TTL sweep with abort semantics (`TBTC_SIGNER_INTERACTIVE_SESSION_TTL_SECONDS`, default 3600); both knobs ride the init-config surface. New structured error `consumed_nonce_replay`. Telemetry: call/success counters ×4, latency for the two cryptographic rounds. The four `frost_tbtc_interactive_*` FFI exports ship additively per the established pattern (Go adoption is Phase 7.3; nothing breaks until the host calls them). ## Verification - **10 engine tests**: e2e round trip with one member through the session API and one through the stateless primitive, aggregating to a **verified BIP-340 signature** (custody changed, cryptography didn't); framing-attack rejection then honest-package acceptance (verify-before-consume); package-shape rejections (outside-set, message mismatch, oversized, self-missing); round-1 idempotency → consumed replay; restart-marker durability; persist-fault rollback; open lifecycle (idempotent/conflict/replacement); abort; TTL expiry; capacity fail-closed. - **1 FFI dispatch smoke test** across all four exports. - Full suite **255 passed / 1 ignored** (baseline 244+1 plus the 11 new), `clippy -D warnings` clean on all targets incl. the bench shape, **Phase 5 chaos suite green**, persisted-state schema additive (existing state files load unchanged). ## Scope boundary `InteractiveAggregate` + package-envelope evidence + cross-language vectors are Phase 7.2 by the frozen phasing; Go orchestration is 7.3. Out of scope per the freeze: DKG secret-package custody (named follow-up). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
2 parents 5d65712 + 4940a9c commit e4a8734

13 files changed

Lines changed: 2900 additions & 14 deletions

File tree

pkg/tbtc/signer/docs/phase-7-interactive-session-spec-freeze.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,17 @@ self-contained):
145145
mode is the only mode here: no legacy-shape fallback), checks
146146
policy gates and provenance, registers the session. Idempotent
147147
by full-request fingerprint; conflicting reopen fails closed.
148+
**The member's key package is resolved from the session's own DKG
149+
state (run_dkg), NOT carried in the request** — so the session
150+
must already exist with completed DKG, and no signing secret
151+
crosses the FFI/host boundary (section 4). This is a correction to
152+
an earlier draft of this spec that had Open accept the key package
153+
in the request; accepting it would have left key shares outside
154+
the engine and defeated the sidecar's signing-secret boundary. A
155+
request `threshold` is still carried but must equal the DKG
156+
threshold. As a consequence, an interactive session always rides a
157+
DKG-populated session and never creates registry entries of its
158+
own.
148159
2. `InteractiveRound1` — fresh nonces + commitments as in section
149160
4. Per (session, attempt, member) at most one live handle;
150161
repeat calls return the same commitments (idempotent) until

pkg/tbtc/signer/include/frost_tbtc.h

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,22 @@ TbtcSignerResult frost_tbtc_finalize_sign_round(const uint8_t* request_ptr, size
5757
TbtcSignerResult frost_tbtc_build_taproot_tx(const uint8_t* request_ptr, size_t request_len);
5858
TbtcSignerResult frost_tbtc_refresh_shares(const uint8_t* request_ptr, size_t request_len);
5959

60+
/*
61+
* Phase 7.1 hardened interactive signing session.
62+
*
63+
* Unlike the stateless nonce contract above, secret nonces NEVER cross this
64+
* boundary in either direction: the engine generates, holds, consumes, and
65+
* zeroizes them internally, keyed by (session_id, attempt_id). The caller
66+
* exchanges only public commitments, signing packages, and signature shares.
67+
* frost_tbtc_interactive_round2 verifies the coordinator's signing package in
68+
* full and consumes the attempt's nonces exactly once; a repeat call for a
69+
* consumed attempt fails closed with the `consumed_nonce_replay` error code.
70+
*/
71+
TbtcSignerResult frost_tbtc_interactive_session_open(const uint8_t* request_ptr, size_t request_len);
72+
TbtcSignerResult frost_tbtc_interactive_round1(const uint8_t* request_ptr, size_t request_len);
73+
TbtcSignerResult frost_tbtc_interactive_round2(const uint8_t* request_ptr, size_t request_len);
74+
TbtcSignerResult frost_tbtc_interactive_session_abort(const uint8_t* request_ptr, size_t request_len);
75+
6076
#ifdef __cplusplus
6177
}
6278
#endif

pkg/tbtc/signer/src/api.rs

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,87 @@ pub struct SignShareResult {
146146
pub signature_share: NativeFrostSignatureShare,
147147
}
148148

149+
// Phase 7.1 hardened interactive signing session (frozen spec
150+
// docs/phase-7-interactive-session-spec-freeze.md, section 5). Unlike
151+
// the stateless primitives above, secret nonces NEVER appear in these
152+
// requests or results: the engine generates, holds, consumes, and
153+
// zeroizes them internally, keyed by (session_id, attempt_id).
154+
155+
#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
156+
pub struct InteractiveSessionOpenRequest {
157+
pub session_id: String,
158+
pub member_identifier: u16,
159+
pub message_hex: String,
160+
pub key_group: String,
161+
/// Signing threshold; must equal the session's DKG threshold. The
162+
/// key material itself is resolved from the engine's DKG state and
163+
/// is never carried in this request - no signing secret crosses the
164+
/// FFI (frozen spec section 4).
165+
pub threshold: u16,
166+
#[serde(default, skip_serializing_if = "Option::is_none")]
167+
pub taproot_merkle_root_hex: Option<String>,
168+
/// Required: interactive sessions are strict-mode only; there is
169+
/// no legacy-shape fallback on this path.
170+
pub attempt_context: AttemptContext,
171+
}
172+
173+
#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
174+
pub struct InteractiveSessionOpenResult {
175+
pub session_id: String,
176+
pub attempt_id: String,
177+
pub idempotent: bool,
178+
}
179+
180+
#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
181+
pub struct InteractiveRound1Request {
182+
pub session_id: String,
183+
pub attempt_id: String,
184+
pub member_identifier: u16,
185+
}
186+
187+
#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
188+
pub struct InteractiveRound1Result {
189+
/// The member's public signing commitments. Idempotent until the
190+
/// attempt's nonces are consumed; the secret nonces they
191+
/// correspond to never leave the engine.
192+
pub commitments_hex: String,
193+
}
194+
195+
#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
196+
pub struct InteractiveRound2Request {
197+
pub session_id: String,
198+
pub attempt_id: String,
199+
pub member_identifier: u16,
200+
/// The coordinator's signing package (the chosen responsive
201+
/// subset's commitment list). Verified in full - membership,
202+
/// subset-of-included, exact threshold size, message binding, and
203+
/// byte-identity of this member's own commitment entry - BEFORE
204+
/// the nonces are consumed.
205+
pub signing_package_hex: String,
206+
}
207+
208+
#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
209+
pub struct InteractiveRound2Result {
210+
pub session_id: String,
211+
pub attempt_id: String,
212+
pub signature_share_hex: String,
213+
}
214+
215+
#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
216+
pub struct InteractiveSessionAbortRequest {
217+
pub session_id: String,
218+
/// When set, abort only if the live attempt matches; when unset,
219+
/// abort whatever attempt is live for the session.
220+
#[serde(default, skip_serializing_if = "Option::is_none")]
221+
pub attempt_id: Option<String>,
222+
}
223+
224+
#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
225+
pub struct InteractiveSessionAbortResult {
226+
pub session_id: String,
227+
pub aborted: bool,
228+
}
229+
149230
#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
150231
pub struct AggregateRequest {
151232
pub signing_package_hex: String,
@@ -521,6 +602,30 @@ pub struct SignerHardeningMetricsResult {
521602
pub finalize_sign_round_latency_samples: u64,
522603
pub refresh_shares_latency_p95_ms: u64,
523604
pub refresh_shares_latency_samples: u64,
605+
#[serde(default)]
606+
pub interactive_session_open_calls_total: u64,
607+
#[serde(default)]
608+
pub interactive_session_open_success_total: u64,
609+
#[serde(default)]
610+
pub interactive_round1_calls_total: u64,
611+
#[serde(default)]
612+
pub interactive_round1_success_total: u64,
613+
#[serde(default)]
614+
pub interactive_round2_calls_total: u64,
615+
#[serde(default)]
616+
pub interactive_round2_success_total: u64,
617+
#[serde(default)]
618+
pub interactive_session_abort_calls_total: u64,
619+
#[serde(default)]
620+
pub interactive_session_abort_success_total: u64,
621+
#[serde(default)]
622+
pub interactive_round1_latency_p95_ms: u64,
623+
#[serde(default)]
624+
pub interactive_round1_latency_samples: u64,
625+
#[serde(default)]
626+
pub interactive_round2_latency_p95_ms: u64,
627+
#[serde(default)]
628+
pub interactive_round2_latency_samples: u64,
524629
pub last_updated_unix: u64,
525630
}
526631

@@ -565,6 +670,10 @@ pub struct InitSignerConfigRequest {
565670
#[serde(default, skip_serializing_if = "Option::is_none")]
566671
pub max_sessions: Option<u64>,
567672
#[serde(default, skip_serializing_if = "Option::is_none")]
673+
pub max_live_interactive_sessions: Option<u64>,
674+
#[serde(default, skip_serializing_if = "Option::is_none")]
675+
pub interactive_session_ttl_seconds: Option<u64>,
676+
#[serde(default, skip_serializing_if = "Option::is_none")]
568677
pub state_key_provider: Option<String>,
569678
#[serde(default, skip_serializing_if = "Option::is_none")]
570679
pub state_key_command: Option<String>,

pkg/tbtc/signer/src/engine/config.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,20 @@ pub(crate) const TBTC_SIGNER_MAX_SESSIONS_ENV: &str = "TBTC_SIGNER_MAX_SESSIONS"
5151

5252
pub(crate) const TBTC_SIGNER_DEFAULT_MAX_SESSIONS: usize = 1024;
5353

54+
// Phase 7.1 interactive session bounds. Live interactive sessions hold
55+
// secret nonces in memory, so they get a dedicated, smaller cap than
56+
// the overall session registry, and a TTL after which an abandoned
57+
// attempt's nonces are destroyed (expiry has abort semantics).
58+
pub(crate) const TBTC_SIGNER_MAX_LIVE_INTERACTIVE_SESSIONS_ENV: &str =
59+
"TBTC_SIGNER_MAX_LIVE_INTERACTIVE_SESSIONS";
60+
61+
pub(crate) const TBTC_SIGNER_DEFAULT_MAX_LIVE_INTERACTIVE_SESSIONS: usize = 64;
62+
63+
pub(crate) const TBTC_SIGNER_INTERACTIVE_SESSION_TTL_SECONDS_ENV: &str =
64+
"TBTC_SIGNER_INTERACTIVE_SESSION_TTL_SECONDS";
65+
66+
pub(crate) const TBTC_SIGNER_DEFAULT_INTERACTIVE_SESSION_TTL_SECONDS: u64 = 3600;
67+
5468
pub(crate) const TBTC_SIGNER_STATE_LOCKFILE_SUFFIX: &str = ".lock";
5569

5670
pub(crate) const TBTC_SIGNER_ALLOW_BOOTSTRAP_ENV: &str = "TBTC_SIGNER_ALLOW_BOOTSTRAP";

pkg/tbtc/signer/src/engine/init_config.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,16 @@ pub(crate) fn config_values_from_request(
267267
TBTC_SIGNER_MAX_SESSIONS_ENV,
268268
request.max_sessions,
269269
);
270+
insert_u64(
271+
&mut values,
272+
TBTC_SIGNER_MAX_LIVE_INTERACTIVE_SESSIONS_ENV,
273+
request.max_live_interactive_sessions,
274+
);
275+
insert_u64(
276+
&mut values,
277+
TBTC_SIGNER_INTERACTIVE_SESSION_TTL_SECONDS_ENV,
278+
request.interactive_session_ttl_seconds,
279+
);
270280
insert_u64(
271281
&mut values,
272282
TBTC_SIGNER_STATE_KEY_COMMAND_TIMEOUT_SECS_ENV,

0 commit comments

Comments
 (0)