feat(vault)!: add multi-operator Shamir custody

This commit is contained in:
CleverWild
2026-09-09 16:42:11 +02:00
parent 0677695b16
commit c722712166
37 changed files with 2148 additions and 301 deletions

View File

@@ -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

View File

@@ -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,
})
}
}

View File

@@ -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(

View File

@@ -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 = &current_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
}

View 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)
}
}
}
}