From 5ade05d475683a31984b409713215967d1ea25e2 Mon Sep 17 00:00:00 2001 From: CleverWild Date: Mon, 7 Sep 2026 17:00:43 +0200 Subject: [PATCH] fix(vault): require an elapsed wake-up before a recovery share can unseal --- .../src/actors/proposal_manager.rs | 4 - .../src/actors/proposal_manager/store.rs | 17 +-- .../src/actors/vault_coordinator/mod.rs | 9 ++ server/crates/arbiter-server/src/db/mod.rs | 1 + .../crates/arbiter-server/src/db/recovery.rs | 102 ++++++++++++++++++ .../arbiter-server/tests/vault/lifecycle.rs | 96 ++++++++++++++++- 6 files changed, 211 insertions(+), 18 deletions(-) create mode 100644 server/crates/arbiter-server/src/db/recovery.rs diff --git a/server/crates/arbiter-server/src/actors/proposal_manager.rs b/server/crates/arbiter-server/src/actors/proposal_manager.rs index 8f8156e..ae93685 100644 --- a/server/crates/arbiter-server/src/actors/proposal_manager.rs +++ b/server/crates/arbiter-server/src/actors/proposal_manager.rs @@ -25,10 +25,6 @@ pub mod store; pub const DEFAULT_TTL_SECS: u32 = 7 * 24 * 60 * 60; // 7 days pub const MAX_TTL_SECS: u32 = DEFAULT_TTL_SECS; -/// Recovery operators stay asleep for this long after a wake-up is requested, so the other -/// operators have time to dispute it (§3.6). -const WAKEUP_DELAY_SECS: i32 = 14 * 24 * 60 * 60; // 14 days - #[derive(Debug, Clone, PartialEq, Eq)] pub enum VoteOutcome { Pending, diff --git a/server/crates/arbiter-server/src/actors/proposal_manager/store.rs b/server/crates/arbiter-server/src/actors/proposal_manager/store.rs index f735a7e..749443b 100644 --- a/server/crates/arbiter-server/src/actors/proposal_manager/store.rs +++ b/server/crates/arbiter-server/src/actors/proposal_manager/store.rs @@ -3,10 +3,9 @@ //! The actor holds a `dyn ProposalStore` rather than a connection pool, so the quorum //! rules can be exercised against a mock instead of a live SQLite file. -use super::{Error, ProposalSummary, WAKEUP_DELAY_SECS}; +use super::{Error, ProposalSummary}; use crate::db::{ self, - functions::unixepoch, models::{ NewProposal, NewProposalVote, NewRecoveryProposalVote, NewRecoveryWakeupRequest, OperatorIdentityId, Proposal, ProposalId, ProposalStatus, RecoveryOperatorIdentityId, @@ -360,17 +359,9 @@ impl ProposalStore for DieselProposalStore { async fn is_recovery_active(&self) -> Result { let mut conn = self.db.get().await?; - select(exists( - schema::recovery_wakeup_request::table - .filter(schema::recovery_wakeup_request::cancelled_at.is_null()) - .filter( - schema::recovery_wakeup_request::requested_at - .le(unixepoch("now") - WAKEUP_DELAY_SECS), - ), - )) - .get_result(&mut conn) - .await - .map_err(Error::from) + db::recovery::is_active(&mut conn) + .await + .map_err(Error::from) } async fn has_uncancelled_wakeup(&self) -> Result { 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 524bd6e..326536e 100644 --- a/server/crates/arbiter-server/src/actors/vault_coordinator/mod.rs +++ b/server/crates/arbiter-server/src/actors/vault_coordinator/mod.rs @@ -54,6 +54,8 @@ pub enum Error { BrokenDatabase, #[error("A committee must have at least one ordinary operator")] EmptyCommittee, + #[error("Recovery operators are sleeping")] + RecoveryNotActive, } // Passphrases stored as plain Vec (not SafeCell) so CoordinatorState is Sync. @@ -583,6 +585,13 @@ impl VaultCoordinator { recovery_operator_id: i32, mut passphrase: SafeCell>, ) -> Result { + { + let mut conn = self.db.get().await?; + if !db::recovery::is_active(&mut conn).await? { + return Err(Error::RecoveryNotActive); + } + } + self.ensure_unsealing_state().await?; let CoordinatorState::Unsealing { diff --git a/server/crates/arbiter-server/src/db/mod.rs b/server/crates/arbiter-server/src/db/mod.rs index 01d1b87..3f8352c 100644 --- a/server/crates/arbiter-server/src/db/mod.rs +++ b/server/crates/arbiter-server/src/db/mod.rs @@ -11,6 +11,7 @@ use tracing::info; pub mod functions; pub mod models; pub mod proposal; +pub mod recovery; pub mod schema; pub type DatabaseConnection = SyncConnectionWrapper; diff --git a/server/crates/arbiter-server/src/db/recovery.rs b/server/crates/arbiter-server/src/db/recovery.rs new file mode 100644 index 0000000..638331d --- /dev/null +++ b/server/crates/arbiter-server/src/db/recovery.rs @@ -0,0 +1,102 @@ +//! Whether the recovery committee is awake. +//! +//! §3.6: a wake-up request opens a dispute window; recovery powers only become active once +//! that window has elapsed without cancellation. Both the proposal manager (for voting) and +//! the vault coordinator (for unsealing) gate on this, so the rule lives in one place. + +use crate::db::{functions::unixepoch, schema}; + +use diesel::{ + ExpressionMethods as _, QueryDsl as _, + dsl::{exists, select}, +}; +use diesel_async::RunQueryDsl; + +/// Recovery operators stay asleep for this long after a wake-up is requested, so the other +/// operators have time to dispute it (§3.6). +pub const WAKEUP_DELAY_SECS: i32 = 14 * 24 * 60 * 60; + +/// True when an uncancelled wake-up request is older than the dispute window. +pub async fn is_active( + conn: &mut crate::db::DatabaseConnection, +) -> Result { + select(exists( + schema::recovery_wakeup_request::table + .filter(schema::recovery_wakeup_request::cancelled_at.is_null()) + .filter( + schema::recovery_wakeup_request::requested_at + .le(unixepoch("now") - WAKEUP_DELAY_SECS), + ), + )) + .get_result(conn) + .await +} + +#[cfg(test)] +mod tests { + use super::{WAKEUP_DELAY_SECS, is_active}; + use crate::db::{self, schema}; + + use diesel::{ExpressionMethods as _, insert_into}; + use diesel_async::RunQueryDsl; + + /// `recovery_wakeup_request.requested_by` references `operator_identity(id)`, and pooled + /// connections enforce foreign keys, so every wake-up row needs a real identity behind it. + async fn insert_operator(pool: &db::DatabasePool) -> i32 { + let mut conn = pool.get().await.unwrap(); + insert_into(schema::operator_identity::table) + .values(schema::operator_identity::public_key.eq(vec![7u8; 32])) + .returning(schema::operator_identity::id) + .get_result(&mut conn) + .await + .unwrap() + } + + /// Pins `.filter(requested_at.le(...))`: a wake-up requested moments ago must not be + /// active yet, even though nothing has cancelled it. Deleting that filter turns this + /// assertion false without touching any other test in the suite. + #[tokio::test] + async fn a_recent_wakeup_is_not_yet_active() { + let pool = db::create_test_pool().await; + let operator_id = insert_operator(&pool).await; + let mut conn = pool.get().await.unwrap(); + + diesel::sql_query(format!( + "INSERT INTO recovery_wakeup_request (requested_by, requested_at) \ + VALUES ({operator_id}, unixepoch('now'))" + )) + .execute(&mut conn) + .await + .unwrap(); + + assert!( + !is_active(&mut conn).await.unwrap(), + "a wake-up requested moments ago must still be asleep" + ); + } + + /// Pins `.filter(cancelled_at.is_null())`: a cancelled wake-up must not count towards + /// activity even once its original request has outlived the dispute window. Deleting + /// that filter turns this assertion false without touching any other test in the suite. + #[tokio::test] + async fn a_cancelled_wakeup_is_not_active_even_past_the_window() { + let pool = db::create_test_pool().await; + let operator_id = insert_operator(&pool).await; + let mut conn = pool.get().await.unwrap(); + + diesel::sql_query(format!( + "INSERT INTO recovery_wakeup_request \ + (requested_by, requested_at, cancelled_by, cancelled_at) \ + VALUES ({operator_id}, unixepoch('now') - {WAKEUP_DELAY_SECS} - 1, \ + {operator_id}, unixepoch('now'))" + )) + .execute(&mut conn) + .await + .unwrap(); + + assert!( + !is_active(&mut conn).await.unwrap(), + "a cancelled wake-up must not activate recovery even past the window" + ); + } +} diff --git a/server/crates/arbiter-server/tests/vault/lifecycle.rs b/server/crates/arbiter-server/tests/vault/lifecycle.rs index 8551659..8a655eb 100644 --- a/server/crates/arbiter-server/tests/vault/lifecycle.rs +++ b/server/crates/arbiter-server/tests/vault/lifecycle.rs @@ -13,7 +13,7 @@ use arbiter_server::{ db::{self, models, schema}, }; -use diesel::{ExpressionMethods, QueryDsl, SelectableHelper, insert_into}; +use diesel::{ExpressionMethods, QueryDsl, SelectableHelper, insert_into, sql_query}; use diesel_async::RunQueryDsl; use kameo::actor::Spawn as _; @@ -254,6 +254,19 @@ async fn recovery_share_stored_and_used_for_unseal() { let state = vault_ref2.ask(GetState {}).await.unwrap(); assert_eq!(state, VaultState::Sealed); + // §3.6: the recovery operator's share only counts once a wake-up request has stood + // uncancelled for the full dispute window, so back-date one here before unsealing. + { + let mut conn = db.get().await.unwrap(); + sql_query(format!( + "INSERT INTO recovery_wakeup_request (requested_by, requested_at) \ + VALUES ({ordinary_id}, unixepoch('now') - 14*24*3600 - 1)" + )) + .execute(&mut conn) + .await + .unwrap(); + } + // §3.5: Unseal using ONLY the recovery operator share (threshold = shamir_threshold(1) = 1). let coordinator2 = VaultCoordinator::spawn(VaultCoordinator::new(db.clone(), vault_ref2.clone())); let done = coordinator2 @@ -417,3 +430,84 @@ async fn unseal_threshold_survives_a_deleted_share_row() { VaultState::Unsealed ); } + +/// §3.6: recovery operators are asleep by default. Without a wake-up whose 14-day dispute +/// window has elapsed, their share must not count towards an unseal. +#[tokio::test] +#[test_log::test] +async fn sleeping_recovery_operator_cannot_contribute_to_unseal() { + 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.clone(), vault_ref.clone())); + + let ordinary_id: i32 = { + let mut conn = db.get().await.unwrap(); + insert_into(schema::operator_identity::table) + .values(schema::operator_identity::public_key.eq(vec![1u8; 32])) + .returning(schema::operator_identity::id) + .get_result(&mut conn) + .await + .unwrap() + }; + let recovery_id: i32 = { + let mut conn = db.get().await.unwrap(); + insert_into(schema::recovery_operator_identity::table) + .values(schema::recovery_operator_identity::public_key.eq(vec![2u8; 32])) + .returning(schema::recovery_operator_identity::id) + .get_result(&mut conn) + .await + .unwrap() + }; + + coordinator + .ask(StartBootstrap { + operator_id: ordinary_id, + declared_count: 1, + recovery_count: 1, + }) + .await + .unwrap(); + coordinator + .ask(ContributeRecoveryBootstrap { + recovery_operator_id: recovery_id, + passphrase: SafeCell::new(b"recovery-pass".to_vec()), + }) + .await + .unwrap(); + coordinator + .ask(ContributeBootstrap { + operator_id: ordinary_id, + passphrase: SafeCell::new(b"ordinary-pass".to_vec()), + }) + .await + .unwrap(); + + // Restart so the vault comes up Sealed. + drop(coordinator); + drop(vault_ref); + let bus2 = GlobalActors::spawn_message_bus(); + let vault_ref2 = Vault::spawn(Vault::new(db.clone(), bus2).await.unwrap()); + let coordinator2 = VaultCoordinator::spawn(VaultCoordinator::new(db.clone(), vault_ref2.clone())); + + let err = coordinator2 + .ask(ContributeRecoveryUnseal { + recovery_operator_id: recovery_id, + passphrase: SafeCell::new(b"recovery-pass".to_vec()), + }) + .await + .unwrap_err(); + + assert!( + matches!( + err, + kameo::error::SendError::HandlerError(CoordinatorError::RecoveryNotActive) + ), + "expected RecoveryNotActive, got {err:?}" + ); + assert_eq!( + vault_ref2.ask(GetState {}).await.unwrap(), + VaultState::Sealed, + "a sleeping recovery operator unsealed the vault" + ); +}