fix(crypto): return None from shamir_threshold for an empty committee

This commit is contained in:
CleverWild
2026-09-07 13:03:57 +02:00
parent f32da65467
commit d916997ef3
4 changed files with 83 additions and 11 deletions

View File

@@ -277,7 +277,11 @@ impl ProposalManager {
let threshold: i64 = if requires_full_quorum { let threshold: i64 = if requires_full_quorum {
total_eligible total_eligible
} else { } else {
crate::crypto::shamir::shamir_threshold(tally.total_ordinary as usize) as i64 match crate::crypto::shamir::shamir_threshold(tally.total_ordinary as usize) {
Some(threshold) => threshold as i64,
// No ordinary operators means no electorate: nothing can settle.
None => return VoteOutcome::Pending,
}
}; };
if tally.approve >= threshold { if tally.approve >= threshold {

View File

@@ -52,6 +52,8 @@ pub enum Error {
TwoOperatorsRequireRecovery, TwoOperatorsRequireRecovery,
#[error("Broken database")] #[error("Broken database")]
BrokenDatabase, BrokenDatabase,
#[error("A committee must have at least one ordinary operator")]
EmptyCommittee,
} }
// Passphrases stored as plain Vec<u8> (not SafeCell) so CoordinatorState is Sync. // Passphrases stored as plain Vec<u8> (not SafeCell) so CoordinatorState is Sync.
@@ -153,7 +155,7 @@ async fn finalize_bootstrap(
let ordinary_count = ordinary_passphrases.len(); let ordinary_count = ordinary_passphrases.len();
let recovery_count = recovery_passphrases.len(); let recovery_count = recovery_passphrases.len();
let total = ordinary_count + recovery_count; let total = ordinary_count + recovery_count;
let threshold = shamir_threshold(ordinary_count); let threshold = shamir_threshold(ordinary_count).ok_or(Error::EmptyCommittee)?;
let mut seal_key_bytes = [0u8; 32]; let mut seal_key_bytes = [0u8; 32];
OsRng.fill_bytes(&mut seal_key_bytes); OsRng.fill_bytes(&mut seal_key_bytes);
@@ -232,7 +234,8 @@ async fn finalize_unseal(
.count() .count()
.get_result(&mut conn) .get_result(&mut conn)
.await?; .await?;
let threshold = shamir_threshold(ordinary_operator_count as usize); let threshold =
shamir_threshold(ordinary_operator_count as usize).ok_or(Error::EmptyCommittee)?;
let mut shares: Vec<Vec<u8>> = Vec::new(); let mut shares: Vec<Vec<u8>> = Vec::new();
@@ -314,7 +317,7 @@ async fn finalize_rekey(
let ordinary_count = ordinary_passphrases.len(); let ordinary_count = ordinary_passphrases.len();
let recovery_count = recovery_passphrases.len(); let recovery_count = recovery_passphrases.len();
let total = ordinary_count + recovery_count; let total = ordinary_count + recovery_count;
let threshold = shamir_threshold(ordinary_count); let threshold = shamir_threshold(ordinary_count).ok_or(Error::EmptyCommittee)?;
let mut new_seal_key_bytes = [0u8; 32]; let mut new_seal_key_bytes = [0u8; 32];
OsRng.fill_bytes(&mut new_seal_key_bytes); OsRng.fill_bytes(&mut new_seal_key_bytes);
@@ -398,6 +401,9 @@ impl VaultCoordinator {
if !matches!(self.state, CoordinatorState::Idle) { if !matches!(self.state, CoordinatorState::Idle) {
return Err(Error::AlreadyBootstrapping); return Err(Error::AlreadyBootstrapping);
} }
if declared_count == 0 {
return Err(Error::EmptyCommittee);
}
if declared_count == 2 && recovery_count == 0 { if declared_count == 2 && recovery_count == 0 {
return Err(Error::TwoOperatorsRequireRecovery); return Err(Error::TwoOperatorsRequireRecovery);
} }
@@ -584,7 +590,8 @@ impl VaultCoordinator {
.count() .count()
.get_result(&mut conn) .get_result(&mut conn)
.await?; .await?;
let threshold = shamir_threshold(usize::try_from(ordinary_count).unwrap_or_default()); let threshold = shamir_threshold(usize::try_from(ordinary_count).unwrap_or_default())
.ok_or(Error::EmptyCommittee)?;
self.state = CoordinatorState::Unsealing { self.state = CoordinatorState::Unsealing {
threshold, threshold,
ordinary_passphrases: HashMap::new(), ordinary_passphrases: HashMap::new(),

View File

@@ -21,14 +21,14 @@ pub fn split_key(
} }
/// Returns the minimum number of shares required to reconstruct the secret /// Returns the minimum number of shares required to reconstruct the secret
/// for a committee of `n` operators. /// for a committee of `n` operators, or `None` for an empty committee.
#[must_use] #[must_use]
pub const fn shamir_threshold(n: usize) -> usize { pub const fn shamir_threshold(n: usize) -> Option<usize> {
match n { match n {
0 => panic!("No operators"), 0 => None,
1 => 1, 1 => Some(1),
2 => 2, 2 => Some(2),
n => n / 2 + 1, n => Some(n / 2 + 1),
} }
} }
@@ -39,3 +39,22 @@ pub fn combine_shares(shares: &[Vec<u8>]) -> Result<[u8; 32], ShamirError> {
<[u8; 32]>::try_from(bytes.as_slice()) <[u8; 32]>::try_from(bytes.as_slice())
.map_err(|_| ShamirError::Combine("unexpected reconstructed key length".to_owned())) .map_err(|_| ShamirError::Combine("unexpected reconstructed key length".to_owned()))
} }
#[cfg(test)]
mod tests {
use super::shamir_threshold;
#[test]
fn empty_committee_has_no_threshold() {
assert_eq!(shamir_threshold(0), None);
}
#[test]
fn threshold_follows_the_ordinary_quorum() {
// ARCHITECTURE.md §3.1/§3.4: 1 decides alone, 2 need consensus, N needs N/2 + 1.
assert_eq!(shamir_threshold(1), Some(1));
assert_eq!(shamir_threshold(2), Some(2));
assert_eq!(shamir_threshold(3), Some(2));
assert_eq!(shamir_threshold(4), Some(3));
}
}

View File

@@ -268,3 +268,45 @@ async fn recovery_share_stored_and_used_for_unseal() {
let state = vault_ref2.ask(GetState {}).await.unwrap(); let state = vault_ref2.ask(GetState {}).await.unwrap();
assert_eq!(state, VaultState::Unsealed); assert_eq!(state, VaultState::Unsealed);
} }
/// A committee of zero ordinary operators used to reach `shamir_threshold(0)` and panic,
/// taking the global coordinator down with it.
#[tokio::test]
#[test_log::test]
async fn empty_committee_is_rejected_without_panicking() {
let db = db::create_test_pool().await;
let bus = GlobalActors::spawn_message_bus();
let vault_ref = Vault::spawn(Vault::new(db.clone(), bus).await.unwrap());
let coordinator = VaultCoordinator::spawn(VaultCoordinator::new(db, vault_ref));
let err = coordinator
.ask(StartBootstrap {
operator_id: 1,
declared_count: 0,
recovery_count: 1,
})
.await
.unwrap_err();
assert!(
matches!(
err,
kameo::error::SendError::HandlerError(CoordinatorError::EmptyCommittee)
),
"expected EmptyCommittee, got {err:?}"
);
// The actor must still be alive to serve the next caller.
let err = coordinator
.ask(StartBootstrap {
operator_id: 1,
declared_count: 0,
recovery_count: 0,
})
.await
.unwrap_err();
assert!(matches!(
err,
kameo::error::SendError::HandlerError(CoordinatorError::EmptyCommittee)
));
}