fix(vault): require an elapsed wake-up before a recovery share can unseal
This commit is contained in:
@@ -25,10 +25,6 @@ pub mod store;
|
|||||||
pub const DEFAULT_TTL_SECS: u32 = 7 * 24 * 60 * 60; // 7 days
|
pub const DEFAULT_TTL_SECS: u32 = 7 * 24 * 60 * 60; // 7 days
|
||||||
pub const MAX_TTL_SECS: u32 = DEFAULT_TTL_SECS;
|
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)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum VoteOutcome {
|
pub enum VoteOutcome {
|
||||||
Pending,
|
Pending,
|
||||||
|
|||||||
@@ -3,10 +3,9 @@
|
|||||||
//! The actor holds a `dyn ProposalStore` rather than a connection pool, so the quorum
|
//! 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.
|
//! 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::{
|
use crate::db::{
|
||||||
self,
|
self,
|
||||||
functions::unixepoch,
|
|
||||||
models::{
|
models::{
|
||||||
NewProposal, NewProposalVote, NewRecoveryProposalVote, NewRecoveryWakeupRequest,
|
NewProposal, NewProposalVote, NewRecoveryProposalVote, NewRecoveryWakeupRequest,
|
||||||
OperatorIdentityId, Proposal, ProposalId, ProposalStatus, RecoveryOperatorIdentityId,
|
OperatorIdentityId, Proposal, ProposalId, ProposalStatus, RecoveryOperatorIdentityId,
|
||||||
@@ -360,17 +359,9 @@ impl ProposalStore for DieselProposalStore {
|
|||||||
|
|
||||||
async fn is_recovery_active(&self) -> Result<bool, Error> {
|
async fn is_recovery_active(&self) -> Result<bool, Error> {
|
||||||
let mut conn = self.db.get().await?;
|
let mut conn = self.db.get().await?;
|
||||||
select(exists(
|
db::recovery::is_active(&mut conn)
|
||||||
schema::recovery_wakeup_request::table
|
.await
|
||||||
.filter(schema::recovery_wakeup_request::cancelled_at.is_null())
|
.map_err(Error::from)
|
||||||
.filter(
|
|
||||||
schema::recovery_wakeup_request::requested_at
|
|
||||||
.le(unixepoch("now") - WAKEUP_DELAY_SECS),
|
|
||||||
),
|
|
||||||
))
|
|
||||||
.get_result(&mut conn)
|
|
||||||
.await
|
|
||||||
.map_err(Error::from)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn has_uncancelled_wakeup(&self) -> Result<bool, Error> {
|
async fn has_uncancelled_wakeup(&self) -> Result<bool, Error> {
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ pub enum Error {
|
|||||||
BrokenDatabase,
|
BrokenDatabase,
|
||||||
#[error("A committee must have at least one ordinary operator")]
|
#[error("A committee must have at least one ordinary operator")]
|
||||||
EmptyCommittee,
|
EmptyCommittee,
|
||||||
|
#[error("Recovery operators are sleeping")]
|
||||||
|
RecoveryNotActive,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Passphrases stored as plain Vec<u8> (not SafeCell) so CoordinatorState is Sync.
|
// Passphrases stored as plain Vec<u8> (not SafeCell) so CoordinatorState is Sync.
|
||||||
@@ -583,6 +585,13 @@ impl VaultCoordinator {
|
|||||||
recovery_operator_id: i32,
|
recovery_operator_id: i32,
|
||||||
mut passphrase: SafeCell<Vec<u8>>,
|
mut passphrase: SafeCell<Vec<u8>>,
|
||||||
) -> Result<bool, Error> {
|
) -> Result<bool, Error> {
|
||||||
|
{
|
||||||
|
let mut conn = self.db.get().await?;
|
||||||
|
if !db::recovery::is_active(&mut conn).await? {
|
||||||
|
return Err(Error::RecoveryNotActive);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
self.ensure_unsealing_state().await?;
|
self.ensure_unsealing_state().await?;
|
||||||
|
|
||||||
let CoordinatorState::Unsealing {
|
let CoordinatorState::Unsealing {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use tracing::info;
|
|||||||
pub mod functions;
|
pub mod functions;
|
||||||
pub mod models;
|
pub mod models;
|
||||||
pub mod proposal;
|
pub mod proposal;
|
||||||
|
pub mod recovery;
|
||||||
pub mod schema;
|
pub mod schema;
|
||||||
|
|
||||||
pub type DatabaseConnection = SyncConnectionWrapper<SqliteConnection>;
|
pub type DatabaseConnection = SyncConnectionWrapper<SqliteConnection>;
|
||||||
|
|||||||
102
server/crates/arbiter-server/src/db/recovery.rs
Normal file
102
server/crates/arbiter-server/src/db/recovery.rs
Normal file
@@ -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<bool, diesel::result::Error> {
|
||||||
|
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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ use arbiter_server::{
|
|||||||
db::{self, models, schema},
|
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 diesel_async::RunQueryDsl;
|
||||||
use kameo::actor::Spawn as _;
|
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();
|
let state = vault_ref2.ask(GetState {}).await.unwrap();
|
||||||
assert_eq!(state, VaultState::Sealed);
|
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).
|
// §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 coordinator2 = VaultCoordinator::spawn(VaultCoordinator::new(db.clone(), vault_ref2.clone()));
|
||||||
let done = coordinator2
|
let done = coordinator2
|
||||||
@@ -417,3 +430,84 @@ async fn unseal_threshold_survives_a_deleted_share_row() {
|
|||||||
VaultState::Unsealed
|
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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user