Skip to content
Merged
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
1 change: 1 addition & 0 deletions pkg/tbtc/signer/Cargo.lock

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

4 changes: 4 additions & 0 deletions pkg/tbtc/signer/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ sha2 = "0.10"
hex = "0.4"
thiserror = "2.0"
frost-secp256k1-tr = "=3.0.0"
# Direct, version-matched access to aggregate_custom + CheaterDetection (the
# frost-secp256k1-tr aggregate wrappers hardcode FirstCheater). Already a
# transitive dependency via frost-secp256k1-tr, pinned to the same 3.0.0.
frost-core = { version = "=3.0.0", default-features = false }
chacha20poly1305 = "0.10"
rand_chacha = "0.3"
libc = "0.2"
Expand Down
25 changes: 18 additions & 7 deletions pkg/tbtc/signer/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -219,13 +219,15 @@ pub struct InteractiveAggregateRequest {
/// The signing package the shares were produced over (carries the
/// message and the chosen subset's commitments).
pub signing_package_hex: String,
/// The collected signature shares from the responsive subset. Each
/// is verified against the member's verifying share (resolved from
/// the session's DKG public key package) before aggregation; an
/// invalid share fails the call closed with `validation_error` and
/// no signature. Per-member attributable blame (a culprit list) is
/// deferred to Phase 7.2b, where the signed-package envelopes bind
/// what each member signed and make the attribution unforgeable.
/// The collected signature shares from the responsive subset. Each is
/// verified against the member's verifying share (resolved from the
/// session's DKG public key package) before aggregation. If any share fails,
/// the call fails closed with no signature and the
/// `aggregate_share_verification_failed` error, which carries the CANDIDATE
/// culprits - every member whose share failed (Phase 7.2b-3). These are
/// pure-crypto candidates for the Go host's envelope-bound blame
/// adjudication (frozen Phase 7.2b spec, section 6); the engine never
/// inspects operator-signed envelopes itself.
pub signature_shares: Vec<NativeFrostSignatureShare>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub taproot_merkle_root_hex: Option<String>,
Expand Down Expand Up @@ -669,6 +671,15 @@ pub struct ErrorResponse {
pub code: String,
pub message: String,
pub recovery_class: String,
/// CANDIDATE culprits for an `aggregate_share_verification_failed` error:
/// the u16 Go member identifiers whose FROST signature shares failed
/// verification (the same identifier space as `excluded_member_identifiers`).
/// Empty - and omitted from the JSON via skip_serializing_if - for every
/// other error, so existing Go clients are unaffected. These are pure-crypto
/// candidates, not adjudicated blame; the Go host performs the envelope-bound
/// adjudication (frozen Phase 7.2b spec, section 6).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub candidate_culprits: Vec<u16>,
}

/// Init-time signer configuration installed once by the host over FFI.
Expand Down
37 changes: 37 additions & 0 deletions pkg/tbtc/signer/src/engine/codec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,43 @@ pub(crate) fn frost_identifier_to_go_string(identifier: frost::Identifier) -> St
.expect("serializing hex identifier as JSON string cannot fail")
}

/// Map a FROST aggregate error to the CANDIDATE culprits it identifies, as u16
/// Go member identifiers (the same identifier space as
/// `excluded_member_identifiers`, so the Go host consumes them directly).
///
/// Returns the members FROST flagged for an invalid signature share
/// (`Error::InvalidSignatureShare`, the full set under
/// `CheaterDetection::AllCheaters`). Every other error class - malformed
/// package, wrong share count, group/field errors - yields an empty list: those
/// are not per-member share attributions, so the caller surfaces them as a
/// generic validation failure instead. Identifiers that do not map to a u16 are
/// dropped: they cannot belong to a real group member (every submitted share
/// carries a u16-derived identifier), so they are foreign to the Go host's
/// member set. CANDIDATES only - pure FROST verdicts, not adjudicated fault.
pub(crate) fn aggregate_candidate_culprits(error: &frost::Error) -> Vec<u16> {
match error {
frost_core::Error::InvalidSignatureShare { culprits } => culprits
.iter()
.filter_map(|identifier| frost_identifier_to_u16(*identifier))
.collect(),
_ => Vec::new(),
}
}

/// Recover the u16 Go member identifier from a FROST participant identifier -
/// the inverse of `participant_identifier_to_frost_identifier`. FROST(secp256k1)
/// serializes the scalar big-endian, so this requires every byte above the low
/// two to be zero and reads the trailing two big-endian. Returns None for an
/// identifier that does not fit a u16.
pub(crate) fn frost_identifier_to_u16(identifier: frost::Identifier) -> Option<u16> {
let bytes = identifier.serialize();
let split = bytes.len().checked_sub(2)?;
if bytes[..split].iter().any(|&b| b != 0) {
return None;
}
Some(u16::from_be_bytes([bytes[split], bytes[split + 1]]))
}

