diff --git a/protobufs/operator/vault/bootstrap.proto b/protobufs/operator/vault/bootstrap.proto index f1bee1d..5fab6a4 100644 --- a/protobufs/operator/vault/bootstrap.proto +++ b/protobufs/operator/vault/bootstrap.proto @@ -9,7 +9,8 @@ message BootstrapEncryptedKey { } message DeclareCommittee { - uint32 count = 1; + uint32 count = 1; + uint32 recovery_count = 2; } message ContributePassphrase { diff --git a/server/crates/arbiter-server/src/actors/vault_coordinator/mod.rs b/server/crates/arbiter-server/src/actors/vault_coordinator/mod.rs index 107ab3e..1074763 100644 --- a/server/crates/arbiter-server/src/actors/vault_coordinator/mod.rs +++ b/server/crates/arbiter-server/src/actors/vault_coordinator/mod.rs @@ -39,6 +39,8 @@ pub enum Error { Encryption, #[error("Vault error")] VaultError, + #[error("Two-operator vaults require at least one recovery share")] + TwoOperatorsRequireRecovery, #[error("Broken database")] BrokenDatabase, } @@ -200,11 +202,15 @@ impl VaultCoordinator { &mut self, operator_id: i32, declared_count: usize, + recovery_count: usize, ) -> Result<(), Error> { let _ = operator_id; // fixme!: any authenticated operator may announce the committee size. the first call wins if !matches!(self.state, CoordinatorState::Idle) { return Err(Error::AlreadyBootstrapping); } + if declared_count == 2 && recovery_count == 0 { + return Err(Error::TwoOperatorsRequireRecovery); + } self.state = CoordinatorState::Bootstrapping { declared_count, passphrases: HashMap::new(), @@ -223,6 +229,7 @@ impl VaultCoordinator { let CoordinatorState::Bootstrapping { declared_count, passphrases, + .. } = &mut self.state else { return Err(Error::NotBootstrapping); diff --git a/server/crates/arbiter-server/src/grpc/operator/vault_gate/inbound.rs b/server/crates/arbiter-server/src/grpc/operator/vault_gate/inbound.rs index b0955eb..5342282 100644 --- a/server/crates/arbiter-server/src/grpc/operator/vault_gate/inbound.rs +++ b/server/crates/arbiter-server/src/grpc/operator/vault_gate/inbound.rs @@ -132,6 +132,7 @@ impl TryConvert for BootstrapRequestPayload { Self::DeclareCommittee(dc) => Ok( vault_gate::Inbound::HandleDeclareCommittee(HandleDeclareCommittee { count: dc.count as usize, + recovery_count: dc.recovery_count as usize, }), ), Self::ContributePassphrase(cp) => Ok( diff --git a/server/crates/arbiter-server/src/peers/operator/vault_gate/mod.rs b/server/crates/arbiter-server/src/peers/operator/vault_gate/mod.rs index 2e73555..611ba82 100644 --- a/server/crates/arbiter-server/src/peers/operator/vault_gate/mod.rs +++ b/server/crates/arbiter-server/src/peers/operator/vault_gate/mod.rs @@ -234,12 +234,17 @@ impl VaultGate { } #[message] - pub async fn handle_declare_committee(&mut self, count: usize) -> Result<(), Error> { + pub async fn handle_declare_committee( + &mut self, + count: usize, + recovery_count: usize, + ) -> Result<(), Error> { self.actors .vault_coordinator .ask(StartBootstrap { operator_id: self.auth_creds.id, declared_count: count, + recovery_count, }) .await .map_err(|_| Error::internal("VaultCoordinator unavailable")) diff --git a/server/crates/arbiter-server/tests/vault/lifecycle.rs b/server/crates/arbiter-server/tests/vault/lifecycle.rs index 6148590..9065654 100644 --- a/server/crates/arbiter-server/tests/vault/lifecycle.rs +++ b/server/crates/arbiter-server/tests/vault/lifecycle.rs @@ -4,6 +4,7 @@ use arbiter_server::{ actors::{ GlobalActors, vault::{Error, Vault}, + vault_coordinator::{Error as CoordinatorError, StartBootstrap, VaultCoordinator}, }, crypto::{KeyCell, encryption::v1::{Nonce, ROOT_KEY_TAG}}, db::{self, models, schema}, @@ -11,6 +12,7 @@ use arbiter_server::{ use diesel::{QueryDsl, SelectableHelper}; use diesel_async::RunQueryDsl; +use kameo::actor::Spawn as _; #[tokio::test] #[test_log::test] @@ -139,3 +141,29 @@ async fn test_unseal_wrong_then_correct_password() { let mut decrypted = actor.decrypt(aead_id).await.unwrap(); assert_eq!(*decrypted.read(), plaintext); } + +#[tokio::test] +#[test_log::test] +async fn two_operator_vault_requires_recovery_share() { + 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: 2, + recovery_count: 0, + }) + .await + .unwrap_err(); + + assert!( + matches!( + err, + kameo::error::SendError::HandlerError(CoordinatorError::TwoOperatorsRequireRecovery) + ), + "expected TwoOperatorsRequireRecovery, got {err:?}" + ); +}