feat(server): introduce VaultCoordinator for multi-operator Shamir bootstrap/unseal
VaultCoordinator collects operator passphrases, splits the seal key into Shamir shares on bootstrap (encrypting each share with the operator's passphrase via Argon2 + XChaCha20-Poly1305), and reconstructs the seal key from threshold shares on unseal. Adds vsss-rs 5.4.0 and rand_core 0.6 dependencies.
This commit is contained in:
8
server/Cargo.lock
generated
8
server/Cargo.lock
generated
@@ -771,6 +771,7 @@ dependencies = [
|
|||||||
"proptest",
|
"proptest",
|
||||||
"prost-types",
|
"prost-types",
|
||||||
"rand 0.10.1",
|
"rand 0.10.1",
|
||||||
|
"rand_core 0.6.4",
|
||||||
"rcgen",
|
"rcgen",
|
||||||
"restructed",
|
"restructed",
|
||||||
"rstest",
|
"rstest",
|
||||||
@@ -3027,7 +3028,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "kameo"
|
name = "kameo"
|
||||||
version = "0.20.0"
|
version = "0.20.0"
|
||||||
source = "git+https://github.com/hdbg/kameo.git?rev=805b417#805b41783fe90b54827ecad142b422c7a9b69b9a"
|
source = "git+https://github.com/hdbg/kameo.git?rev=3e18ba2#3e18ba24023d0422034e60ff2ea1ecd49e8c3c93"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"downcast-rs",
|
"downcast-rs",
|
||||||
"dyn-clone",
|
"dyn-clone",
|
||||||
@@ -3041,7 +3042,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "kameo_actors"
|
name = "kameo_actors"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
source = "git+https://github.com/hdbg/kameo.git?rev=805b417#805b41783fe90b54827ecad142b422c7a9b69b9a"
|
source = "git+https://github.com/hdbg/kameo.git?rev=3e18ba2#3e18ba24023d0422034e60ff2ea1ecd49e8c3c93"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures",
|
"futures",
|
||||||
"glob",
|
"glob",
|
||||||
@@ -3053,9 +3054,8 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "kameo_macros"
|
name = "kameo_macros"
|
||||||
version = "0.20.0"
|
version = "0.20.0"
|
||||||
source = "git+https://github.com/hdbg/kameo.git?rev=805b417#805b41783fe90b54827ecad142b422c7a9b69b9a"
|
source = "git+https://github.com/hdbg/kameo.git?rev=3e18ba2#3e18ba24023d0422034e60ff2ea1ecd49e8c3c93"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"darling 0.23.0",
|
|
||||||
"heck",
|
"heck",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ base64 = "0.22.1"
|
|||||||
chrono = { version = "0.4.44", features = ["serde"] }
|
chrono = { version = "0.4.44", features = ["serde"] }
|
||||||
futures = "0.3.32"
|
futures = "0.3.32"
|
||||||
k256 = { version = "0.13.4", features = ["ecdsa", "pkcs8"] }
|
k256 = { version = "0.13.4", features = ["ecdsa", "pkcs8"] }
|
||||||
kameo = {git = "https://github.com/hdbg/kameo.git", rev = "805b417"}
|
kameo = {git = "https://github.com/hdbg/kameo.git", rev = "3e18ba2"}
|
||||||
kameo_actors = {git = "https://github.com/hdbg/kameo.git", rev = "805b417"}
|
kameo_actors = {git = "https://github.com/hdbg/kameo.git", rev = "3e18ba2"}
|
||||||
hmac = "0.13.0"
|
hmac = "0.13.0"
|
||||||
miette = { version = "7.6.0", features = ["fancy", "serde"] }
|
miette = { version = "7.6.0", features = ["fancy", "serde"] }
|
||||||
ml-dsa = { version = "0.1.0-rc.9", features = ["zeroize"] }
|
ml-dsa = { version = "0.1.0-rc.9", features = ["zeroize"] }
|
||||||
@@ -106,7 +106,6 @@ indexing_slicing = "warn"
|
|||||||
infinite_loop = "warn"
|
infinite_loop = "warn"
|
||||||
inline_asm_x86_att_syntax = "warn"
|
inline_asm_x86_att_syntax = "warn"
|
||||||
inline_asm_x86_intel_syntax = "warn"
|
inline_asm_x86_intel_syntax = "warn"
|
||||||
integer_division = "warn"
|
|
||||||
large_include_file = "warn"
|
large_include_file = "warn"
|
||||||
lossy_float_literal = "warn"
|
lossy_float_literal = "warn"
|
||||||
map_with_unused_argument_over_ranges = "warn"
|
map_with_unused_argument_over_ranges = "warn"
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ x25519-dalek.workspace = true
|
|||||||
k256.workspace = true
|
k256.workspace = true
|
||||||
kameo_actors.workspace = true
|
kameo_actors.workspace = true
|
||||||
vsss-rs = "5.4.0"
|
vsss-rs = "5.4.0"
|
||||||
|
rand_core = "0.6"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
proptest = "1.11.0"
|
proptest = "1.11.0"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ use crate::{
|
|||||||
actors::{
|
actors::{
|
||||||
bootstrap::Bootstrapper, evm::EvmActor, flow_coordinator::FlowCoordinator,
|
bootstrap::Bootstrapper, evm::EvmActor, flow_coordinator::FlowCoordinator,
|
||||||
operator_registry::OperatorRegistry, vault::Vault,
|
operator_registry::OperatorRegistry, vault::Vault,
|
||||||
|
vault_coordinator::VaultCoordinator,
|
||||||
},
|
},
|
||||||
db,
|
db,
|
||||||
};
|
};
|
||||||
@@ -15,6 +16,7 @@ pub mod evm;
|
|||||||
pub mod flow_coordinator;
|
pub mod flow_coordinator;
|
||||||
pub mod operator_registry;
|
pub mod operator_registry;
|
||||||
pub mod vault;
|
pub mod vault;
|
||||||
|
pub mod vault_coordinator;
|
||||||
|
|
||||||
#[derive(Error, Debug)]
|
#[derive(Error, Debug)]
|
||||||
pub enum SpawnError {
|
pub enum SpawnError {
|
||||||
@@ -30,6 +32,7 @@ pub enum SpawnError {
|
|||||||
pub struct GlobalActors {
|
pub struct GlobalActors {
|
||||||
pub vault: ActorRef<Vault>,
|
pub vault: ActorRef<Vault>,
|
||||||
pub bootstrapper: ActorRef<Bootstrapper>,
|
pub bootstrapper: ActorRef<Bootstrapper>,
|
||||||
|
pub vault_coordinator: ActorRef<VaultCoordinator>,
|
||||||
pub flow_coordinator: ActorRef<FlowCoordinator>,
|
pub flow_coordinator: ActorRef<FlowCoordinator>,
|
||||||
pub operator_registry: ActorRef<OperatorRegistry>,
|
pub operator_registry: ActorRef<OperatorRegistry>,
|
||||||
pub evm: ActorRef<EvmActor>,
|
pub evm: ActorRef<EvmActor>,
|
||||||
@@ -47,7 +50,11 @@ impl GlobalActors {
|
|||||||
let operator_registry = OperatorRegistry::spawn(OperatorRegistry::default());
|
let operator_registry = OperatorRegistry::spawn(OperatorRegistry::default());
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
bootstrapper: Bootstrapper::spawn(Bootstrapper::new(&db).await?),
|
bootstrapper: Bootstrapper::spawn(Bootstrapper::new(&db).await?),
|
||||||
evm: EvmActor::spawn(EvmActor::new(key_holder.clone(), db)),
|
evm: EvmActor::spawn(EvmActor::new(key_holder.clone(), db.clone())),
|
||||||
|
vault_coordinator: VaultCoordinator::spawn(VaultCoordinator::new(
|
||||||
|
db,
|
||||||
|
key_holder.clone(),
|
||||||
|
)),
|
||||||
vault: key_holder,
|
vault: key_holder,
|
||||||
flow_coordinator: FlowCoordinator::spawn(FlowCoordinator::new(
|
flow_coordinator: FlowCoordinator::spawn(FlowCoordinator::new(
|
||||||
operator_registry.clone(),
|
operator_registry.clone(),
|
||||||
|
|||||||
316
server/crates/arbiter-server/src/actors/vault_coordinator/mod.rs
Normal file
316
server/crates/arbiter-server/src/actors/vault_coordinator/mod.rs
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
|
||||||
|
use diesel::{ExpressionMethods as _, QueryDsl};
|
||||||
|
use diesel_async::RunQueryDsl;
|
||||||
|
use kameo::{Actor, actor::ActorRef, messages};
|
||||||
|
use rand_core::{OsRng, RngCore as _};
|
||||||
|
use tracing::error;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
actors::vault::{Bootstrap, TryUnseal, Vault},
|
||||||
|
crypto::{derive_key, encryption::v1::Nonce, shamir},
|
||||||
|
db::{self, models, schema},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum Error {
|
||||||
|
#[error("Already coordinating a bootstrap")]
|
||||||
|
AlreadyBootstrapping,
|
||||||
|
#[error("Already coordinating an unseal")]
|
||||||
|
AlreadyUnsealing,
|
||||||
|
#[error("Bootstrap not in progress")]
|
||||||
|
NotBootstrapping,
|
||||||
|
#[error("Unseal not in progress")]
|
||||||
|
NotUnsealing,
|
||||||
|
#[error("Operator already contributed")]
|
||||||
|
DuplicateContribution,
|
||||||
|
#[error("Operator not found in database")]
|
||||||
|
OperatorNotFound,
|
||||||
|
#[error("Invalid passphrase (decryption failed)")]
|
||||||
|
InvalidPassphrase,
|
||||||
|
#[error("Shamir error: {0}")]
|
||||||
|
Shamir(String),
|
||||||
|
#[error("Database connection error: {0}")]
|
||||||
|
DatabaseConnection(#[from] db::PoolError),
|
||||||
|
#[error("Database query error: {0}")]
|
||||||
|
DatabaseQuery(#[from] diesel::result::Error),
|
||||||
|
#[error("Encryption error")]
|
||||||
|
Encryption,
|
||||||
|
#[error("Vault error")]
|
||||||
|
VaultError,
|
||||||
|
#[error("Broken database")]
|
||||||
|
BrokenDatabase,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Passphrases stored as plain Vec<u8> (not SafeCell) so CoordinatorState is Sync.
|
||||||
|
// They are ephemeral and dropped immediately after use.
|
||||||
|
enum CoordinatorState {
|
||||||
|
Idle,
|
||||||
|
Bootstrapping {
|
||||||
|
declared_count: usize,
|
||||||
|
passphrases: HashMap<i32, Vec<u8>>,
|
||||||
|
},
|
||||||
|
Unsealing {
|
||||||
|
threshold: usize,
|
||||||
|
passphrases: HashMap<i32, Vec<u8>>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Actor)]
|
||||||
|
pub struct VaultCoordinator {
|
||||||
|
db: db::DatabasePool,
|
||||||
|
vault: ActorRef<Vault>,
|
||||||
|
state: CoordinatorState,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VaultCoordinator {
|
||||||
|
pub const fn new(db: db::DatabasePool, vault: ActorRef<Vault>) -> Self {
|
||||||
|
Self {
|
||||||
|
db,
|
||||||
|
vault,
|
||||||
|
state: CoordinatorState::Idle,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const SHARE_AAD: &[u8] = b"arbiter/shamir-share/v1";
|
||||||
|
|
||||||
|
const fn shamir_threshold(n: usize) -> usize {
|
||||||
|
match n {
|
||||||
|
0 => panic!("No operators"),
|
||||||
|
1 => 1,
|
||||||
|
2 => 2,
|
||||||
|
n => n / 2 + 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn finalize_bootstrap(
|
||||||
|
db: db::DatabasePool,
|
||||||
|
vault: ActorRef<Vault>,
|
||||||
|
passphrases: HashMap<i32, Vec<u8>>,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
let total = passphrases.len();
|
||||||
|
let threshold = shamir_threshold(total);
|
||||||
|
|
||||||
|
// Generate random 32-byte seal key
|
||||||
|
let mut seal_key_bytes = vec![0u8; 32];
|
||||||
|
OsRng.fill_bytes(&mut seal_key_bytes);
|
||||||
|
|
||||||
|
// Split seal key into shares using Shamir (OsRng from rand_core 0.6, compatible with vsss-rs)
|
||||||
|
let shares = shamir::split_key(threshold, total, &seal_key_bytes, OsRng)
|
||||||
|
.map_err(|e| Error::Shamir(e.to_string()))?;
|
||||||
|
|
||||||
|
let seal_key = SafeCell::new(seal_key_bytes);
|
||||||
|
|
||||||
|
let mut conn = db.get().await?;
|
||||||
|
|
||||||
|
for ((operator_id_raw, passphrase_bytes), share) in passphrases.into_iter().zip(shares) {
|
||||||
|
// Generate a fresh share_salt for this operator
|
||||||
|
let mut share_salt = vec![0u8; 32];
|
||||||
|
OsRng.fill_bytes(&mut share_salt);
|
||||||
|
|
||||||
|
// Derive share encryption key from passphrase + salt
|
||||||
|
let mut passphrase_cell = SafeCell::new(passphrase_bytes);
|
||||||
|
let mut share_seal_key = derive_key(&mut passphrase_cell, &share_salt);
|
||||||
|
|
||||||
|
// Encrypt this operator's share
|
||||||
|
let nonce = Nonce::default();
|
||||||
|
let encrypted_share = share_seal_key
|
||||||
|
.encrypt(&nonce, SHARE_AAD, &share)
|
||||||
|
.map_err(|_| Error::Encryption)?;
|
||||||
|
|
||||||
|
let nonce_bytes = nonce.to_vec();
|
||||||
|
|
||||||
|
diesel::replace_into(schema::operator::table)
|
||||||
|
.values((
|
||||||
|
schema::operator::id.eq(Some(operator_id_raw)),
|
||||||
|
schema::operator::share.eq(&encrypted_share),
|
||||||
|
schema::operator::share_nonce.eq(&nonce_bytes),
|
||||||
|
schema::operator::share_salt.eq(&share_salt),
|
||||||
|
schema::operator::created_at.eq(models::SqliteTimestamp::now()),
|
||||||
|
schema::operator::updated_at.eq(models::SqliteTimestamp::now()),
|
||||||
|
))
|
||||||
|
.execute(&mut conn)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
vault
|
||||||
|
.ask(Bootstrap {
|
||||||
|
seal_key_raw: seal_key,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|err| {
|
||||||
|
error!(?err, "Vault bootstrap failed");
|
||||||
|
Error::VaultError
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn finalize_unseal(
|
||||||
|
db: db::DatabasePool,
|
||||||
|
vault: ActorRef<Vault>,
|
||||||
|
passphrases: HashMap<i32, Vec<u8>>,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
let mut conn = db.get().await?;
|
||||||
|
let mut shares: Vec<Vec<u8>> = Vec::new();
|
||||||
|
|
||||||
|
for (operator_id_raw, passphrase_bytes) in passphrases {
|
||||||
|
let (encrypted_share, share_nonce_bytes, share_salt): (Vec<u8>, Vec<u8>, Vec<u8>) =
|
||||||
|
schema::operator::table
|
||||||
|
.filter(schema::operator::id.eq(Some(operator_id_raw)))
|
||||||
|
.select((
|
||||||
|
schema::operator::share,
|
||||||
|
schema::operator::share_nonce,
|
||||||
|
schema::operator::share_salt,
|
||||||
|
))
|
||||||
|
.first(&mut conn)
|
||||||
|
.await
|
||||||
|
.map_err(|_| Error::OperatorNotFound)?;
|
||||||
|
|
||||||
|
let nonce = Nonce::try_from(share_nonce_bytes.as_slice()).map_err(|()| {
|
||||||
|
error!(operator_id = operator_id_raw, "Invalid nonce in DB");
|
||||||
|
Error::BrokenDatabase
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mut passphrase_cell = SafeCell::new(passphrase_bytes);
|
||||||
|
let mut share_seal_key = derive_key(&mut passphrase_cell, &share_salt);
|
||||||
|
|
||||||
|
let mut share_buffer = SafeCell::new(encrypted_share);
|
||||||
|
share_seal_key
|
||||||
|
.decrypt_in_place(&nonce, SHARE_AAD, &mut share_buffer)
|
||||||
|
.map_err(|_| Error::InvalidPassphrase)?;
|
||||||
|
|
||||||
|
let decrypted_share = share_buffer.read().clone();
|
||||||
|
shares.push(decrypted_share);
|
||||||
|
}
|
||||||
|
|
||||||
|
let seal_key_bytes =
|
||||||
|
shamir::combine_shares(&shares).map_err(|e| Error::Shamir(e.to_string()))?;
|
||||||
|
|
||||||
|
let seal_key = SafeCell::new(seal_key_bytes);
|
||||||
|
|
||||||
|
vault
|
||||||
|
.ask(TryUnseal {
|
||||||
|
seal_key_raw: seal_key,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|err| {
|
||||||
|
error!(?err, "Vault unseal failed");
|
||||||
|
Error::VaultError
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[messages]
|
||||||
|
impl VaultCoordinator {
|
||||||
|
/// Phase 1 of multi-operator bootstrap: declare the committee size.
|
||||||
|
#[message]
|
||||||
|
#[expect(clippy::unused_async, reason = "kameo requires messages to be async")]
|
||||||
|
pub async fn start_bootstrap(
|
||||||
|
&mut self,
|
||||||
|
operator_id: i32,
|
||||||
|
declared_count: usize,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
let _ = operator_id;
|
||||||
|
if !matches!(self.state, CoordinatorState::Idle) {
|
||||||
|
return Err(Error::AlreadyBootstrapping);
|
||||||
|
}
|
||||||
|
self.state = CoordinatorState::Bootstrapping {
|
||||||
|
declared_count,
|
||||||
|
passphrases: HashMap::new(),
|
||||||
|
};
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Phase 2 of multi-operator bootstrap: contribute a passphrase.
|
||||||
|
/// Returns Ok(true) when all operators contributed and bootstrap finalized.
|
||||||
|
#[message]
|
||||||
|
pub async fn contribute_bootstrap(
|
||||||
|
&mut self,
|
||||||
|
operator_id: i32,
|
||||||
|
mut passphrase: SafeCell<Vec<u8>>,
|
||||||
|
) -> Result<bool, Error> {
|
||||||
|
let CoordinatorState::Bootstrapping {
|
||||||
|
declared_count,
|
||||||
|
passphrases,
|
||||||
|
} = &mut self.state
|
||||||
|
else {
|
||||||
|
return Err(Error::NotBootstrapping);
|
||||||
|
};
|
||||||
|
|
||||||
|
if passphrases.contains_key(&operator_id) {
|
||||||
|
return Err(Error::DuplicateContribution);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract bytes immediately so state stays Sync
|
||||||
|
let passphrase_bytes = passphrase.read().to_vec();
|
||||||
|
passphrases.insert(operator_id, passphrase_bytes);
|
||||||
|
|
||||||
|
if passphrases.len() < *declared_count {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
let CoordinatorState::Bootstrapping { passphrases, .. } =
|
||||||
|
std::mem::replace(&mut self.state, CoordinatorState::Idle)
|
||||||
|
else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
|
||||||
|
finalize_bootstrap(self.db.clone(), self.vault.clone(), passphrases).await?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Contribute a passphrase for vault unseal.
|
||||||
|
/// Returns Ok(true) when threshold reached and vault is unsealed.
|
||||||
|
#[message]
|
||||||
|
pub async fn contribute_unseal(
|
||||||
|
&mut self,
|
||||||
|
operator_id: i32,
|
||||||
|
mut passphrase: SafeCell<Vec<u8>>,
|
||||||
|
) -> Result<bool, Error> {
|
||||||
|
if matches!(self.state, CoordinatorState::Idle) {
|
||||||
|
let mut conn = self.db.get().await?;
|
||||||
|
let count: i64 = schema::operator::table
|
||||||
|
.count()
|
||||||
|
.get_result(&mut conn)
|
||||||
|
.await?;
|
||||||
|
let threshold = shamir_threshold(usize::try_from(count).unwrap_or_default());
|
||||||
|
|
||||||
|
self.state = CoordinatorState::Unsealing {
|
||||||
|
threshold,
|
||||||
|
passphrases: HashMap::new(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let CoordinatorState::Unsealing {
|
||||||
|
threshold,
|
||||||
|
passphrases,
|
||||||
|
} = &mut self.state
|
||||||
|
else {
|
||||||
|
return Err(Error::NotUnsealing);
|
||||||
|
};
|
||||||
|
|
||||||
|
if passphrases.contains_key(&operator_id) {
|
||||||
|
return Err(Error::DuplicateContribution);
|
||||||
|
}
|
||||||
|
|
||||||
|
let passphrase_bytes = passphrase.read().to_vec();
|
||||||
|
passphrases.insert(operator_id, passphrase_bytes);
|
||||||
|
|
||||||
|
if passphrases.len() < *threshold {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
let CoordinatorState::Unsealing { passphrases, .. } =
|
||||||
|
std::mem::replace(&mut self.state, CoordinatorState::Idle)
|
||||||
|
else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
|
||||||
|
finalize_unseal(self.db.clone(), self.vault.clone(), passphrases).await?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user