pub(crate) fn parse_frost_identifier(
operation: &str,
field_name: &str,
Expand Down
64 changes: 42 additions & 22 deletions pkg/tbtc/signer/src/engine/interactive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -653,34 +653,54 @@ pub fn interactive_aggregate(
// step is each signer's Round2, where lifecycle/quarantine/firewall
// were already enforced (including the full-subset quarantine check).
//
// frost verifies every share and can name which failed, but this path
// does NOT surface those as attributable member blame: the engine
// cannot yet bind these public inputs (signing package, taproot root)
// to what each member actually signed at Round2, so a coordinator
// aggregating against a different package/root would make honest
// shares fail and frame their members. Attributable blame waits for
// the signed-package envelopes (Phase 7.2b, frozen spec section 6),
// which prove what each member signed. Until then a verification
// failure is a generic fail-closed error: no signature, no blame.
// frost verifies every share and names which failed. This path now surfaces
// those as CANDIDATE culprits (Phase 7.2b-3): the engine reports the members
// whose shares did not verify against the group's own verifying material,
// but it does NOT adjudicate fault. The engine cannot bind these public
// inputs (signing package, taproot root) to what each member signed at
// Round2, so a coordinator that aggregated honest shares against a
// substituted package/root would make those honest shares fail and appear
// here. Authoritative, envelope-bound blame is the Go host's job at an f+1
// accuser quorum (frozen Phase 7.2b spec, section 6), using the signed
// signing-package envelopes; this candidate list is its input. Fail-closed
// either way: no signature leaves on a verification failure.
let verification_key_package = match taproot_merkle_root.as_ref() {
Some(root) => public_key_package.clone().tweak(Some(root.as_slice())),
None => public_key_package.clone(),
};

let aggregate_result = match taproot_merkle_root.as_ref() {
Some(root) => frost::aggregate_with_tweak(
&signing_package,
&signature_shares,
&public_key_package,
Some(root.as_slice()),
),
None => frost::aggregate(&signing_package, &signature_shares, &public_key_package),
// Aggregate with AllCheaters detection. The frost-secp256k1-tr
// aggregate/aggregate_with_tweak wrappers hardcode FirstCheater, so a
// failure would name only one member; AllCheaters names EVERY member whose
// share failed. verification_key_package is the (taproot-tweaked, when a
// root is set) public key package - exactly what aggregate_with_tweak
// derives internally - so this is equivalent to those wrappers on the
// success path. Cheater detection only runs after the aggregate signature
// itself fails to verify, so there is no happy-path cost.
let signature = match frost_core::aggregate_custom(
&signing_package,
&signature_shares,
&verification_key_package,
frost_core::CheaterDetection::AllCheaters,
) {
Ok(signature) => signature,
Err(error) => {
let candidate_culprits = aggregate_candidate_culprits(&error);
if candidate_culprits.is_empty() {
// Not a per-member share attribution (malformed package, wrong
// share count, group/field error): fail closed with the generic
// validation error, no blame.
return Err(EngineError::Validation(format!(
"InteractiveAggregate: failed to aggregate: {error}"
)));
}
return Err(EngineError::AggregateShareVerificationFailed {
session_id: request.session_id.clone(),
attempt_id,
candidate_culprits,
});
}
};
let signature = aggregate_result.map_err(|error| {
EngineError::Validation(format!(
"InteractiveAggregate: failed to aggregate: {error}"
))
})?;

// Self-verify the aggregate against the (tweaked) group verifying
// key before releasing it, matching the coarse finalize path.
Expand Down
143 changes: 135 additions & 8 deletions pkg/tbtc/signer/src/engine/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13120,15 +13120,142 @@ fn interactive_aggregate_rejects_invalid_share_fail_closed() {
taproot_merkle_root_hex: None,
})
.expect_err("an invalid share must fail aggregation closed");
// 7.2a fails closed without attributable member blame: the engine
// cannot yet bind the aggregate inputs to what each member signed
// (that needs the Phase 7.2b signed-package envelopes), so a
// verification failure is a generic error - no signature, and no
// culprit naming that a wrong-package/root coordinator could forge.
assert!(
matches!(err, EngineError::Validation(ref m) if m.contains("failed to aggregate")),
"unexpected error: {err:?}"
// 7.2b-3: the aggregate now fails closed WITH attributable CANDIDATE blame.
// Member 2 submitted a structurally valid share over a different package, so
// its share fails verification against the group's verifying material and is
// named a candidate culprit (its u16 Go member id); member 1's honest share
// is not. The engine surfaces candidates only - envelope-bound adjudication
// is the Go host's job (frozen Phase 7.2b spec, section 6).
let candidate_culprits = match err {
EngineError::AggregateShareVerificationFailed {
candidate_culprits, ..
} => candidate_culprits,
other => panic!("expected AggregateShareVerificationFailed, got {other:?}"),
};
assert_eq!(
candidate_culprits,
vec![2],
"only the cheating member 2 must be named: {candidate_culprits:?}"
);
}

#[test]
fn frost_identifier_to_u16_inverts_participant_mapping() {
// The culprit list reports u16 Go member identifiers, so the inverse of
// participant_identifier_to_frost_identifier must round-trip - including
// across the low/high byte boundary (255 -> 256).
for id in [1u16, 2, 3, 255, 256, 65535] {
let identifier = participant_identifier_to_frost_identifier(id).expect("identifier");
assert_eq!(frost_identifier_to_u16(identifier), Some(id), "id {id}");
}
}

#[test]
fn interactive_aggregate_names_all_invalid_share_culprits() {
let _guard = lock_test_state();
reset_for_tests();

let session_id = "interactive-aggregate-multi-blame";
let key_group = "interactive-test-key-group";
let message = [0x5au8; 32];
let included = [1u16, 2];
let key_packages = ensure_interactive_dkg_session(session_id, key_group);

// Both members of the threshold-2 signing subset cheat: each signs a
// DIFFERENT package, so both shares fail verification against the
// authoritative package. Aggregation must name BOTH (AllCheaters), not just
// the first cheater. (The signing package carries exactly `threshold`
// commitments, so a multi-culprit case needs every subset member to cheat.)
let opened = open_interactive_for_test(session_id, key_group, &message, &included, 1, 1, 2)
.expect("opens");

let real1 = generate_nonces_and_commitments(GenerateNoncesAndCommitmentsRequest {
key_package_identifier: key_packages[&1].identifier.clone(),
key_package_hex: key_packages[&1].data_hex.clone(),
})
.expect("member 1 nonces");
let real2 = generate_nonces_and_commitments(GenerateNoncesAndCommitmentsRequest {
key_package_identifier: key_packages[&2].identifier.clone(),
key_package_hex: key_packages[&2].data_hex.clone(),
})
.expect("member 2 nonces");
let signing_package_hex = interactive_package_for_test(
&message,
vec![real1.commitment.clone(), real2.commitment.clone()],
);

// Each member signs a different (2-party) package over another message, so
// both shares fail verification against the authoritative package.
let other_message = [0x5bu8; 32];
let bogus1 = generate_nonces_and_commitments(GenerateNoncesAndCommitmentsRequest {
key_package_identifier: key_packages[&1].identifier.clone(),
key_package_hex: key_packages[&1].data_hex.clone(),
})
.expect("bogus member 1 nonces");
let bogus1_package = interactive_package_for_test(
&other_message,
vec![
NativeFrostCommitment {
identifier: key_packages[&1].identifier.clone(),
data_hex: bogus1.commitment.data_hex.clone(),
},
NativeFrostCommitment {
identifier: key_packages[&2].identifier.clone(),
data_hex: bogus1.commitment.data_hex.clone(),
},
],
);
let bogus1_share = sign_share(SignShareRequest {
signing_package_hex: bogus1_package,
nonces_hex: bogus1.nonces_hex,
key_package_identifier: key_packages[&1].identifier.clone(),
key_package_hex: key_packages[&1].data_hex.clone(),
})
.expect("bogus member 1 share");
let bogus2 = generate_nonces_and_commitments(GenerateNoncesAndCommitmentsRequest {
key_package_identifier: key_packages[&2].identifier.clone(),
key_package_hex: key_packages[&2].data_hex.clone(),
})
.expect("bogus member 2 nonces");
let bogus2_package = interactive_package_for_test(
&other_message,
vec![
NativeFrostCommitment {
identifier: key_packages[&2].identifier.clone(),
data_hex: bogus2.commitment.data_hex.clone(),
},
NativeFrostCommitment {
identifier: key_packages[&1].identifier.clone(),
data_hex: bogus2.commitment.data_hex.clone(),
},
],
);
let bogus2_share = sign_share(SignShareRequest {
signing_package_hex: bogus2_package,
nonces_hex: bogus2.nonces_hex,
key_package_identifier: key_packages[&2].identifier.clone(),
key_package_hex: key_packages[&2].data_hex.clone(),
})
.expect("bogus member 2 share");

let err = interactive_aggregate(InteractiveAggregateRequest {
session_id: session_id.to_string(),
attempt_id: opened.attempt_id.clone(),
signing_package_hex,
signature_shares: vec![bogus1_share.signature_share, bogus2_share.signature_share],
taproot_merkle_root_hex: None,
})
.expect_err("two invalid shares must fail aggregation closed");

let mut candidate_culprits = match err {
EngineError::AggregateShareVerificationFailed {
candidate_culprits, ..
} => candidate_culprits,
other => panic!("expected AggregateShareVerificationFailed, got {other:?}"),
};
candidate_culprits.sort_unstable();
// AllCheaters, not FirstCheater: BOTH cheating members are named.
assert_eq!(candidate_culprits, vec![1, 2], "{candidate_culprits:?}");
}

#[test]
Expand Down
Loading
Loading