refactor(server::actors::vault): clean up Bootstrap/TryUnseal, remove Bootstrapping state

Bootstrap and TryUnseal now accept a SafeCell<Vec<u8>> seal key directly.
The Bootstrapping intermediate state is removed — multi-operator coordination
is the responsibility of VaultCoordinator, which calls Bootstrap atomically
once all shares are collected.
This commit is contained in:
CleverWild
2026-06-12 19:43:02 +02:00
parent 50fe18d6ce
commit 9764b0d5ce
3 changed files with 52 additions and 187 deletions

View File

@@ -1,15 +1,13 @@
use std::collections::HashMap;
use crate::{ use crate::{
crypto::{ crypto::{
KeyCell, derive_key, KeyCell,
encryption::v1::{self, Nonce}, encryption::v1::{self, Nonce},
integrity::v1::HmacSha256, integrity::v1::HmacSha256,
}, },
db::{ db::{
self, self,
models::{self, OperatorId, OperatorIdentityId, RootKeyHistory, RootKeyHistoryId}, models::{self, RootKeyHistory, RootKeyHistoryId},
schema::{self}, schema,
}, },
}; };
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _}; use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
@@ -17,11 +15,10 @@ use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use chrono::Utc; use chrono::Utc;
use diesel::{ use diesel::{
ExpressionMethods as _, OptionalExtension, QueryDsl, SelectableHelper, ExpressionMethods as _, OptionalExtension, QueryDsl, SelectableHelper,
dsl::{count, insert_into, update}, dsl::{insert_into, update},
select,
}; };
use diesel_async::{AsyncConnection, RunQueryDsl}; use diesel_async::{AsyncConnection, RunQueryDsl};
use hmac::{KeyInit as _, Mac as _, digest::common}; use hmac::{KeyInit as _, Mac as _};
use kameo::{Actor, Reply, actor::ActorRef, messages}; use kameo::{Actor, Reply, actor::ActorRef, messages};
use kameo_actors::message_bus::{MessageBus, Publish}; use kameo_actors::message_bus::{MessageBus, Publish};
use strum::{EnumDiscriminants, IntoDiscriminant}; use strum::{EnumDiscriminants, IntoDiscriminant};
@@ -65,15 +62,6 @@ pub enum Error {
BrokenDatabase, BrokenDatabase,
} }
#[derive(Debug, thiserror::Error)]
pub enum UnsealError {}
#[derive(Debug, thiserror::Error)]
pub enum BootstrapError {
#[error("That operator already contributed his share")]
AlreadyContributed,
}
struct Unsealed { struct Unsealed {
root_key_history_id: RootKeyHistoryId, root_key_history_id: RootKeyHistoryId,
root_key: KeyCell, root_key: KeyCell,
@@ -85,15 +73,8 @@ enum State {
#[default] #[default]
Unbootstrapped, Unbootstrapped,
Bootstrapping {
declared_operators: u64,
current_passphrases: HashMap<OperatorIdentityId, SafeCell<Vec<u8>>>,
},
Sealed { Sealed {
threshold: u64, // basically, quorum size
root_key_history_id: RootKeyHistoryId, root_key_history_id: RootKeyHistoryId,
current_shares: HashMap<OperatorId, SafeCell<Vec<u8>>>,
}, },
Unsealed(Unsealed), Unsealed(Unsealed),
} }
@@ -121,17 +102,9 @@ impl Vault {
.await?; .await?;
match root_key_history { match root_key_history {
Some(root_key_history) => { Some(root_key_history) => State::Sealed {
let operator_count: i64 = schema::operator::table
.count()
.get_result(&mut conn)
.await?;
State::Sealed {
root_key_history_id: root_key_history.id, root_key_history_id: root_key_history.id,
current_shares: HashMap::default(), },
threshold: shamir_threshold(operator_count.cast_unsigned()), // invariant: db couldn't return negative number of rows
}
}
None => State::Unbootstrapped, None => State::Unbootstrapped,
} }
}; };
@@ -139,7 +112,7 @@ impl Vault {
Ok(Self { db, state, events }) Ok(Self { db, state, events })
} }
// Exclusive transaction to avoid race condtions if multiple vaults write // Exclusive transaction to avoid race conditions if multiple vaults write
// additional layer of protection against nonce-reuse // additional layer of protection against nonce-reuse
async fn get_new_nonce( async fn get_new_nonce(
pool: &db::DatabasePool, pool: &db::DatabasePool,
@@ -180,37 +153,33 @@ impl Vault {
const fn expect_unsealed(state: &mut State) -> Result<&mut Unsealed, Error> { const fn expect_unsealed(state: &mut State) -> Result<&mut Unsealed, Error> {
match state { match state {
State::Unsealed(unsealed) => Ok(unsealed), State::Unsealed(unsealed) => Ok(unsealed),
State::Bootstrapping { .. } => Err(Error::NotBootstrapped),
State::Unbootstrapped => Err(Error::NotBootstrapped), State::Unbootstrapped => Err(Error::NotBootstrapped),
State::Sealed { .. } => Err(Error::Sealed), State::Sealed { .. } => Err(Error::Sealed),
} }
} }
}
pub async fn finalize_bootstrap(&mut self) -> Result<(), Error> { #[messages]
let State::Bootstrapping { impl Vault {
declared_operators, #[message]
current_passphrases, pub async fn bootstrap(&mut self, seal_key_raw: SafeCell<Vec<u8>>) -> Result<(), Error> {
} = &mut self.state if !matches!(&self.state, State::Unbootstrapped) {
else {
return Err(Error::AlreadyBootstrapped); return Err(Error::AlreadyBootstrapped);
}; }
let mut root_key = KeyCell::new_secure_random(); let mut root_key = KeyCell::new_secure_random();
let root_key_salt = v1::generate_salt(); let mut seal_key = KeyCell::try_from(seal_key_raw).map_err(|()| Error::InvalidKey)?;
let mut seal_key = KeyCell::new_secure_random();
let shares = seal_key.0.read_inline(|seal_key| {
generate_shamir_shares(current_passphrases.len() as u64, seal_key.as_slice())
});
// Zero nonces are fine because they are one-time // Zero nonces are fine because they are one-time
let root_key_nonce = Nonce::default(); let root_key_nonce = Nonce::default();
let data_encryption_nonce = Nonce::default(); let data_encryption_nonce = Nonce::default();
let root_key_ciphertext: Vec<u8> = root_key.0.read_inline(|reader| { // Generate salt (kept for schema compat)
let root_key_reader = reader.as_slice(); let root_key_salt = v1::generate_salt();
let root_key_ciphertext: Vec<u8> = root_key.0.read_inline(|rk| {
seal_key seal_key
.encrypt(&root_key_nonce, v1::ROOT_KEY_TAG, root_key_reader) .encrypt(&root_key_nonce, v1::ROOT_KEY_TAG, rk.as_slice())
.map_err(|err| { .map_err(|err| {
error!(?err, "Fatal bootstrap error"); error!(?err, "Fatal bootstrap error");
Error::Encryption(err) Error::Encryption(err)
@@ -222,16 +191,6 @@ impl Vault {
let root_key_history_id = conn let root_key_history_id = conn
.transaction(async |conn| { .transaction(async |conn| {
for ((operator_id, raw_passphrase), raw_share) in
current_passphrases.iter_mut().zip(shares.iter())
{
let salt = v1::generate_salt();
let mut share_seal_key = derive_key(&mut raw_passphrase, &salt);
let share_encryption_nonce = Nonce::default();
let share_key = derive_key(&mut raw_passphrase, &salt);
}
let root_key_history_id = insert_into(schema::root_key_history::table) let root_key_history_id = insert_into(schema::root_key_history::table)
.values(&models::NewRootKeyHistory { .values(&models::NewRootKeyHistory {
ciphertext: root_key_ciphertext.clone(), ciphertext: root_key_ciphertext.clone(),
@@ -266,82 +225,28 @@ impl Vault {
Ok(()) Ok(())
} }
}
// Seal / unseal / bootstrap stuff. Will be separated into another actor, eventually
#[messages]
impl Vault {
#[message]
pub async fn start_bootstrap(&mut self, declared_operators: u64) -> Result<(), Error> {
if !matches!(&self.state, State::Unbootstrapped) {
return Err(Error::AlreadyBootstrapped);
}
self.state = State::Bootstrapping {
declared_operators,
current_passphrases: HashMap::default(),
};
Ok(())
}
#[message] #[message]
pub async fn contribute_bootstrap( pub async fn try_unseal(&mut self, seal_key_raw: SafeCell<Vec<u8>>) -> Result<(), Error> {
&mut self,
operator: OperatorIdentityId,
key_raw: SafeCell<Vec<u8>>,
) -> Result<(), Error> {
let State::Bootstrapping {
current_passphrases,
declared_operators,
} = &mut self.state
else {
return Err(Error::AlreadyBootstrapped);
};
if current_passphrases.contains_key(&operator) {
return Err(Error::AlreadyBootstrapped);
}
current_passphrases.insert(operator, key_raw);
if current_passphrases.len() == declared_operators {
return self.finalize_bootstrap(seal_key_raw);
}
Ok(())
}
#[message]
pub async fn contribute_unseal(
&mut self,
operator: OperatorId,
key_raw: SafeCell<Vec<u8>>,
) -> Result<(), Error> {
let State::Sealed { let State::Sealed {
root_key_history_id, root_key_history_id,
current_shares,
} = &self.state } = &self.state
else { else {
return Err(Error::NotBootstrapped); return Err(Error::NotBootstrapped);
}; };
let root_key_history_id = *root_key_history_id;
// We don't want to hold connection while doing expensive KDF work // We don't want to hold connection while doing expensive work
let current_key = { let current_key = {
let mut conn = self.db.get().await?; let mut conn = self.db.get().await?;
schema::root_key_history::table schema::root_key_history::table
.filter(schema::root_key_history::id.eq(*root_key_history_id)) .filter(schema::root_key_history::id.eq(root_key_history_id))
.select(RootKeyHistory::as_select()) .select(RootKeyHistory::as_select())
.first(&mut conn) .first(&mut conn)
.await? .await?
}; };
let salt = &current_key.salt; let mut seal_key = KeyCell::try_from(seal_key_raw).map_err(|()| Error::InvalidKey)?;
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(key_raw, &salt);
let mut root_key = SafeCell::new(current_key.ciphertext.clone());
let nonce = let nonce =
Nonce::try_from(current_key.root_key_encryption_nonce.as_slice()).map_err(|()| { Nonce::try_from(current_key.root_key_encryption_nonce.as_slice()).map_err(|()| {
@@ -349,19 +254,22 @@ impl Vault {
Error::BrokenDatabase Error::BrokenDatabase
})?; })?;
let mut root_key_bytes = SafeCell::new(current_key.ciphertext.clone());
seal_key seal_key
.decrypt_in_place(&nonce, v1::ROOT_KEY_TAG, &mut root_key) .decrypt_in_place(&nonce, v1::ROOT_KEY_TAG, &mut root_key_bytes)
.map_err(|err| { .map_err(|err| {
error!(?err, "Failed to unseal root key: invalid seal key"); error!(?err, "Failed to unseal root key: invalid seal key");
Error::InvalidKey Error::InvalidKey
})?; })?;
let root_key = KeyCell::try_from(root_key_bytes).map_err(|()| {
error!("Broken database: invalid encryption key size");
Error::BrokenDatabase
})?;
self.state = State::Unsealed(Unsealed { self.state = State::Unsealed(Unsealed {
root_key_history_id: current_key.id, root_key_history_id: current_key.id,
root_key: KeyCell::try_from(root_key).map_err(|err| { root_key,
error!(?err, "Broken database: invalid encryption key size");
Error::BrokenDatabase
})?,
}); });
info!("Vault unsealed successfully"); info!("Vault unsealed successfully");
@@ -379,7 +287,6 @@ impl Vault {
self.state = State::Sealed { self.state = State::Sealed {
root_key_history_id: *root_key_history_id, root_key_history_id: *root_key_history_id,
current_shares: HashMap::new(),
}; };
let _ = self.events.tell(Publish(events::VaultResealed)).await; let _ = self.events.tell(Publish(events::VaultResealed)).await;
Ok(()) Ok(())
@@ -466,11 +373,9 @@ impl Vault {
root_key_history_id, root_key_history_id,
} = Self::expect_unsealed(&mut self.state)?; } = Self::expect_unsealed(&mut self.state)?;
let mut hmac = root_key let mut hmac = root_key.0.read_inline(|k| {
.0 HmacSha256::new_from_slice(k)
.read_inline(|k| match HmacSha256::new_from_slice(k) { .unwrap_or_else(|_| unreachable!("HMAC accepts keys of any size"))
Ok(v) => v,
Err(_) => unreachable!("HMAC accepts keys of any size"),
}); });
hmac.update(&root_key_history_id.to_raw().to_be_bytes()); hmac.update(&root_key_history_id.to_raw().to_be_bytes());
hmac.update(&mac_input); hmac.update(&mac_input);
@@ -495,11 +400,9 @@ impl Vault {
return Ok(false); return Ok(false);
} }
let mut hmac = root_key let mut hmac = root_key.0.read_inline(|k| {
.0 HmacSha256::new_from_slice(k)
.read_inline(|k| match HmacSha256::new_from_slice(k) { .unwrap_or_else(|_| unreachable!("HMAC accepts keys of any size"))
Ok(v) => v,
Err(_) => unreachable!("HMAC accepts keys of any size"),
}); });
hmac.update(&key_version.to_raw().to_be_bytes()); hmac.update(&key_version.to_raw().to_be_bytes());
hmac.update(&mac_input); hmac.update(&mac_input);
@@ -508,42 +411,6 @@ impl Vault {
} }
} }
/// According to the spec, the quorum is 50% + 1
/// with exception for 1 and 2 operators, those require exactly the number of operators registered
fn shamir_threshold(comittee_size: u64) -> u64 {
if comittee_size == 2 || comittee_size == 1 {
return comittee_size;
}
let half_comittee = match comittee_size % 2 != 0 {
true => (comittee_size - 1) / 2,
false => comittee_size / 2,
};
half_comittee + 1
}
/// Beware: this function accepts raw key references (without memory protection)
fn generate_shamir_shares(threshold: u64, key: &[u8]) -> Vec<SafeCell<Vec<u8>>> {
use vsss_rs::{shamir, *};
type P256Share = DefaultShare<IdentifierPrimeField<Scalar>, IdentifierPrimeField<Scalar>>;
let mut osrng = rand_core::OsRng::default();
let sk = SecretKey::random(&mut osrng);
let nzs = sk.to_nonzero_scalar();
let shared_secret = IdentifierPrimeField(*nzs.as_ref());
let res = shamir::split_secret::<P256Share>(2, 3, &shared_secret, &mut osrng);
assert!(res.is_ok());
let shares = res.unwrap();
let res = shares.combine();
assert!(res.is_ok());
let scalar = res.unwrap();
let nzs_dup = NonZeroScalar::from_repr(scalar.0.to_repr()).unwrap();
let sk_dup = SecretKey::from(nzs_dup);
assert_eq!(sk_dup.to_bytes(), sk.to_bytes());
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::actors::GlobalActors; use crate::actors::GlobalActors;
@@ -555,8 +422,8 @@ mod tests {
let mut actor = Vault::new(db.clone(), GlobalActors::spawn_message_bus()) let mut actor = Vault::new(db.clone(), GlobalActors::spawn_message_bus())
.await .await
.unwrap(); .unwrap();
let seal_key = SafeCell::new(b"test-seal-key".to_vec()); let seal_key = SafeCell::new([0u8; 32].to_vec());
actor.finalize_bootstrap(seal_key).await.unwrap(); actor.bootstrap(seal_key).await.unwrap();
actor actor
} }
@@ -565,12 +432,12 @@ mod tests {
async fn nonce_monotonic_even_when_nonce_allocation_interleaves() { async fn nonce_monotonic_even_when_nonce_allocation_interleaves() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
let mut actor = bootstrapped_actor(&db).await; let mut actor = bootstrapped_actor(&db).await;
let root_key_history_id = match actor.state { let State::Unsealed(Unsealed {
State::Unsealed(Unsealed {
root_key_history_id, root_key_history_id,
.. ..
}) => root_key_history_id, }) = actor.state
_ => panic!("expected unsealed state"), else {
panic!("expected unsealed state");
}; };
let n1 = Vault::get_new_nonce(&db, root_key_history_id) let n1 = Vault::get_new_nonce(&db, root_key_history_id)

View File

@@ -31,7 +31,6 @@ pub(super) async fn dispatch(
VaultRequestPayload::QueryState(()) => { VaultRequestPayload::QueryState(()) => {
let state = match actor.ask(HandleQueryVaultState {}).await { let state = match actor.ask(HandleQueryVaultState {}).await {
Ok(VaultState::Unbootstrapped) => ProtoVaultState::Unbootstrapped, Ok(VaultState::Unbootstrapped) => ProtoVaultState::Unbootstrapped,
Ok(VaultState::Bootstrapping) => ProtoVaultState::Boostrapping,
Ok(VaultState::Sealed) => ProtoVaultState::Sealed, Ok(VaultState::Sealed) => ProtoVaultState::Sealed,
Ok(VaultState::Unsealed) => ProtoVaultState::Unsealed, Ok(VaultState::Unsealed) => ProtoVaultState::Unsealed,
Err(SendError::HandlerError(Error::Internal)) => ProtoVaultState::Error, Err(SendError::HandlerError(Error::Internal)) => ProtoVaultState::Error,

View File

@@ -47,7 +47,6 @@ async fn handle_query_vault_state(
let state = match actor.ask(HandleQueryVaultState {}).await { let state = match actor.ask(HandleQueryVaultState {}).await {
Ok(VaultState::Unbootstrapped) => ProtoVaultState::Unbootstrapped, Ok(VaultState::Unbootstrapped) => ProtoVaultState::Unbootstrapped,
Ok(VaultState::Sealed) => ProtoVaultState::Sealed, Ok(VaultState::Sealed) => ProtoVaultState::Sealed,
Ok(VaultState::Bootstrapping) => ProtoVaultState::Boostrapping,
Ok(VaultState::Unsealed) => ProtoVaultState::Unsealed, Ok(VaultState::Unsealed) => ProtoVaultState::Unsealed,
Err(err) => { Err(err) => {
warn!(error = ?err, "Failed to query vault state"); warn!(error = ?err, "Failed to query vault state");