Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
d1ab4dc
feat(tbtc/signer): persist a distributed DKG key package as signing m…
mswilkison Jul 7, 2026
af5a306
fix(tbtc/signer): accumulate distributed DKG key packages under one s…
mswilkison Jul 7, 2026
a491df8
fix(tbtc/signer): gate distributed-DKG persist behind provenance; add…
mswilkison Jul 7, 2026
4ac6bb7
fix(tbtc/signer): admission gate, key-package consistency, ABI bump f…
mswilkison Jul 8, 2026
d6f293c
fix(tbtc/signer): validate participant count + reject non-canonical I…
mswilkison Jul 8, 2026
228073e
fix(tbtc/signer): verify signing share derives to public share; match…
mswilkison Jul 8, 2026
70f8bba
fix(tbtc/signer): enforce operator quarantine before persisting distr…
mswilkison Jul 8, 2026
1841747
fix(signer): resolve DKG key material by key_group so signing works a…
mswilkison Jul 8, 2026
8dd9401
fix(signer): re-check emergency-rekey/finalize gates from the WALLET …
mswilkison Jul 8, 2026
5a88e9a
fix(signer): CI green (rustfmt, RUSTSEC-2026-0204) + harden emergency…
mswilkison Jul 8, 2026
82cffed
fix(signer): enforce the total-session cap when Open creates a cross-…
mswilkison Jul 8, 2026
a5762aa
fix(signer): scrub serde-owned secret request Strings + SigningShare …
mswilkison Jul 8, 2026
0d2ebcb
fix(signer): a session belongs to one key_group for life; scrub secre…
mswilkison Jul 8, 2026
1d6f946
fix(signer): persist bound_key_group so cross-session signing survive…
mswilkison Jul 8, 2026
4ac4a42
style(signer): satisfy clippy -D warnings (unnecessary_map_or, clone_…
mswilkison Jul 8, 2026
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
4 changes: 2 additions & 2 deletions pkg/tbtc/signer/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pkg/tbtc/signer/include/frost_tbtc.h
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ TbtcSignerResult frost_tbtc_run_dkg(const uint8_t* request_ptr, size_t request_l
TbtcSignerResult frost_tbtc_dkg_part1(const uint8_t* request_ptr, size_t request_len);
TbtcSignerResult frost_tbtc_dkg_part2(const uint8_t* request_ptr, size_t request_len);
TbtcSignerResult frost_tbtc_dkg_part3(const uint8_t* request_ptr, size_t request_len);
TbtcSignerResult frost_tbtc_persist_distributed_dkg_key_package(const uint8_t* request_ptr, size_t request_len);

/*
* Stateless interactive signing nonce contract:
Expand Down
14 changes: 14 additions & 0 deletions pkg/tbtc/signer/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,20 @@ pub struct DkgPart3Result {
pub public_key_package: NativeFrostPublicKeyPackage,
}

/// Persists the result of a DISTRIBUTED FROST DKG for one seat: this node's own
/// key package (from Part3) plus the group public key package, into the engine
/// session state that the interactive signing path loads. Unlike the dealer
/// `run_dkg`, a distributed node holds only its OWN secret key package.
#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
pub struct PersistDistributedDkgKeyPackageRequest {
pub session_id: String,
pub participant_identifier: u16,
pub threshold: u16,
pub participant_count: u16,
pub key_package: NativeFrostKeyPackage,
pub public_key_package: NativeFrostPublicKeyPackage,
}

#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
pub struct NativeFrostCommitment {
pub identifier: String,
Expand Down
234 changes: 234 additions & 0 deletions pkg/tbtc/signer/src/engine/dkg.rs
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,240 @@ pub fn run_dkg(request: RunDkgRequest) -> Result<DkgResult, EngineError> {
Ok(result)
}

/// Persists a DISTRIBUTED FROST DKG result for one seat so the interactive
/// signing path can load its key. The dealer `run_dkg` above persists ALL key
/// packages (it generates them all); a distributed DKG instead runs Part1/2/3
/// across nodes and each node's Part3 returns only ITS OWN secret key package.
/// This op stores that key package (keyed by this node's participant identifier)
/// together with the group public key package, then persists - the exact session
/// shape interactive signing consumes (own key package by member_identifier; the
/// public key package for the participant set and aggregation). A MULTI-SEAT
/// operator calls it once per local seat and the key packages accumulate under
/// one session (same key group). There is NO production gate: this is the real
/// distributed path, not the transitional dealer one.
pub fn persist_distributed_dkg_key_package(
mut request: PersistDistributedDkgKeyPackageRequest,
) -> Result<DkgResult, EngineError> {
const OP: &str = "persist_distributed_dkg_key_package";
// data_hex is the serialized SECRET signing share. Move it into a zeroizing holder
// BEFORE any fallible check (validation, admission, quarantine can all return first),
// so serde's owned String is wiped on EVERY return path rather than dropped un-wiped.
let data_hex = Zeroizing::new(std::mem::take(&mut request.key_package.data_hex));
validate_session_id(&request.session_id)?;
// Gate BEFORE decoding or persisting any key material: this op writes signing
// material to durable state that interactive signing trusts after restart, so
// an unattested runtime must not be able to install it - the same gate run_dkg
// and every interactive op enforce.
enforce_provenance_gate()?;

if request.participant_identifier == 0 {
return Err(EngineError::Validation(format!(
"{OP}: participant identifier must be non-zero"
)));
}
if request.threshold < 2 || request.participant_count < request.threshold {
return Err(EngineError::Validation(format!(
"{OP}: threshold [{}] must be between 2 and participant_count [{}]",
request.threshold, request.participant_count
)));
}

let public_key_package = native_public_key_package_to_frost(OP, &request.public_key_package)?;

// The group public key package is the authoritative participant set. EVERY
// verifying share must have a canonical (u16-derived) identifier: a
// non-canonical one cannot be a real group member, and silently dropping it
// would let it slip past the admission allowlist/required checks below while
// still inflating the participant count.
let mut admission_participant_identifiers = HashSet::new();
for identifier in public_key_package.verifying_shares().keys() {
match frost_identifier_to_u16(*identifier) {
Some(participant_identifier) => {
admission_participant_identifiers.insert(participant_identifier);
}
None => {
return Err(EngineError::Validation(format!(
"{OP}: public key package contains a non-canonical participant identifier"
)))
}
}
}

// The caller's participant_count must match the authoritative public-package
// set, or downstream consumers of the stored DkgResult get the wrong group
// size for this key material.
if request.participant_count as usize != admission_participant_identifiers.len() {
return Err(EngineError::Validation(format!(
"{OP}: participant_count [{}] does not match the public key package [{}]",
request.participant_count,
admission_participant_identifiers.len()
)));
}

// Enforce the SAME DKG admission policy the dealer run_dkg enforces, over the
// participant set derived from the public key package. Otherwise a caller could
// persist a package that omits a required participant or includes a
// non-allowlisted one, and interactive signing would later trust it.
enforce_admission_policy_for(
&request.session_id,
admission_participant_identifiers.len(),
&admission_participant_identifiers,
request.threshold,
)?;

// Enforce operator quarantine over the same derived participant set, exactly
// as the dealer run_dkg does: a distributed DKG whose group includes a
// quarantined operator must not be persisted and then trusted by later
// interactive signing sessions.
let auto_quarantine_config = load_auto_quarantine_config()?;
let quarantined_operator_identifiers = {
let guard = state()?
.lock()
.map_err(|_| EngineError::Internal("engine lock poisoned".to_string()))?;
guard.quarantined_operator_identifiers.clone()
};
let participant_identifiers: Vec<u16> =
admission_participant_identifiers.iter().copied().collect();
enforce_not_quarantined_identifiers(
&request.session_id,
&participant_identifiers,
&quarantined_operator_identifiers,
auto_quarantine_config.as_ref(),
)?;

let key_package = decode_key_package(OP, &request.key_package.identifier, &data_hex)?;

// The key package must belong to this participant AND be consistent with the
// group public key package: matching identifier, embedded threshold, group
// verifying key, and this participant's verifying share. An inconsistent
// package (e.g. min_signers 3 vs a stored threshold of 2, or a share from a
// different DKG) would let interactive signing open an attempt it can never
// complete and burn it at share release.
let frost_identifier =
participant_identifier_to_frost_identifier(request.participant_identifier)?;
if *key_package.identifier() != frost_identifier {
return Err(EngineError::Validation(format!(
"{OP}: key package identifier does not match participant_identifier"
)));
}
if *key_package.min_signers() != request.threshold {
return Err(EngineError::Validation(format!(
"{OP}: key package min_signers [{}] does not match threshold [{}]",
*key_package.min_signers(),
request.threshold
)));
}
if key_package.verifying_key() != public_key_package.verifying_key() {
return Err(EngineError::Validation(format!(
"{OP}: key package group verifying key does not match the public key package"
)));
}
match public_key_package.verifying_shares().get(&frost_identifier) {
None => {
return Err(EngineError::Validation(format!(
"{OP}: participant_identifier is not a member of the public key package"
)))
}
Some(verifying_share) if verifying_share != key_package.verifying_share() => {
return Err(EngineError::Validation(format!(
"{OP}: key package verifying share does not match the public key package"
)))
}
Some(_) => {}
}

// The checks above only trust the PUBLIC verifying share embedded in the key
// package; Round2 signs with the embedded SECRET signing share, and
// deserialization does not prove the signing scalar derives to that public
// share. Verify signing_share -> verifying_share, so a corrupt or malformed
// key package cannot be stored and then burn signing attempts producing shares
// that never verify.
// signing_share() is Copy (frost-core SigningShare is Copy + DefaultIsZeroes, NOT
// ZeroizeOnDrop), so bind the extracted copy and zeroize it right after the check -
// otherwise the secret scalar lingers as un-wiped stack residue. (The copy frost's
// own by-value VerifyingShare::from makes internally is beyond our reach.)
let mut signing_share = *key_package.signing_share();
let derives_to_verifying_share =
frost::keys::VerifyingShare::from(signing_share) == *key_package.verifying_share();
signing_share.zeroize();
if !derives_to_verifying_share {
return Err(EngineError::Validation(format!(
"{OP}: key package signing share does not derive to its verifying share"
)));
}

let key_group = public_key_package
.verifying_key()
.serialize()
.map(hex::encode)
.map_err(|e| {
EngineError::Internal(format!("{OP}: failed to serialize verifying key: {e}"))
})?;

let mut guard = state()?
.lock()
.map_err(|_| EngineError::Internal("engine lock poisoned".to_string()))?;
ensure_session_insert_capacity(&guard.sessions, &request.session_id)?;

let session = guard
.sessions
.entry(request.session_id.clone())
.or_insert_with(SessionState::default);

// A session may already hold a DKG result: this seat re-persisting (idempotent)
// or, for a MULTI-SEAT operator, a sibling seat of the SAME distributed DKG.
// Same key group -> accumulate this seat's key package into the session; a
// different key group for the same session is a conflict.
if let Some(existing) = &session.dkg_result {
if existing.key_group != key_group {
return Err(EngineError::SessionConflict {
session_id: request.session_id,
});
}
// Same group key is NOT enough: a sibling seat of the SAME distributed DKG
// must carry the SAME threshold, participant count, and public key package.
// Otherwise a second seat could be validated against a different submitted
// public package while the session keeps the first, so later signing would
// use public material inconsistent with this seat's key.
if existing.threshold != request.threshold
|| existing.participant_count != request.participant_count
{
return Err(EngineError::Validation(format!(
"{OP}: threshold/participant_count does not match the stored DKG for this session"
)));
}
if session.dkg_public_key_package.as_ref() != Some(&public_key_package) {
return Err(EngineError::Validation(format!(
"{OP}: public key package does not match the stored DKG for this session"
)));
}
} else {
session.dkg_result = Some(DkgResult {
session_id: request.session_id.clone(),
key_group,
participant_count: request.participant_count,
threshold: request.threshold,
created_at_unix: now_unix(),
});
session.dkg_public_key_package = Some(public_key_package);
}

session
.dkg_key_packages
.get_or_insert_with(BTreeMap::new)
.insert(request.participant_identifier, key_package);

// Clone the result before the `&guard` persist call so the mutable `session`
// borrow ends here (mirrors run_dkg's ordering).
let result = session
.dkg_result
.clone()
.expect("dkg_result was just set for this session");
persist_engine_state_to_storage(&guard)?;

Ok(result)
}

pub(crate) fn enforce_bootstrap_dealer_dkg_disabled_in_production(
session_id: &str,
) -> Result<(), EngineError> {
Expand Down
24 changes: 17 additions & 7 deletions pkg/tbtc/signer/src/engine/frost_ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -173,14 +173,17 @@ pub fn dkg_part3(request: DkgPart3Request) -> Result<DkgPart3Result, EngineError
}

pub fn generate_nonces_and_commitments(
request: GenerateNoncesAndCommitmentsRequest,
mut request: GenerateNoncesAndCommitmentsRequest,
) -> Result<GenerateNoncesAndCommitmentsResult, EngineError> {
// key_package_hex is the serialized SECRET signing share. Hold it zeroizing so
// serde's owned String is wiped on EVERY return path, not left to drop un-wiped.
let key_package_hex = Zeroizing::new(std::mem::take(&mut request.key_package_hex));
enforce_provenance_gate()?;

let key_package = decode_key_package(
"GenerateNoncesAndCommitments",
&request.key_package_identifier,
&request.key_package_hex,
&key_package_hex,
)?;
let mut rng = zeroizing_rng_from_os();
let (mut nonces, commitments) = frost::round1::commit(key_package.signing_share(), &mut rng);
Expand Down Expand Up @@ -233,7 +236,13 @@ pub fn new_signing_package(
})
}

pub fn sign_share(request: SignShareRequest) -> Result<SignShareResult, EngineError> {
pub fn sign_share(mut request: SignShareRequest) -> Result<SignShareResult, EngineError> {
// The two SECRET request fields - the one-time signing nonces and this seat's
// signing key package - are held zeroizing so serde's owned Strings are wiped on
// EVERY return path (every decode/deserialize below can fail first), not left to
// drop un-wiped. signing_package_hex is public, so it stays in request.
let nonces_hex = Zeroizing::new(std::mem::take(&mut request.nonces_hex));
let key_package_hex = Zeroizing::new(std::mem::take(&mut request.key_package_hex));
enforce_provenance_gate()?;

let signing_package_bytes = decode_hex_field(
Expand All @@ -244,17 +253,18 @@ pub fn sign_share(request: SignShareRequest) -> Result<SignShareResult, EngineEr
let signing_package = frost::SigningPackage::deserialize(&signing_package_bytes)
.map_err(|e| EngineError::Validation(format!("SignShare: invalid signing package: {e}")))?;

let mut nonces_bytes = decode_hex_field("SignShare", "nonces_hex", &request.nonces_hex)?;
let mut nonces_bytes = decode_hex_field("SignShare", "nonces_hex", &nonces_hex)?;
let nonces_result = frost::round1::SigningNonces::deserialize(&nonces_bytes);
nonces_bytes.zeroize();
let mut nonces = nonces_result
.map_err(|e| EngineError::Validation(format!("SignShare: invalid nonces: {e}")))?;

let key_package = match decode_key_package(
let key_package_result = decode_key_package(
"SignShare",
&request.key_package_identifier,
&request.key_package_hex,
) {
&key_package_hex,
);
let key_package = match key_package_result {
Ok(key_package) => key_package,
Err(err) => {
nonces.zeroize();
Expand Down
Loading
Loading