feat(vault)!: add multi-operator Shamir custody
This commit is contained in:
@@ -1,119 +1,183 @@
|
||||
use crate::db::{self, DatabasePool, schema};
|
||||
use crate::{
|
||||
actors::vault::events,
|
||||
db::{self, schema},
|
||||
};
|
||||
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
|
||||
use arbiter_proto::{BOOTSTRAP_PATH, home_path};
|
||||
|
||||
use diesel::QueryDsl;
|
||||
use diesel_async::RunQueryDsl;
|
||||
use kameo::{Actor, messages};
|
||||
use kameo::{
|
||||
Actor,
|
||||
actor::ActorRef,
|
||||
messages,
|
||||
prelude::{Context, Message},
|
||||
};
|
||||
use kameo_actors::message_bus::{MessageBus, Register};
|
||||
use rand::{RngExt, distr::Alphanumeric, rngs::SysRng};
|
||||
use rand_core::UnwrapErr;
|
||||
use std::path::{Path, PathBuf};
|
||||
use subtle::ConstantTimeEq as _;
|
||||
use thiserror::Error;
|
||||
use tracing::warn;
|
||||
|
||||
const TOKEN_LENGTH: usize = 64;
|
||||
|
||||
async fn write_token_file(path: &Path, content: &str) -> Result<(), std::io::Error> {
|
||||
if let Some(parent) = path.parent() {
|
||||
tokio::fs::create_dir_all(parent).await?;
|
||||
}
|
||||
tokio::fs::write(path, content.as_bytes()).await?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
tokio::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_token_file(path: &Path) {
|
||||
match tokio::fs::remove_file(path).await {
|
||||
Ok(()) => {}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(error) => warn!(?error, ?path, "Failed to remove bootstrap token file"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn generate_token(path: &Path) -> Result<SafeCell<[u8; TOKEN_LENGTH]>, std::io::Error> {
|
||||
let mut cell = SafeCell::new([0u8; TOKEN_LENGTH]);
|
||||
{
|
||||
let mut buf = cell.write();
|
||||
for (slot, b) in buf
|
||||
for (slot, byte) in buf
|
||||
.iter_mut()
|
||||
.zip(UnwrapErr(SysRng).sample_iter(Alphanumeric))
|
||||
{
|
||||
*slot = b;
|
||||
*slot = byte;
|
||||
}
|
||||
}
|
||||
|
||||
let token_str = cell.read_inline(|buf| String::from_utf8_lossy(buf.as_ref()).into_owned());
|
||||
|
||||
write_token_file(path, &token_str).await?;
|
||||
|
||||
let token = cell.read_inline(|buf| String::from_utf8_lossy(buf.as_ref()).into_owned());
|
||||
write_token_file(path, &token).await?;
|
||||
Ok(cell)
|
||||
}
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("Database error: {0}")]
|
||||
Database(#[from] db::PoolError),
|
||||
|
||||
#[error("I/O error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("Database query error: {0}")]
|
||||
Query(#[from] diesel::result::Error),
|
||||
}
|
||||
|
||||
#[derive(Actor)]
|
||||
/// Custodian of the one-time bootstrap token.
|
||||
///
|
||||
/// The token authorises registering operator identities before the vault
|
||||
/// exists. A Shamir committee needs every member registered before it can be
|
||||
/// declared, so the token stays valid across several registrations and is
|
||||
/// retired by the `Bootstrapped` event rather than by first use, whichever
|
||||
/// bootstrap path fired it.
|
||||
pub struct Bootstrapper {
|
||||
token: Option<SafeCell<[u8; TOKEN_LENGTH]>>,
|
||||
token_path: Option<PathBuf>,
|
||||
events: ActorRef<MessageBus>,
|
||||
}
|
||||
|
||||
impl Bootstrapper {
|
||||
pub async fn new(db: &DatabasePool) -> Result<Self, Error> {
|
||||
let row_count: i64 = {
|
||||
let mut conn = db.get().await?;
|
||||
impl Actor for Bootstrapper {
|
||||
type Args = Self;
|
||||
type Error = std::convert::Infallible;
|
||||
|
||||
schema::operator::table
|
||||
.count()
|
||||
.get_result(&mut conn)
|
||||
.await?
|
||||
};
|
||||
|
||||
let (token, token_path) = if row_count == 0 {
|
||||
let path = home_path()?.join(BOOTSTRAP_PATH);
|
||||
let token = generate_token(&path).await?;
|
||||
(Some(token), Some(path))
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
|
||||
Ok(Self { token, token_path })
|
||||
async fn on_start(args: Self::Args, actor_ref: ActorRef<Self>) -> Result<Self, Self::Error> {
|
||||
let _ = args
|
||||
.events
|
||||
.tell(Register(actor_ref.recipient::<events::Bootstrapped>()))
|
||||
.await;
|
||||
Ok(args)
|
||||
}
|
||||
}
|
||||
|
||||
impl Bootstrapper {
|
||||
pub async fn new(db: &db::DatabasePool, events: ActorRef<MessageBus>) -> Result<Self, Error> {
|
||||
let path = home_path()?.join(BOOTSTRAP_PATH);
|
||||
let mut conn = db.get().await?;
|
||||
|
||||
let bootstrapped = schema::arbiter_settings::table
|
||||
.select(schema::arbiter_settings::root_key_id)
|
||||
.first::<Option<i32>>(&mut conn)
|
||||
.await?
|
||||
.is_some();
|
||||
if bootstrapped {
|
||||
// A token file can outlive the bootstrap that made it obsolete:
|
||||
// the daemon may have been killed before the event was handled.
|
||||
remove_token_file(&path).await;
|
||||
return Ok(Self {
|
||||
token: None,
|
||||
token_path: None,
|
||||
events,
|
||||
});
|
||||
}
|
||||
|
||||
let registered = diesel::select(diesel::dsl::exists(
|
||||
schema::operator_identity::table.select(schema::operator_identity::id),
|
||||
))
|
||||
.get_result::<bool>(&mut conn)
|
||||
.await?;
|
||||
|
||||
let token = if registered {
|
||||
match tokio::fs::read_to_string(&path).await {
|
||||
Ok(existing)
|
||||
if existing.len() == TOKEN_LENGTH
|
||||
&& existing.chars().all(|c| c.is_ascii_alphanumeric()) =>
|
||||
{
|
||||
let mut cell = SafeCell::new([0u8; TOKEN_LENGTH]);
|
||||
cell.write().copy_from_slice(existing.as_bytes());
|
||||
cell
|
||||
}
|
||||
Ok(_) | Err(_) => generate_token(&path).await?,
|
||||
}
|
||||
} else {
|
||||
generate_token(&path).await?
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
token: Some(token),
|
||||
token_path: Some(path),
|
||||
events,
|
||||
})
|
||||
}
|
||||
|
||||
fn is_correct_token(&mut self, token: &[u8]) -> bool {
|
||||
self.token.as_mut().is_some_and(|expected| {
|
||||
expected.read_inline(|exp| bool::from(exp.as_ref().ct_eq(token)))
|
||||
expected.read_inline(|bytes| bool::from(bytes.as_ref().ct_eq(token)))
|
||||
})
|
||||
}
|
||||
|
||||
async fn forget(&mut self) {
|
||||
self.token = None;
|
||||
if let Some(path) = self.token_path.take() {
|
||||
remove_token_file(&path).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Message<events::Bootstrapped> for Bootstrapper {
|
||||
type Reply = ();
|
||||
|
||||
async fn handle(
|
||||
&mut self,
|
||||
_: events::Bootstrapped,
|
||||
_ctx: &mut Context<Self, Self::Reply>,
|
||||
) -> Self::Reply {
|
||||
self.forget().await;
|
||||
}
|
||||
}
|
||||
|
||||
#[messages]
|
||||
impl Bootstrapper {
|
||||
#[message]
|
||||
pub async fn consume_token(&mut self, token: Vec<u8>) -> bool {
|
||||
if self.is_correct_token(&token) {
|
||||
self.token = None;
|
||||
if let Some(path) = self.token_path.take()
|
||||
&& let Err(e) = tokio::fs::remove_file(&path).await
|
||||
{
|
||||
warn!(error = ?e, path = ?path, "Failed to delete bootstrap token file after consumption");
|
||||
}
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
pub fn verify_token(&mut self, token: Vec<u8>) -> bool {
|
||||
self.is_correct_token(&token)
|
||||
}
|
||||
}
|
||||
|
||||
#[messages]
|
||||
impl Bootstrapper {
|
||||
#[message]
|
||||
pub fn get_token(&mut self) -> Option<String> {
|
||||
self.token
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
use crate::{
|
||||
actors::{
|
||||
bootstrap::Bootstrapper, evm::EvmActor, flow_coordinator::FlowCoordinator,
|
||||
operator_registry::OperatorRegistry, vault::Vault,
|
||||
operator_registry::OperatorRegistry, vault::Vault, vault_coordinator::VaultCoordinator,
|
||||
},
|
||||
db::{
|
||||
self,
|
||||
custody::{CustodyStore, DieselCustodyStore},
|
||||
},
|
||||
db,
|
||||
};
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use kameo::actor::{ActorRef, Spawn};
|
||||
use kameo_actors::{DeliveryStrategy, message_bus::MessageBus};
|
||||
use thiserror::Error;
|
||||
@@ -15,20 +20,22 @@ pub mod evm;
|
||||
pub mod flow_coordinator;
|
||||
pub mod operator_registry;
|
||||
pub mod vault;
|
||||
pub mod vault_coordinator;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum SpawnError {
|
||||
#[error("Failed to spawn Bootstrapper actor")]
|
||||
Bootstrapper(#[from] bootstrap::Error),
|
||||
|
||||
#[error("Failed to spawn Vault actor")]
|
||||
Vault(#[from] vault::Error),
|
||||
#[error("Failed to spawn VaultCoordinator actor")]
|
||||
VaultCoordinator(#[from] vault_coordinator::Error),
|
||||
}
|
||||
|
||||
/// Long-lived actors that are shared across all connections and handle global state and operations
|
||||
#[derive(Clone)]
|
||||
pub struct GlobalActors {
|
||||
pub vault: ActorRef<Vault>,
|
||||
pub vault_coordinator: ActorRef<VaultCoordinator>,
|
||||
pub bootstrapper: ActorRef<Bootstrapper>,
|
||||
pub flow_coordinator: ActorRef<FlowCoordinator>,
|
||||
pub operator_registry: ActorRef<OperatorRegistry>,
|
||||
@@ -42,18 +49,24 @@ impl GlobalActors {
|
||||
}
|
||||
|
||||
pub async fn spawn(db: db::DatabasePool) -> Result<Self, SpawnError> {
|
||||
let message_bus = Self::spawn_message_bus();
|
||||
let key_holder = Vault::spawn(Vault::new(db.clone(), message_bus.clone()).await?);
|
||||
let events = Self::spawn_message_bus();
|
||||
let custody: Arc<dyn CustodyStore> = Arc::new(DieselCustodyStore);
|
||||
let vault =
|
||||
Vault::spawn(Vault::new(db.clone(), events.clone(), Arc::clone(&custody)).await?);
|
||||
let bootstrapper = Bootstrapper::spawn(Bootstrapper::new(&db, events.clone()).await?);
|
||||
let vault_coordinator =
|
||||
VaultCoordinator::spawn(VaultCoordinator::new(db.clone(), vault.clone(), custody));
|
||||
let operator_registry = OperatorRegistry::spawn(OperatorRegistry::default());
|
||||
Ok(Self {
|
||||
bootstrapper: Bootstrapper::spawn(Bootstrapper::new(&db).await?),
|
||||
evm: EvmActor::spawn(EvmActor::new(key_holder.clone(), db)),
|
||||
vault: key_holder,
|
||||
bootstrapper,
|
||||
evm: EvmActor::spawn(EvmActor::new(vault.clone(), db.clone())),
|
||||
vault,
|
||||
vault_coordinator,
|
||||
flow_coordinator: FlowCoordinator::spawn(FlowCoordinator::new(
|
||||
operator_registry.clone(),
|
||||
)),
|
||||
operator_registry,
|
||||
events: message_bus,
|
||||
events,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,8 +20,8 @@ impl Actor for OperatorRegistry {
|
||||
|
||||
type Error = Infallible;
|
||||
|
||||
async fn on_start(args: Self::Args, _: ActorRef<Self>) -> Result<Self, Self::Error> {
|
||||
Ok(args)
|
||||
fn on_start(args: Self::Args, _: ActorRef<Self>) -> impl Future<Output = Result<Self, Self::Error>> {
|
||||
std::future::ready(Ok(args))
|
||||
}
|
||||
|
||||
async fn on_link_died(
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
use crate::{
|
||||
crypto::{
|
||||
KeyCell, derive_key,
|
||||
KeyCell,
|
||||
encryption::v1::{self, Nonce},
|
||||
integrity::v1::HmacSha256,
|
||||
},
|
||||
db::{
|
||||
self,
|
||||
custody::{CustodyRecord, CustodyStore},
|
||||
models::{self, RootKeyHistory, RootKeyHistoryId},
|
||||
schema::{self},
|
||||
},
|
||||
};
|
||||
|
||||
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::Utc;
|
||||
use diesel::{
|
||||
@@ -60,6 +63,9 @@ pub enum Error {
|
||||
#[error("Database transaction error: {0}")]
|
||||
DatabaseTransaction(#[from] diesel::result::Error),
|
||||
|
||||
#[error("Custody storage error: {0}")]
|
||||
Custody(#[from] db::custody::Error),
|
||||
|
||||
#[error("Broken database")]
|
||||
BrokenDatabase,
|
||||
|
||||
@@ -95,12 +101,17 @@ pub struct Vault {
|
||||
db: db::DatabasePool,
|
||||
state: State,
|
||||
events: ActorRef<MessageBus>,
|
||||
custody: Arc<dyn CustodyStore>,
|
||||
unseal_failures: u32,
|
||||
}
|
||||
|
||||
#[messages]
|
||||
impl Vault {
|
||||
pub async fn new(db: db::DatabasePool, events: ActorRef<MessageBus>) -> Result<Self, Error> {
|
||||
pub async fn new(
|
||||
db: db::DatabasePool,
|
||||
events: ActorRef<MessageBus>,
|
||||
custody: Arc<dyn CustodyStore>,
|
||||
) -> Result<Self, Error> {
|
||||
let state = {
|
||||
let mut conn = db.get().await?;
|
||||
|
||||
@@ -118,7 +129,13 @@ impl Vault {
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Self { db, state, events, unseal_failures: 0 })
|
||||
Ok(Self {
|
||||
db,
|
||||
state,
|
||||
events,
|
||||
custody,
|
||||
unseal_failures: 0,
|
||||
})
|
||||
}
|
||||
|
||||
// Exclusive transaction to avoid race condtions if multiple vaults write
|
||||
@@ -167,13 +184,16 @@ impl Vault {
|
||||
}
|
||||
}
|
||||
|
||||
/// Create the root key and take the vault into the unsealed state.
|
||||
#[message]
|
||||
pub async fn bootstrap(&mut self, seal_key_raw: SafeCell<Vec<u8>>) -> Result<(), Error> {
|
||||
pub async fn bootstrap(
|
||||
&mut self,
|
||||
mut seal_key: KeyCell,
|
||||
custody: Option<CustodyRecord>,
|
||||
) -> Result<(), Error> {
|
||||
if !matches!(self.state, State::Unbootstrapped) {
|
||||
return Err(Error::AlreadyBootstrapped);
|
||||
}
|
||||
let salt = v1::generate_salt();
|
||||
let mut seal_key = derive_key(seal_key_raw, &salt);
|
||||
let mut root_key = KeyCell::new_secure_random();
|
||||
|
||||
// Zero nonces are fine because they are one-time
|
||||
@@ -193,6 +213,7 @@ impl Vault {
|
||||
let mut conn = self.db.get().await?;
|
||||
|
||||
let data_encryption_nonce_bytes = data_encryption_nonce.to_vec();
|
||||
let custody_store = Arc::clone(&self.custody);
|
||||
let root_key_history_id = conn
|
||||
.transaction(async |conn| {
|
||||
let root_key_history_id = insert_into(schema::root_key_history::table)
|
||||
@@ -202,7 +223,7 @@ impl Vault {
|
||||
root_key_encryption_nonce: root_key_nonce.to_vec(),
|
||||
data_encryption_nonce: data_encryption_nonce_bytes.clone(),
|
||||
schema_version: 1,
|
||||
salt: salt.to_vec(),
|
||||
salt: v1::generate_salt().to_vec(),
|
||||
})
|
||||
.returning(schema::root_key_history::id)
|
||||
.get_result(&mut *conn)
|
||||
@@ -213,9 +234,11 @@ impl Vault {
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
|
||||
Result::<_, diesel::result::Error>::Ok(RootKeyHistoryId::from_raw(
|
||||
root_key_history_id,
|
||||
))
|
||||
if let Some(record) = custody.as_ref() {
|
||||
custody_store.write_record(&mut *conn, record).await?;
|
||||
}
|
||||
|
||||
Result::<_, Error>::Ok(RootKeyHistoryId::from_raw(root_key_history_id))
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -231,7 +254,7 @@ impl Vault {
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub async fn try_unseal(&mut self, seal_key_raw: SafeCell<Vec<u8>>) -> Result<(), Error> {
|
||||
pub async fn try_unseal(&mut self, mut seal_key: KeyCell) -> Result<(), Error> {
|
||||
if self.unseal_failures >= MAX_UNSEAL_ATTEMPTS {
|
||||
return Err(Error::LockedOut);
|
||||
}
|
||||
@@ -253,13 +276,6 @@ impl Vault {
|
||||
.await?
|
||||
};
|
||||
|
||||
let salt = ¤t_key.salt;
|
||||
let salt = v1::Salt::try_from(salt.as_slice()).map_err(|_| {
|
||||
error!("Broken database: invalid salt for root key");
|
||||
Error::BrokenDatabase
|
||||
})?;
|
||||
let mut seal_key = derive_key(seal_key_raw, &salt);
|
||||
|
||||
let mut root_key = SafeCell::new(current_key.ciphertext.clone());
|
||||
|
||||
let nonce =
|
||||
@@ -441,18 +457,20 @@ impl Vault {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::actors::GlobalActors;
|
||||
use crate::db::models::RootKeyHistory;
|
||||
use arbiter_crypto::safecell::SafeCellHandle as _;
|
||||
use crate::{actors::GlobalActors, db::custody::DieselCustodyStore};
|
||||
|
||||
use super::*;
|
||||
|
||||
async fn bootstrapped_actor(db: &db::DatabasePool) -> Vault {
|
||||
let mut actor = Vault::new(db.clone(), GlobalActors::spawn_message_bus())
|
||||
.await
|
||||
.unwrap();
|
||||
let seal_key = SafeCell::new(b"test-seal-key".to_vec());
|
||||
actor.bootstrap(seal_key).await.unwrap();
|
||||
let mut actor = Vault::new(
|
||||
db.clone(),
|
||||
GlobalActors::spawn_message_bus(),
|
||||
Arc::new(DieselCustodyStore),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let seal_key = KeyCell::from([0u8; 32]);
|
||||
actor.bootstrap(seal_key, None).await.unwrap();
|
||||
actor
|
||||
}
|
||||
|
||||
|
||||
398
server/crates/arbiter-server/src/actors/vault_coordinator/mod.rs
Normal file
398
server/crates/arbiter-server/src/actors/vault_coordinator/mod.rs
Normal file
@@ -0,0 +1,398 @@
|
||||
//! Coordinates the multi-operator ceremonies that create and open the vault.
|
||||
//!
|
||||
//! The coordinator collects one passphrase per committee member, then hands the
|
||||
//! assembled material to [`Vault`] in a single message. It owns no Diesel code:
|
||||
//! everything it reads or writes goes through [`CustodyStore`].
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
|
||||
use argon2::RECOMMENDED_SALT_LEN;
|
||||
use kameo::{Actor, actor::ActorRef, error::SendError, messages};
|
||||
use rand::rngs::SysRng;
|
||||
use rand_core::{Rng as _, UnwrapErr};
|
||||
|
||||
use crate::{
|
||||
actors::vault::{self, Bootstrap, TryUnseal, Vault},
|
||||
crypto::{KeyCell, derive_key, encryption::v1::Nonce, shamir},
|
||||
db::{
|
||||
self,
|
||||
custody::{CustodyRecord, CustodyStore, EncryptedShare},
|
||||
models::OperatorId,
|
||||
},
|
||||
};
|
||||
|
||||
const SHARE_AAD: &[u8] = b"arbiter/shamir-share/v1";
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("An ordinary committee is already being coordinated")]
|
||||
AlreadyBootstrapping,
|
||||
#[error("An unseal is already being coordinated")]
|
||||
AlreadyUnsealing,
|
||||
#[error("Bootstrap is not in progress")]
|
||||
NotBootstrapping,
|
||||
#[error("The operator already contributed")]
|
||||
DuplicateContribution,
|
||||
#[error("The ordinary committee cannot be empty")]
|
||||
EmptyCommittee,
|
||||
#[error("Two-operator committees are unsupported")]
|
||||
UnsupportedCommittee,
|
||||
#[error(
|
||||
"The ordinary committee cannot exceed {} members",
|
||||
shamir::MAX_COMMITTEE_SIZE
|
||||
)]
|
||||
CommitteeTooLarge,
|
||||
#[error("Invalid passphrase")]
|
||||
InvalidPassphrase,
|
||||
#[error("Broken database")]
|
||||
BrokenDatabase,
|
||||
#[error("Shamir error: {0}")]
|
||||
Shamir(String),
|
||||
#[error("Database connection error: {0}")]
|
||||
DatabaseConnection(#[from] db::PoolError),
|
||||
#[error("Custody storage error: {0}")]
|
||||
Custody(#[from] db::custody::Error),
|
||||
#[error("Encryption error")]
|
||||
Encryption,
|
||||
#[error("The vault is already bootstrapped")]
|
||||
AlreadyBootstrapped,
|
||||
#[error("Vault error")]
|
||||
Vault,
|
||||
}
|
||||
|
||||
/// Passphrases gathered so far, in contribution order.
|
||||
///
|
||||
/// A `Vec` rather than a map because [`SafeCell`] values are neither cloneable
|
||||
/// nor hashable, and a committee holds at most
|
||||
/// [`shamir::MAX_COMMITTEE_SIZE`] of them.
|
||||
#[derive(Default)]
|
||||
struct Contributions(Vec<(OperatorId, SafeCell<Vec<u8>>)>);
|
||||
|
||||
impl Contributions {
|
||||
fn contains(&self, operator_id: OperatorId) -> bool {
|
||||
self.0.iter().any(|(id, _)| *id == operator_id)
|
||||
}
|
||||
|
||||
fn put(&mut self, operator_id: OperatorId, passphrase: SafeCell<Vec<u8>>) {
|
||||
match self.0.iter_mut().find(|(id, _)| *id == operator_id) {
|
||||
Some(slot) => slot.1 = passphrase,
|
||||
None => self.0.push((operator_id, passphrase)),
|
||||
}
|
||||
}
|
||||
|
||||
const fn len(&self) -> usize {
|
||||
self.0.len()
|
||||
}
|
||||
|
||||
fn operators(&self) -> Vec<OperatorId> {
|
||||
self.0.iter().map(|(id, _)| *id).collect()
|
||||
}
|
||||
}
|
||||
|
||||
enum CoordinatorState {
|
||||
Idle,
|
||||
Bootstrapping {
|
||||
/// The operator that declared the committee. Only they may re-declare
|
||||
/// it, which is the way out of a ceremony the others never finish.
|
||||
declarer: OperatorId,
|
||||
declared_count: usize,
|
||||
contributions: Contributions,
|
||||
retryable: bool,
|
||||
},
|
||||
Unsealing {
|
||||
threshold: usize,
|
||||
contributions: Contributions,
|
||||
retryable: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Actor)]
|
||||
pub struct VaultCoordinator {
|
||||
db: db::DatabasePool,
|
||||
vault: ActorRef<Vault>,
|
||||
custody: Arc<dyn CustodyStore>,
|
||||
state: CoordinatorState,
|
||||
}
|
||||
|
||||
impl VaultCoordinator {
|
||||
pub fn new(
|
||||
db: db::DatabasePool,
|
||||
vault: ActorRef<Vault>,
|
||||
custody: Arc<dyn CustodyStore>,
|
||||
) -> Self {
|
||||
Self {
|
||||
db,
|
||||
vault,
|
||||
custody,
|
||||
state: CoordinatorState::Idle,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Explain why a committee size was rejected.
|
||||
const fn committee_error(declared_count: usize) -> Error {
|
||||
match declared_count {
|
||||
0 => Error::EmptyCommittee,
|
||||
2 => Error::UnsupportedCommittee,
|
||||
_ => Error::CommitteeTooLarge,
|
||||
}
|
||||
}
|
||||
|
||||
fn encrypt_share(
|
||||
passphrase: &mut SafeCell<Vec<u8>>,
|
||||
share: &[u8],
|
||||
) -> Result<EncryptedShare, Error> {
|
||||
let mut salt = [0u8; RECOMMENDED_SALT_LEN];
|
||||
UnwrapErr(SysRng).fill_bytes(&mut salt);
|
||||
|
||||
let nonce = Nonce::default();
|
||||
let ciphertext = derive_key(passphrase, &salt)
|
||||
.encrypt(&nonce, SHARE_AAD, share)
|
||||
.map_err(|_| Error::Encryption)?;
|
||||
|
||||
Ok(EncryptedShare {
|
||||
ciphertext,
|
||||
nonce: nonce.to_vec(),
|
||||
salt: salt.to_vec(),
|
||||
})
|
||||
}
|
||||
|
||||
fn decrypt_share(
|
||||
passphrase: &mut SafeCell<Vec<u8>>,
|
||||
share: EncryptedShare,
|
||||
) -> Result<SafeCell<Vec<u8>>, Error> {
|
||||
let nonce = Nonce::try_from(share.nonce.as_slice()).map_err(|()| Error::BrokenDatabase)?;
|
||||
|
||||
let mut buffer = SafeCell::new(share.ciphertext);
|
||||
derive_key(passphrase, &share.salt)
|
||||
.decrypt_in_place(&nonce, SHARE_AAD, &mut buffer)
|
||||
.map_err(|_| Error::InvalidPassphrase)?;
|
||||
|
||||
Ok(buffer)
|
||||
}
|
||||
|
||||
const fn bootstrap_error(error: &SendError<Bootstrap, vault::Error>) -> Error {
|
||||
match error {
|
||||
SendError::HandlerError(vault::Error::AlreadyBootstrapped) => Error::AlreadyBootstrapped,
|
||||
_ => Error::Vault,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the custody record and hand it to the vault, which stores it in the
|
||||
/// same transaction as the root key.
|
||||
async fn finalize_bootstrap(
|
||||
vault: &ActorRef<Vault>,
|
||||
contributions: &mut Contributions,
|
||||
) -> Result<(), Error> {
|
||||
let total = contributions.len();
|
||||
let threshold = shamir::shamir_threshold(total).ok_or_else(|| committee_error(total))?;
|
||||
|
||||
let mut seal_key = KeyCell::new_secure_random();
|
||||
let mut shares = shamir::split_key(threshold, total, &mut seal_key, UnwrapErr(SysRng))
|
||||
.map_err(|error| Error::Shamir(error.to_string()))?;
|
||||
|
||||
let mut encrypted = Vec::with_capacity(total);
|
||||
for (index, (operator_id, passphrase)) in contributions.0.iter_mut().enumerate() {
|
||||
let share = shares
|
||||
.read_inline(|shares| shares.get(index).cloned())
|
||||
.ok_or_else(|| Error::Shamir("missing share for operator".to_owned()))?;
|
||||
encrypted.push((*operator_id, encrypt_share(passphrase, &share)?));
|
||||
}
|
||||
|
||||
vault
|
||||
.ask(Bootstrap {
|
||||
seal_key,
|
||||
custody: Some(CustodyRecord {
|
||||
threshold,
|
||||
shares: encrypted,
|
||||
}),
|
||||
})
|
||||
.await
|
||||
.map_err(|error| bootstrap_error(&error))
|
||||
}
|
||||
|
||||
/// Reconstruct the seal key from the contributed passphrases and unseal.
|
||||
async fn finalize_unseal(
|
||||
db: &db::DatabasePool,
|
||||
custody: &Arc<dyn CustodyStore>,
|
||||
vault: &ActorRef<Vault>,
|
||||
threshold: usize,
|
||||
contributions: &mut Contributions,
|
||||
) -> Result<(), Error> {
|
||||
let stored = {
|
||||
let mut conn = db.get().await?;
|
||||
custody
|
||||
.shares(&mut conn, &contributions.operators())
|
||||
.await?
|
||||
};
|
||||
|
||||
let mut plaintext = SafeCell::new(Vec::with_capacity(stored.len()));
|
||||
for ((_, passphrase), share) in contributions.0.iter_mut().zip(stored) {
|
||||
let mut decrypted = decrypt_share(passphrase, share)?;
|
||||
decrypted.read_inline(|share| {
|
||||
plaintext.write_inline(|shares| shares.push(share.clone()));
|
||||
});
|
||||
}
|
||||
|
||||
let seal_key = shamir::combine_shares(threshold, &mut plaintext)
|
||||
.map_err(|error| Error::Shamir(error.to_string()))?;
|
||||
|
||||
vault
|
||||
.ask(TryUnseal { seal_key })
|
||||
.await
|
||||
.map_err(|_| Error::Vault)
|
||||
}
|
||||
|
||||
#[messages]
|
||||
impl VaultCoordinator {
|
||||
/// Announce how many operators will contribute to the bootstrap.
|
||||
///
|
||||
/// The declaring operator may re-declare to restart the ceremony; that is
|
||||
/// the only way to release a committee whose members never all show up.
|
||||
#[message]
|
||||
pub fn start_bootstrap(
|
||||
&mut self,
|
||||
operator_id: OperatorId,
|
||||
declared_count: usize,
|
||||
) -> Result<(), Error> {
|
||||
if shamir::shamir_threshold(declared_count).is_none() {
|
||||
return Err(committee_error(declared_count));
|
||||
}
|
||||
|
||||
match &self.state {
|
||||
CoordinatorState::Unsealing { .. } => return Err(Error::AlreadyUnsealing),
|
||||
CoordinatorState::Bootstrapping { declarer, .. } if *declarer != operator_id => {
|
||||
return Err(Error::AlreadyBootstrapping);
|
||||
}
|
||||
CoordinatorState::Bootstrapping { .. } | CoordinatorState::Idle => {}
|
||||
}
|
||||
|
||||
self.state = CoordinatorState::Bootstrapping {
|
||||
declarer: operator_id,
|
||||
declared_count,
|
||||
contributions: Contributions::default(),
|
||||
retryable: false,
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub async fn contribute_bootstrap(
|
||||
&mut self,
|
||||
operator_id: OperatorId,
|
||||
passphrase: SafeCell<Vec<u8>>,
|
||||
) -> Result<bool, Error> {
|
||||
let CoordinatorState::Bootstrapping {
|
||||
declared_count,
|
||||
contributions,
|
||||
retryable,
|
||||
..
|
||||
} = &mut self.state
|
||||
else {
|
||||
return Err(Error::NotBootstrapping);
|
||||
};
|
||||
|
||||
if contributions.contains(operator_id) && !*retryable {
|
||||
return Err(Error::DuplicateContribution);
|
||||
}
|
||||
contributions.put(operator_id, passphrase);
|
||||
*retryable = false;
|
||||
|
||||
if contributions.len() < *declared_count {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let state = std::mem::replace(&mut self.state, CoordinatorState::Idle);
|
||||
let CoordinatorState::Bootstrapping {
|
||||
declarer,
|
||||
declared_count,
|
||||
mut contributions,
|
||||
..
|
||||
} = state
|
||||
else {
|
||||
unreachable!("state was matched as Bootstrapping above")
|
||||
};
|
||||
|
||||
match finalize_bootstrap(&self.vault, &mut contributions).await {
|
||||
Ok(()) => Ok(true),
|
||||
Err(error) => {
|
||||
self.state = CoordinatorState::Bootstrapping {
|
||||
declarer,
|
||||
declared_count,
|
||||
contributions,
|
||||
retryable: true,
|
||||
};
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub async fn contribute_unseal(
|
||||
&mut self,
|
||||
operator_id: OperatorId,
|
||||
passphrase: SafeCell<Vec<u8>>,
|
||||
) -> Result<bool, Error> {
|
||||
if matches!(self.state, CoordinatorState::Idle) {
|
||||
let threshold = {
|
||||
let mut conn = self.db.get().await?;
|
||||
self.custody.threshold(&mut conn).await?
|
||||
};
|
||||
self.state = CoordinatorState::Unsealing {
|
||||
threshold,
|
||||
contributions: Contributions::default(),
|
||||
retryable: false,
|
||||
};
|
||||
}
|
||||
|
||||
let CoordinatorState::Unsealing {
|
||||
threshold,
|
||||
contributions,
|
||||
retryable,
|
||||
} = &mut self.state
|
||||
else {
|
||||
return Err(Error::AlreadyBootstrapping);
|
||||
};
|
||||
|
||||
if contributions.contains(operator_id) && !*retryable {
|
||||
return Err(Error::DuplicateContribution);
|
||||
}
|
||||
contributions.put(operator_id, passphrase);
|
||||
*retryable = false;
|
||||
|
||||
if contributions.len() < *threshold {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let state = std::mem::replace(&mut self.state, CoordinatorState::Idle);
|
||||
let CoordinatorState::Unsealing {
|
||||
threshold,
|
||||
mut contributions,
|
||||
..
|
||||
} = state
|
||||
else {
|
||||
unreachable!("state was matched as Unsealing above")
|
||||
};
|
||||
|
||||
match finalize_unseal(
|
||||
&self.db,
|
||||
&self.custody,
|
||||
&self.vault,
|
||||
threshold,
|
||||
&mut contributions,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => Ok(true),
|
||||
Err(error) => {
|
||||
self.state = CoordinatorState::Unsealing {
|
||||
threshold,
|
||||
contributions,
|
||||
retryable: true,
|
||||
};
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user