@@ -58,8 +58,7 @@ use crypto_shared::{
5858 types:: { PublicKeyExtended , PublicKeyExtendedConversionError } ,
5959} ;
6060use errors:: {
61- DomainError , InvalidParameters , InvalidState , InvalidThreshold , PublicKeyError , RespondError ,
62- TeeError ,
61+ DomainError , InvalidParameters , InvalidState , PublicKeyError , RespondError , TeeError ,
6362} ;
6463use k256:: elliptic_curve:: PrimeField ;
6564use near_mpc_contract_interface:: types:: Ed25519PublicKey ;
@@ -79,7 +78,7 @@ use near_sdk::{
7978} ;
8079use node_migrations:: NodeMigrations ;
8180use primitives:: {
82- domain:: DomainRegistry ,
81+ domain:: { DomainRegistry , max_reconstruction_threshold } ,
8382 key_state:: { AuthenticatedParticipantId , EpochId , KeyEventId , Keyset } ,
8483 signature:: { SignRequestArgs , SignatureRequest , YieldIndex } ,
8584 thresholds:: { ProposedThresholdParameters , Threshold , ThresholdParameters } ,
@@ -925,10 +924,6 @@ impl MpcContract {
925924 proposal,
926925 ) ;
927926
928- // Defense in depth: never reshare into a participant set smaller than any
929- // threshold (see `assert_proposal_meets_all_thresholds`).
930- self . assert_proposal_meets_all_thresholds ( & proposal) ?;
931-
932927 let tee_upgrade_deadline_duration =
933928 Duration :: from_secs ( self . config . tee_upgrade_deadline_duration_seconds ) ;
934929
@@ -971,47 +966,6 @@ impl MpcContract {
971966 }
972967 }
973968
974- /// Defense-in-depth guard for [`Self::vote_new_parameters`]: rejects a
975- /// proposal whose participant set is smaller than the proposed signing
976- /// threshold or any domain's effective reconstruction threshold (proposed
977- /// override if present, else the domain's current value). Such a set would
978- /// leave a key un-signable or un-reconstructible. Redundant with
979- /// `RunningContractState::process_new_parameters_proposal`.
980- fn assert_proposal_meets_all_thresholds (
981- & self ,
982- proposal : & ProposedThresholdParameters ,
983- ) -> Result < ( ) , Error > {
984- let num_participants = u64:: try_from ( proposal. participants ( ) . len ( ) )
985- . expect ( "participant list should be wayyyy smaller than u64::MAX" ) ;
986-
987- let threshold = proposal. threshold ( ) . value ( ) ;
988- if threshold > num_participants {
989- return Err ( InvalidThreshold :: MaxRequirementFailed {
990- max : num_participants,
991- found : threshold,
992- }
993- . into ( ) ) ;
994- }
995-
996- let domains = self . protocol_state . domain_registry ( ) ?;
997- let updates = proposal. per_domain_thresholds ( ) ;
998- for domain in domains. domains ( ) {
999- let effective = updates
1000- . get ( & domain. id )
1001- . copied ( )
1002- . unwrap_or ( domain. reconstruction_threshold )
1003- . inner ( ) ;
1004- if effective > num_participants {
1005- return Err ( DomainError :: ReconstructionThresholdExceedsParticipants {
1006- threshold : effective,
1007- participants : num_participants,
1008- }
1009- . into ( ) ) ;
1010- }
1011- }
1012- Ok ( ( ) )
1013- }
1014-
1015969 /// Propose adding a new set of domains for the MPC network.
1016970 /// If a threshold number of votes are reached on the exact same proposal, this will transition
1017971 /// the contract into the Initializing state to generate keys for the new domains.
@@ -1744,26 +1698,24 @@ impl MpcContract {
17441698 TeeValidationResult :: Partial {
17451699 participants_with_valid_attestation,
17461700 } => {
1747- let threshold = current_params. threshold ( ) . value ( ) as usize ;
17481701 let remaining = participants_with_valid_attestation. len ( ) ;
1749- // Defense in depth: the surviving participant set must cover every
1750- // threshold the network is bound to — the governance threshold and
1751- // each domain's reconstruction threshold (the kickout keeps the
1752- // existing per-domain thresholds). Resharing into a smaller set
1753- // would leave a key un-signable, so we refuse and wait for manual
1754- // intervention.
1755- let max_reconstruction_threshold = running_state
1756- . domains
1757- . domains ( )
1758- . iter ( )
1759- . map ( |domain| domain. reconstruction_threshold . inner ( ) as usize )
1760- . max ( )
1761- . unwrap_or ( 0 ) ;
1762- let required = threshold. max ( max_reconstruction_threshold) ;
1763- if required > remaining {
1702+ // Defense in depth: the surviving participant set must keep the full
1703+ // threshold relation intact — the GovernanceThreshold must still sit
1704+ // within its bounds for the smaller set (in particular it must not
1705+ // exceed the remaining participant count or the upper cap) and must
1706+ // remain at least every domain's ReconstructionThreshold (the kickout
1707+ // keeps the existing per-domain thresholds). Otherwise we refuse and
1708+ // wait for manual intervention.
1709+ let max_reconstruction_threshold =
1710+ max_reconstruction_threshold ( running_state. domains . domains ( ) ) ;
1711+ if let Err ( err) = ThresholdParameters :: validate_governance_against_reconstruction (
1712+ u64:: try_from ( remaining) . expect ( "participant count fits in u64" ) ,
1713+ current_params. threshold ( ) ,
1714+ max_reconstruction_threshold,
1715+ ) {
17641716 log ! (
1765- "Fewer than the required number of participants ({}) are left with a valid TEE status ({}) . This requires manual intervention. We will not accept new signature requests as a safety precaution." ,
1766- required ,
1717+ "Kicking out participants with an invalid TEE status would break the threshold relation ({:?}); {} participants remain with a valid TEE status. This requires manual intervention. We will not accept new signature requests as a safety precaution." ,
1718+ err ,
17671719 remaining,
17681720 ) ;
17691721 self . accept_requests = false ;
@@ -1778,7 +1730,8 @@ impl MpcContract {
17781730 //let n_participants_new = new_participants.len();
17791731 //let new_threshold = (3 * n_participants_new + 4) / 5; // minimum 60%
17801732 //let new_threshold = new_threshold.max(2); // but also minimum 2
1781- let new_threshold = threshold;
1733+ let new_threshold = usize:: try_from ( current_params. threshold ( ) . value ( ) )
1734+ . expect ( "threshold value fits in usize" ) ;
17821735
17831736 let threshold_parameters = ThresholdParameters :: new (
17841737 participants_with_valid_attestation,
@@ -2009,6 +1962,12 @@ impl MpcContract {
20091962 for domain in domains. domains ( ) {
20101963 crate :: primitives:: domain:: validate_domain_threshold ( domain, num_participants) ?;
20111964 }
1965+ // Keep the GovernanceThreshold at least as large as the largest ReconstructionThreshold.
1966+ ThresholdParameters :: validate_governance_against_reconstruction (
1967+ num_participants,
1968+ parameters. threshold ( ) ,
1969+ max_reconstruction_threshold ( domains. domains ( ) ) ,
1970+ ) ?;
20121971
20131972 // Check that the domains match exactly those in the keyset.
20141973 let domain_ids_from_domains = domains. domains ( ) . iter ( ) . map ( |d| d. id ) . collect :: < Vec < _ > > ( ) ;
@@ -2668,7 +2627,7 @@ mod tests {
26682627 } ;
26692628
26702629 use super :: * ;
2671- use crate :: errors:: NodeMigrationError ;
2630+ use crate :: errors:: { InvalidCandidateSet , InvalidThreshold , NodeMigrationError } ;
26722631 use crate :: pending_requests:: MAX_PENDING_REQUEST_FAN_OUT ;
26732632 use crate :: primitives:: participants:: { ParticipantId , ParticipantInfo , Participants } ;
26742633 use crate :: primitives:: test_utils:: {
@@ -4128,11 +4087,10 @@ mod tests {
41284087 }
41294088
41304089 #[ test]
4131- fn vote_new_parameters__should_reject_when_shrinking_below_unchanged_domain_threshold ( ) {
4132- // Given: a Running contract with 3 participants and a domain whose
4133- // reconstruction threshold is 3.
4090+ fn vote_new_parameters__should_reject_when_shrinking_below_governance_threshold ( ) {
4091+ // Given: a Running contract with 4 participants and a GovernanceThreshold of 3.
41344092 let ( mut contract, participants, signer, _domain_id) =
4135- setup_running_contract_with_domain ( 3 , 3 , 3 ) ;
4093+ setup_running_contract_with_domain ( 4 , 3 , 3 ) ;
41364094 // ...and a proposal that shrinks the participant set to 2 without touching
41374095 // the per-domain thresholds.
41384096 let proposal = ProposedThresholdParameters :: new (
@@ -4143,14 +4101,13 @@ mod tests {
41434101 // When
41444102 let result = vote_params ( & mut contract, & signer, & proposal) ;
41454103
4146- // Then: the domain's unchanged threshold of 3 exceeds the 2 proposed
4147- // participants, so the guard rejects it.
4104+ // Then: the candidate-set guard rejects the proposal first, because only 2
4105+ // old participants remain — fewer than the GovernanceThreshold of 3. (Under
4106+ // the GovernanceThreshold >= max(ReconstructionThreshold) invariant this guard
4107+ // always fires before any per-domain ReconstructionThreshold check could.)
41484108 assert_matches ! (
41494109 result. unwrap_err( ) ,
4150- Error :: DomainError ( DomainError :: ReconstructionThresholdExceedsParticipants {
4151- threshold: 3 ,
4152- participants: 2 ,
4153- } )
4110+ Error :: InvalidCandidateSet ( InvalidCandidateSet :: InsufficientOldParticipants )
41544111 ) ;
41554112 }
41564113
@@ -4177,15 +4134,15 @@ mod tests {
41774134
41784135 #[ test]
41794136 fn vote_new_parameters__should_accept_per_domain_threshold_within_participant_count ( ) {
4180- // Given: a Running contract with 3 participants and one domain.
4137+ // Given: a Running contract with 5 participants (GovernanceThreshold 4) and one domain.
41814138 let ( mut contract, participants, signer, domain_id) =
4182- setup_running_contract_with_domain ( 3 , 2 , 2 ) ;
4183- // ...and a proposal raising the domain's reconstruction threshold to 3 ,
4184- // which still fits the 3 participants.
4139+ setup_running_contract_with_domain ( 5 , 4 , 2 ) ;
4140+ // ...and a proposal raising the domain's reconstruction threshold to 4 ,
4141+ // which fits the 5 participants and does not exceed the GovernanceThreshold .
41854142 let mut per_domain = BTreeMap :: new ( ) ;
4186- per_domain. insert ( domain_id, ReconstructionThreshold :: new ( 3 ) ) ;
4143+ per_domain. insert ( domain_id, ReconstructionThreshold :: new ( 4 ) ) ;
41874144 let proposal = ProposedThresholdParameters :: new (
4188- ThresholdParameters :: new ( participants, Threshold :: new ( 2 ) ) . unwrap ( ) ,
4145+ ThresholdParameters :: new ( participants, Threshold :: new ( 4 ) ) . unwrap ( ) ,
41894146 per_domain,
41904147 ) ;
41914148
@@ -4196,6 +4153,33 @@ mod tests {
41964153 assert_matches ! ( result, Ok ( ( ) ) ) ;
41974154 }
41984155
4156+ #[ test]
4157+ fn vote_new_parameters__should_reject_governance_below_max_reconstruction ( ) {
4158+ // Given: a Running contract with 5 participants, GovernanceThreshold 4, and a
4159+ // domain whose reconstruction threshold is 4.
4160+ let ( mut contract, participants, signer, _domain_id) =
4161+ setup_running_contract_with_domain ( 5 , 4 , 4 ) ;
4162+ // ...and a proposal lowering the GovernanceThreshold to 3 (valid on its own)
4163+ // while the domain keeps its reconstruction threshold of 4.
4164+ let proposal = ProposedThresholdParameters :: new (
4165+ ThresholdParameters :: new ( participants, Threshold :: new ( 3 ) ) . unwrap ( ) ,
4166+ BTreeMap :: new ( ) ,
4167+ ) ;
4168+
4169+ // When
4170+ let result = vote_params ( & mut contract, & signer, & proposal) ;
4171+
4172+ // Then: the GovernanceThreshold (3) would fall below the domain's
4173+ // reconstruction threshold (4), so the proposal is rejected.
4174+ assert_matches ! (
4175+ result. unwrap_err( ) ,
4176+ Error :: InvalidThreshold ( InvalidThreshold :: BelowReconstructionThreshold {
4177+ reconstruction_threshold: 4 ,
4178+ governance_threshold: 3 ,
4179+ } )
4180+ ) ;
4181+ }
4182+
41994183 #[ test]
42004184 #[ should_panic( expected = "Caller must be the signer account" ) ]
42014185 fn vote_new_parameters__should_panic_when_predecessor_differs_from_signer ( ) {
@@ -5652,19 +5636,26 @@ mod tests {
56525636 }
56535637
56545638 /// Tests that [`MpcContract::verify_tee`] refuses to reshare when a TEE
5655- /// kickout would leave fewer participants than a domain's reconstruction
5656- /// threshold, even though the remaining set still meets the governance
5657- /// threshold. The contract stays Running and stops accepting requests.
5639+ /// kickout would leave fewer participants than the threshold relation requires.
5640+ /// The contract stays Running and stops accepting requests.
56585641 #[ test]
5659- fn verify_tee__should_refuse_kickout_below_domain_reconstruction_threshold ( ) {
5642+ fn verify_tee__should_refuse_kickout_when_remaining_breaks_threshold_relation ( ) {
56605643 const PARTICIPANT_COUNT : usize = 5 ;
56615644 const ATTESTATION_EXPIRY_SECONDS : u64 = 5 ;
56625645 const TEE_UPGRADE_DURATION : Duration = Duration :: MAX ;
56635646
5664- // Given: 5 participants, governance threshold 3, and one domain whose
5665- // reconstruction threshold is 5 (every participant is needed to sign).
5647+ // Given: 5 participants, GovernanceThreshold 5, and one domain whose
5648+ // reconstruction threshold is 5 (every participant is needed to sign). Dropping
5649+ // to 4 participants would leave the GovernanceThreshold above the participant
5650+ // count, breaking the threshold relation.
56665651 let participants = gen_participants ( PARTICIPANT_COUNT ) ;
5667- let parameters = ThresholdParameters :: new ( participants. clone ( ) , Threshold :: new ( 3 ) ) . unwrap ( ) ;
5652+ let parameters = ThresholdParameters :: new (
5653+ participants. clone ( ) ,
5654+ Threshold :: new (
5655+ u64:: try_from ( PARTICIPANT_COUNT ) . expect ( "participant count fits in u64" ) ,
5656+ ) ,
5657+ )
5658+ . unwrap ( ) ;
56685659 let domain_id = DomainId :: default ( ) ;
56695660 let domains = vec ! [ DomainConfig {
56705661 id: domain_id,
@@ -5717,9 +5708,9 @@ mod tests {
57175708 // When
57185709 let result = contract. verify_tee ( ) ;
57195710
5720- // Then: the 4 surviving participants meet the governance threshold (3) but
5721- // not the domain's reconstruction threshold (5) , so verify_tee refuses to
5722- // reshare, stays Running, and stops accepting requests.
5711+ // Then: with only 4 surviving participants the GovernanceThreshold of 5 would
5712+ // exceed the participant count, breaking the threshold relation , so verify_tee
5713+ // refuses to reshare, stays Running, and stops accepting requests.
57235714 assert_matches ! ( result, Ok ( false ) ) ;
57245715 assert_matches ! ( contract. protocol_state, ProtocolContractState :: Running ( _) ) ;
57255716 assert ! ( !contract. accept_requests) ;
0 commit comments