From 2fda0484fc410f7e8eefe3f8112a6d285879776a Mon Sep 17 00:00:00 2001 From: CleverWild Date: Sat, 13 Jun 2026 22:20:48 +0200 Subject: [PATCH] =?UTF-8?q?feat(server):=20key-rotation=20proposals=20requ?= =?UTF-8?q?ire=20full=20quorum=20(=C2=A73.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/actors/proposal_manager.rs | 13 ++++- .../crates/arbiter-server/tests/governance.rs | 48 +++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/server/crates/arbiter-server/src/actors/proposal_manager.rs b/server/crates/arbiter-server/src/actors/proposal_manager.rs index f97ed65..dc11a23 100644 --- a/server/crates/arbiter-server/src/actors/proposal_manager.rs +++ b/server/crates/arbiter-server/src/actors/proposal_manager.rs @@ -64,6 +64,12 @@ impl ProposalKind { } } + /// Key-rotation proposals require every operator to approve (§3.3). + #[must_use] + pub fn requires_full_quorum(kind: &str) -> bool { + matches!(kind, "replace_operator" | "update_shamir_parameters") + } + pub fn decode(kind: &str, payload: &[u8]) -> Result { match kind { "approve_sdk_client" => { @@ -379,7 +385,12 @@ impl ProposalManager { clippy::as_conversions, reason = "operator count is always a small positive integer" )] - let threshold = crate::crypto::shamir::shamir_threshold(total_operators as usize); + let threshold = if ProposalKind::requires_full_quorum(&proposal.kind) { + // §3.3: key-rotation proposals require every operator to approve + total_operators as usize + } else { + crate::crypto::shamir::shamir_threshold(total_operators as usize) + }; let approve_count: i64 = schema::proposal_vote::table .filter(schema::proposal_vote::proposal_id.eq(proposal_id)) diff --git a/server/crates/arbiter-server/tests/governance.rs b/server/crates/arbiter-server/tests/governance.rs index ca86e64..7cb1cff 100644 --- a/server/crates/arbiter-server/tests/governance.rs +++ b/server/crates/arbiter-server/tests/governance.rs @@ -792,6 +792,54 @@ async fn update_shamir_parameters_reaches_quorum() { assert_eq!(outcome, VoteOutcome::QuorumApproved); } +#[tokio::test] +async fn key_rotation_requires_full_quorum() { + // §3.3: ReplaceOperator needs all 3 operators to approve, not just shamir_threshold(3)=2 + let db = db::create_test_pool().await; + let actors = GlobalActors::spawn(db.clone()).await.unwrap(); + actors + .vault + .ask(Bootstrap { seal_key: KeyCell::from([0u8; 32]) }) + .await + .unwrap(); + + let key1 = authn::SigningKey::generate(); + let key2 = authn::SigningKey::generate(); + let key3 = authn::SigningKey::generate(); + let op1 = register_operator(&db, &key1.public_key()).await; + let op2 = register_operator(&db, &key2.public_key()).await; + let op3 = register_operator(&db, &key3.public_key()).await; + + let new_pubkey = authn::SigningKey::generate().public_key().to_bytes(); + let proposal_id = actors + .proposal_manager + .ask(CreateProposal { + kind: ProposalKind::ReplaceOperator { new_pubkey }, + initiator_id: op1, + ttl_secs: None, + }) + .await + .unwrap(); + + let cast = |op_id, key: &authn::SigningKey| { + let actors = actors.clone(); + let sig = key.sign_message(&make_vote_message(proposal_id, true), GOVERNANCE_CONTEXT).unwrap(); + async move { + actors + .proposal_manager + .ask(CastVote { proposal_id, operator_id: op_id, approve: true, signature: sig.to_bytes() }) + .await + .unwrap() + } + }; + + // With shamir_threshold(3)=2, two approvals would suffice for a normal proposal. + // For key rotation, they must not. + assert_eq!(cast(op1, &key1).await, VoteOutcome::Pending); + assert_eq!(cast(op2, &key2).await, VoteOutcome::Pending); + assert_eq!(cast(op3, &key3).await, VoteOutcome::QuorumApproved); +} + #[tokio::test] async fn approve_server_update_reaches_quorum() { let db = db::create_test_pool().await;