Skip to content

Commit 9fd9845

Browse files
authored
Phase 7.3: engine-side DeriveInteractiveAttemptContext helper (#4077)
## Phase 7.3 — engine-side attempt-context derivation (Option B, PR 1 of 2) Per the Codex+Gemini design consult, the host must not re-implement the engine's domain-separated attempt-context derivations (the seed-divergence class). This PR exposes them from the engine. ### What - New FFI export `frost_tbtc_derive_interactive_attempt_context` → `engine::derive_interactive_attempt_context`. Stateless, secret-free: takes an attempt's public inputs, returns the canonical `AttemptContext` (coordinator, included-participants fingerprint, attempt id) + one canonical FROST identifier per participant. - Composes the existing ROAST primitives and **re-validates the derived context against strict-mode `validate_attempt_context`** before returning, so the host receives a context `interactive_session_open` is guaranteed to accept. ### Why The interactive runner ships placeholder fingerprint/attempt_id/identifier values documented as inert (only the fake engine is wired). The real engine strict-validates those fields; deriving them in Go would duplicate domain-separated hashing across languages. Exposing from the engine = single source of truth. ### Tests - Engine units: matches the standalone derivations (coordinator, fingerprint, attempt_id, canonical ordering, identifier encoding); deterministic; rejects empty message / zero attempt number / threshold>set / duplicate / empty participants. - FFI success round-trip (the helper succeeds with no DKG fixture). - `cargo fmt` + `clippy --all-targets --all-features -D warnings` clean; full lib suite 289 pass / 1 ignored. ### Scope / follow-up - Go-host wiring (bridge wrapper + Go-coordinator cross-check + replacing the runner's three placeholders + threading identifiers) is the next PR. - Production real-engine signing remains gated on the `frost-secp256k1-tr =3.0.0` external audit; this helper is parse/canonicalization surface only. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
2 parents 2bd81fa + 0fdea53 commit 9fd9845

6 files changed

Lines changed: 409 additions & 20 deletions

File tree

pkg/tbtc/signer/include/frost_tbtc.h

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,16 @@ TbtcSignerResult frost_tbtc_interactive_aggregate(const uint8_t* request_ptr, si
9898
* blame on its own.
9999
*/
100100
TbtcSignerResult frost_tbtc_verify_signature_share(const uint8_t* request_ptr, size_t request_len);
101+
/*
102+
* Phase 7.3 interactive attempt-context derivation. Stateless and secret-free:
103+
* derives the canonical attempt context (coordinator, included-participants
104+
* fingerprint, attempt id) and the per-participant FROST identifiers the host
105+
* feeds into frost_tbtc_interactive_session_open, so the host never
106+
* re-implements the engine's domain-separated derivations. The returned context
107+
* is re-validated against the same strict-mode check session_open runs, so the
108+
* engine is guaranteed to accept it. No DKG/nonce/session state is touched.
109+
*/
110+
TbtcSignerResult frost_tbtc_derive_interactive_attempt_context(const uint8_t* request_ptr, size_t request_len);
101111

102112
#ifdef __cplusplus
103113
}

pkg/tbtc/signer/src/api.rs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,45 @@ pub struct AttemptContext {
381381
pub attempt_id: String,
382382
}
383383

384+
/// Request to derive the canonical interactive attempt context (plus the
385+
/// per-participant FROST identifiers) from an attempt's public inputs, so the
386+
/// host never re-implements the engine's domain-separated derivations - the
387+
/// cross-language divergence class. Stateless and secret-free: no DKG lookup,
388+
/// no nonce/session state, no policy decision.
389+
#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
390+
pub struct DeriveInteractiveAttemptContextRequest {
391+
pub session_id: String,
392+
pub message_hex: String,
393+
pub key_group: String,
394+
/// Validation gate only - the derivation requires `included_participants`
395+
/// to hold at least `threshold` members; it is NOT an input to the
396+
/// fingerprint, attempt-id, or coordinator derivation.
397+
pub threshold: u16,
398+
/// 1-based wire attempt number (the host's 0-based value + 1), matching
399+
/// `AttemptContext.attempt_number`.
400+
pub attempt_number: u32,
401+
pub included_participants: Vec<u16>,
402+
}
403+
404+
#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
405+
pub struct DeriveInteractiveAttemptContextResult {
406+
/// The canonical attempt context, re-validated against strict-mode
407+
/// `validate_attempt_context` before returning, so the host can pass it
408+
/// verbatim to `InteractiveSessionOpenRequest.attempt_context` and the
409+
/// engine will accept it.
410+
pub attempt_context: AttemptContext,
411+
/// One FROST identifier string per included participant, in canonical
412+
/// (ascending) participant order - the exact key-package encoding the
413+
/// signing-package and aggregate paths expect.
414+
pub frost_identifiers: Vec<ParticipantFrostIdentifier>,
415+
}
416+
417+
#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
418+
pub struct ParticipantFrostIdentifier {
419+
pub participant_identifier: u16,
420+
pub frost_identifier: String,
421+
}
422+
384423
#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
385424
pub struct AttemptExclusionEvidence {
386425
pub reason: String,

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

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -64,24 +64,25 @@ use zeroize::{Zeroize, Zeroizing};
6464
use crate::api::{
6565
AggregateRequest, AggregateResult, AttemptContext, AttemptExclusionEvidence,
6666
AttemptTransitionEvidence, AttemptTransitionTelemetry, BlameProofVerificationResult,
67-
BuildTaprootTxRequest, CanaryRolloutStatusResult, DifferentialDivergence,
68-
DifferentialFuzzRequest, DifferentialFuzzResult, DkgPart1Request, DkgPart1Result,
69-
DkgPart2Request, DkgPart2Result, DkgPart3Request, DkgPart3Result, DkgResult, DkgRound1Package,
70-
DkgRound2Package, FinalizeSignRoundRequest, GenerateNoncesAndCommitmentsRequest,
67+
BuildTaprootTxRequest, CanaryRolloutStatusResult, DeriveInteractiveAttemptContextRequest,
68+
DeriveInteractiveAttemptContextResult, DifferentialDivergence, DifferentialFuzzRequest,
69+
DifferentialFuzzResult, DkgPart1Request, DkgPart1Result, DkgPart2Request, DkgPart2Result,
70+
DkgPart3Request, DkgPart3Result, DkgResult, DkgRound1Package, DkgRound2Package,
71+
FinalizeSignRoundRequest, GenerateNoncesAndCommitmentsRequest,
7172
GenerateNoncesAndCommitmentsResult, InitSignerConfigRequest, InitSignerConfigResult,
7273
InteractiveAggregateRequest, InteractiveAggregateResult, InteractiveRound1Request,
7374
InteractiveRound1Result, InteractiveRound2Request, InteractiveRound2Result,
7475
InteractiveSessionAbortRequest, InteractiveSessionAbortResult, InteractiveSessionOpenRequest,
7576
InteractiveSessionOpenResult, NativeFrostCommitment, NativeFrostKeyPackage,
7677
NativeFrostPublicKeyPackage, NativeFrostSignatureShare, NewSigningPackageRequest,
77-
NewSigningPackageResult, PromoteCanaryRequest, PromoteCanaryResult, QuarantineStatusRequest,
78-
QuarantineStatusResult, RefreshCadenceStatusRequest, RefreshCadenceStatusResult,
79-
RefreshSharesRequest, RefreshSharesResult, RoastLivenessPolicyResult, RollbackCanaryRequest,
80-
RollbackCanaryResult, RoundContribution, RoundState, RunDkgRequest, ShareMaterial,
81-
SignShareRequest, SignShareResult, SignatureResult, SignerHardeningMetricsResult,
82-
StartSignRoundRequest, TransactionResult, TranscriptAuditRecord, TranscriptAuditRequest,
83-
TranscriptAuditResult, TriggerEmergencyRekeyRequest, TriggerEmergencyRekeyResult,
84-
VerifyBlameProofRequest,
78+
NewSigningPackageResult, ParticipantFrostIdentifier, PromoteCanaryRequest, PromoteCanaryResult,
79+
QuarantineStatusRequest, QuarantineStatusResult, RefreshCadenceStatusRequest,
80+
RefreshCadenceStatusResult, RefreshSharesRequest, RefreshSharesResult,
81+
RoastLivenessPolicyResult, RollbackCanaryRequest, RollbackCanaryResult, RoundContribution,
82+
RoundState, RunDkgRequest, ShareMaterial, SignShareRequest, SignShareResult, SignatureResult,
83+
SignerHardeningMetricsResult, StartSignRoundRequest, TransactionResult, TranscriptAuditRecord,
84+
TranscriptAuditRequest, TranscriptAuditResult, TriggerEmergencyRekeyRequest,
85+
TriggerEmergencyRekeyResult, VerifyBlameProofRequest,
8586
};
8687
use crate::errors::EngineError;
8788
use crate::go_math_rand::select_coordinator_identifier;

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

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -456,6 +456,127 @@ pub(crate) fn validate_attempt_context(
456456
Ok(Some(canonical_included_participants))
457457
}
458458

459+
/// Derives the canonical interactive attempt context for an attempt from its
460+
/// public inputs, so the host never re-implements the engine's domain-separated
461+
/// derivations (the cross-language divergence class that bit the coordinator
462+
/// seed). Stateless and secret-free: it touches no DKG, nonce, or session state.
463+
///
464+
/// The returned context is re-validated against strict-mode
465+
/// `validate_attempt_context` for the same inputs before returning, so it is
466+
/// guaranteed to be accepted by `interactive_session_open`; the per-participant
467+
/// FROST identifiers use the canonical key-package encoding the
468+
/// signing-package/aggregate paths require.
469+
pub(crate) fn derive_interactive_attempt_context(
470+
request: DeriveInteractiveAttemptContextRequest,
471+
) -> Result<DeriveInteractiveAttemptContextResult, EngineError> {
472+
// Mirror interactive_session_open's front door (and every other engine
473+
// endpoint, including the public-material-only verify_signature_share): an
474+
// unattested engine, or a session_id open would reject, must fail closed
475+
// here too rather than hand back a context the real open refuses.
476+
enforce_provenance_gate()?;
477+
validate_session_id(&request.session_id)?;
478+
479+
let message_bytes = hex::decode(&request.message_hex)
480+
.map_err(|e| EngineError::Validation(format!("message_hex is not valid hex: {e}")))?;
481+
if message_bytes.is_empty() {
482+
return Err(EngineError::Validation(
483+
"message_hex must not be empty".to_string(),
484+
));
485+
}
486+
if request.attempt_number == 0 {
487+
return Err(EngineError::Validation(
488+
"attempt_number must be at least 1".to_string(),
489+
));
490+
}
491+
// interactive_session_open rejects threshold == 0 BEFORE validating the
492+
// context, and validate_attempt_context only checks len >= threshold (always
493+
// true for 0). Reject it here too so the helper never hands the host a
494+
// context open would reject - a missing/uninitialized threshold fails at the
495+
// derivation seam, not later at open.
496+
if request.threshold == 0 {
497+
return Err(EngineError::Validation(
498+
"threshold must be non-zero".to_string(),
499+
));
500+
}
501+
502+
let canonical_included_participants =
503+
canonicalize_included_participants(&request.included_participants)?;
504+
if canonical_included_participants.len() < usize::from(request.threshold) {
505+
return Err(EngineError::Validation(format!(
506+
"included_participants must contain at least threshold members [{}]",
507+
request.threshold
508+
)));
509+
}
510+
511+
// Coordinator: the RFC-21 Annex A shuffle binds the padded raw message
512+
// digest and uses the 0-based attempt number.
513+
let attempt_seed = roast_attempt_shuffle_seed(
514+
&request.key_group,
515+
&request.session_id,
516+
&rfc21_message_digest(&message_bytes)?,
517+
)?;
518+
let coordinator_identifier = select_coordinator_identifier(
519+
&canonical_included_participants,
520+
attempt_seed,
521+
request.attempt_number - 1,
522+
)
523+
.ok_or_else(|| {
524+
EngineError::Validation("included_participants must not be empty".to_string())
525+
})?;
526+
527+
// Fingerprint over the canonical set; the attempt_id binds the engine's
528+
// SHA256 transcript digest of the message (NOT the RFC-21 shuffle digest).
529+
let included_participants_fingerprint =
530+
roast_included_participants_fingerprint_hex(&canonical_included_participants)?;
531+
let message_digest_hex = hash_hex(&message_bytes);
532+
let attempt_id = roast_attempt_id_hex(
533+
&request.session_id,
534+
&message_digest_hex,
535+
request.attempt_number,
536+
coordinator_identifier,
537+
&included_participants_fingerprint,
538+
)?;
539+
540+
let attempt_context = AttemptContext {
541+
attempt_number: request.attempt_number,
542+
coordinator_identifier,
543+
included_participants: canonical_included_participants.clone(),
544+
included_participants_fingerprint,
545+
attempt_id,
546+
};
547+
548+
// Post-condition: the derived context MUST satisfy the same strict-mode
549+
// validator `interactive_session_open` runs, so the host can never be handed
550+
// a context the engine would later reject. A failure here is an internal
551+
// derivation inconsistency, surfaced rather than shipped.
552+
validate_attempt_context(
553+
&request.session_id,
554+
&request.key_group,
555+
&message_bytes,
556+
&message_digest_hex,
557+
request.threshold,
558+
Some(&attempt_context),
559+
true,
560+
)?;
561+
562+
let frost_identifiers = canonical_included_participants
563+
.iter()
564+
.map(|participant| {
565+
Ok(ParticipantFrostIdentifier {
566+
participant_identifier: *participant,
567+
frost_identifier: frost_identifier_to_go_string(
568+
participant_identifier_to_frost_identifier(*participant)?,
569+
),
570+
})
571+
})
572+
.collect::<Result<Vec<_>, EngineError>>()?;
573+
574+
Ok(DeriveInteractiveAttemptContextResult {
575+
attempt_context,
576+
frost_identifiers,
577+
})
578+
}
579+
459580
pub(crate) fn canonical_attempt_context(attempt_context: &AttemptContext) -> AttemptContext {
460581
let mut canonical = Some(attempt_context.clone());
461582
canonicalize_attempt_context_for_fingerprint(&mut canonical);

0 commit comments

Comments
 (0)