feat(server): key-rotation proposals require full quorum (§3.3)
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful

This commit is contained in:
CleverWild
2026-06-13 22:20:48 +02:00
parent f8c621b20e
commit 2fda0484fc
2 changed files with 60 additions and 1 deletions

View File

@@ -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<Self, String> { pub fn decode(kind: &str, payload: &[u8]) -> Result<Self, String> {
match kind { match kind {
"approve_sdk_client" => { "approve_sdk_client" => {
@@ -379,7 +385,12 @@ impl ProposalManager {
clippy::as_conversions, clippy::as_conversions,
reason = "operator count is always a small positive integer" 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 let approve_count: i64 = schema::proposal_vote::table
.filter(schema::proposal_vote::proposal_id.eq(proposal_id)) .filter(schema::proposal_vote::proposal_id.eq(proposal_id))

View File

@@ -792,6 +792,54 @@ async fn update_shamir_parameters_reaches_quorum() {
assert_eq!(outcome, VoteOutcome::QuorumApproved); 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] #[tokio::test]
async fn approve_server_update_reaches_quorum() { async fn approve_server_update_reaches_quorum() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;