Compare commits

...

2 Commits

Author SHA1 Message Date
CleverWild
62fb83469f refactor(custody): drop the single-implementation CustodyStore trait 2026-09-11 15:45:21 +02:00
CleverWild
d49c39130a fix(vault): apply Shamir custody review feedback 2026-09-11 15:02:59 +02:00
12 changed files with 230 additions and 347 deletions

View File

@@ -78,7 +78,7 @@ pub_underscore_fields = "allow"
redundant_pub_crate = "allow" redundant_pub_crate = "allow"
uninhabited_references = "allow" # safe with unsafe_code = "forbid" and standard uninhabited pattern (match *self {}) uninhabited_references = "allow" # safe with unsafe_code = "forbid" and standard uninhabited pattern (match *self {})
too-many-lines = "allow" # this is a very common pattern in server code, and it's not always possible to break it down into smaller modules without hurting readability too-many-lines = "allow" # this is a very common pattern in server code, and it's not always possible to break it down into smaller modules without hurting readability
unused_async_trait_impl = "allow" # to pedantic unused_async_trait_impl = "allow" # too pedantic
# restriction lints # restriction lints
alloc_instead_of_core = "warn" alloc_instead_of_core = "warn"

View File

@@ -76,6 +76,9 @@ pub enum Error {
/// declared, so the token stays valid across several registrations and is /// declared, so the token stays valid across several registrations and is
/// retired by the `Bootstrapped` event rather than by first use, whichever /// retired by the `Bootstrapped` event rather than by first use, whichever
/// bootstrap path fired it. /// bootstrap path fired it.
///
/// Every daemon start mints a fresh token and overwrites the file: a token
/// handed out by an earlier run is dead.
pub struct Bootstrapper { pub struct Bootstrapper {
token: Option<SafeCell<[u8; TOKEN_LENGTH]>>, token: Option<SafeCell<[u8; TOKEN_LENGTH]>>,
token_path: Option<PathBuf>, token_path: Option<PathBuf>,
@@ -116,41 +119,13 @@ impl Bootstrapper {
}); });
} }
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 { Ok(Self {
token: Some(token), token: Some(generate_token(&path).await?),
token_path: Some(path), token_path: Some(path),
events, events,
}) })
} }
fn is_correct_token(&mut self, token: &[u8]) -> bool {
self.token.as_mut().is_some_and(|expected| {
expected.read_inline(|bytes| bool::from(bytes.as_ref().ct_eq(token)))
})
}
async fn forget(&mut self) { async fn forget(&mut self) {
self.token = None; self.token = None;
if let Some(path) = self.token_path.take() { if let Some(path) = self.token_path.take() {
@@ -170,12 +145,14 @@ impl Message<events::Bootstrapped> for Bootstrapper {
self.forget().await; self.forget().await;
} }
} }
#[messages] #[messages]
impl Bootstrapper { impl Bootstrapper {
#[message] #[message]
pub fn verify_token(&mut self, token: Vec<u8>) -> bool { pub fn verify_token(&mut self, token: Vec<u8>) -> bool {
self.is_correct_token(&token) self.token.as_mut().is_some_and(|expected| {
expected.read_inline(|bytes| bool::from(bytes.as_ref().ct_eq(token.as_slice())))
})
} }
#[message] #[message]

View File

@@ -3,14 +3,9 @@ use crate::{
bootstrap::Bootstrapper, evm::EvmActor, flow_coordinator::FlowCoordinator, bootstrap::Bootstrapper, evm::EvmActor, flow_coordinator::FlowCoordinator,
operator_registry::OperatorRegistry, vault::Vault, vault_coordinator::VaultCoordinator, operator_registry::OperatorRegistry, vault::Vault, vault_coordinator::VaultCoordinator,
}, },
db::{ db,
self,
custody::{CustodyStore, DieselCustodyStore},
},
}; };
use std::sync::Arc;
use kameo::actor::{ActorRef, Spawn}; use kameo::actor::{ActorRef, Spawn};
use kameo_actors::{DeliveryStrategy, message_bus::MessageBus}; use kameo_actors::{DeliveryStrategy, message_bus::MessageBus};
use thiserror::Error; use thiserror::Error;
@@ -50,12 +45,10 @@ impl GlobalActors {
pub async fn spawn(db: db::DatabasePool) -> Result<Self, SpawnError> { pub async fn spawn(db: db::DatabasePool) -> Result<Self, SpawnError> {
let events = Self::spawn_message_bus(); let events = Self::spawn_message_bus();
let custody: Arc<dyn CustodyStore> = Arc::new(DieselCustodyStore); let vault = Vault::spawn(Vault::new(db.clone(), events.clone()).await?);
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 bootstrapper = Bootstrapper::spawn(Bootstrapper::new(&db, events.clone()).await?);
let vault_coordinator = let vault_coordinator =
VaultCoordinator::spawn(VaultCoordinator::new(db.clone(), vault.clone(), custody)); VaultCoordinator::spawn(VaultCoordinator::new(db.clone(), vault.clone()));
let operator_registry = OperatorRegistry::spawn(OperatorRegistry::default()); let operator_registry = OperatorRegistry::spawn(OperatorRegistry::default());
Ok(Self { Ok(Self {
bootstrapper, bootstrapper,

View File

@@ -6,14 +6,13 @@ use crate::{
}, },
db::{ db::{
self, self,
custody::{CustodyRecord, CustodyStore}, custody::{self, CustodyRecord},
models::{self, RootKeyHistory, RootKeyHistoryId}, models::{self, RootKeyHistory, RootKeyHistoryId},
schema::{self}, schema::{self},
}, },
}; };
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _}; use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use std::sync::Arc;
use chrono::Utc; use chrono::Utc;
use diesel::{ use diesel::{
@@ -64,7 +63,7 @@ pub enum Error {
DatabaseTransaction(#[from] diesel::result::Error), DatabaseTransaction(#[from] diesel::result::Error),
#[error("Custody storage error: {0}")] #[error("Custody storage error: {0}")]
Custody(#[from] db::custody::Error), Custody(#[from] custody::Error),
#[error("Broken database")] #[error("Broken database")]
BrokenDatabase, BrokenDatabase,
@@ -101,17 +100,12 @@ pub struct Vault {
db: db::DatabasePool, db: db::DatabasePool,
state: State, state: State,
events: ActorRef<MessageBus>, events: ActorRef<MessageBus>,
custody: Arc<dyn CustodyStore>,
unseal_failures: u32, unseal_failures: u32,
} }
#[messages] #[messages]
impl Vault { impl Vault {
pub async fn new( pub async fn new(db: db::DatabasePool, events: ActorRef<MessageBus>) -> Result<Self, Error> {
db: db::DatabasePool,
events: ActorRef<MessageBus>,
custody: Arc<dyn CustodyStore>,
) -> Result<Self, Error> {
let state = { let state = {
let mut conn = db.get().await?; let mut conn = db.get().await?;
@@ -133,7 +127,6 @@ impl Vault {
db, db,
state, state,
events, events,
custody,
unseal_failures: 0, unseal_failures: 0,
}) })
} }
@@ -213,7 +206,6 @@ impl Vault {
let mut conn = self.db.get().await?; let mut conn = self.db.get().await?;
let data_encryption_nonce_bytes = data_encryption_nonce.to_vec(); let data_encryption_nonce_bytes = data_encryption_nonce.to_vec();
let custody_store = Arc::clone(&self.custody);
let root_key_history_id = conn let root_key_history_id = conn
.transaction(async |conn| { .transaction(async |conn| {
let root_key_history_id = insert_into(schema::root_key_history::table) let root_key_history_id = insert_into(schema::root_key_history::table)
@@ -235,7 +227,7 @@ impl Vault {
.await?; .await?;
if let Some(record) = custody.as_ref() { if let Some(record) = custody.as_ref() {
custody_store.write_record(&mut *conn, record).await?; custody::write_record(&mut *conn, record).await?;
} }
Result::<_, Error>::Ok(RootKeyHistoryId::from_raw(root_key_history_id)) Result::<_, Error>::Ok(RootKeyHistoryId::from_raw(root_key_history_id))
@@ -457,18 +449,14 @@ impl Vault {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::{actors::GlobalActors, db::custody::DieselCustodyStore}; use crate::actors::GlobalActors;
use super::*; use super::*;
async fn bootstrapped_actor(db: &db::DatabasePool) -> Vault { async fn bootstrapped_actor(db: &db::DatabasePool) -> Vault {
let mut actor = Vault::new( let mut actor = Vault::new(db.clone(), GlobalActors::spawn_message_bus())
db.clone(), .await
GlobalActors::spawn_message_bus(), .unwrap();
Arc::new(DieselCustodyStore),
)
.await
.unwrap();
let seal_key = KeyCell::from([0u8; 32]); let seal_key = KeyCell::from([0u8; 32]);
actor.bootstrap(seal_key, None).await.unwrap(); actor.bootstrap(seal_key, None).await.unwrap();
actor actor

View File

@@ -2,9 +2,7 @@
//! //!
//! The coordinator collects one passphrase per committee member, then hands the //! The coordinator collects one passphrase per committee member, then hands the
//! assembled material to [`Vault`] in a single message. It owns no Diesel code: //! assembled material to [`Vault`] in a single message. It owns no Diesel code:
//! everything it reads or writes goes through [`CustodyStore`]. //! everything it reads or writes goes through [`db::custody`].
use std::sync::Arc;
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _}; use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use argon2::RECOMMENDED_SALT_LEN; use argon2::RECOMMENDED_SALT_LEN;
@@ -17,7 +15,7 @@ use crate::{
crypto::{KeyCell, derive_key, encryption::v1::Nonce, shamir}, crypto::{KeyCell, derive_key, encryption::v1::Nonce, shamir},
db::{ db::{
self, self,
custody::{CustodyRecord, CustodyStore, EncryptedShare}, custody::{self, CustodyRecord, EncryptedShare},
models::OperatorId, models::OperatorId,
}, },
}; };
@@ -52,7 +50,7 @@ pub enum Error {
#[error("Database connection error: {0}")] #[error("Database connection error: {0}")]
DatabaseConnection(#[from] db::PoolError), DatabaseConnection(#[from] db::PoolError),
#[error("Custody storage error: {0}")] #[error("Custody storage error: {0}")]
Custody(#[from] db::custody::Error), Custody(#[from] custody::Error),
#[error("Encryption error")] #[error("Encryption error")]
Encryption, Encryption,
#[error("The vault is already bootstrapped")] #[error("The vault is already bootstrapped")]
@@ -111,20 +109,14 @@ enum CoordinatorState {
pub struct VaultCoordinator { pub struct VaultCoordinator {
db: db::DatabasePool, db: db::DatabasePool,
vault: ActorRef<Vault>, vault: ActorRef<Vault>,
custody: Arc<dyn CustodyStore>,
state: CoordinatorState, state: CoordinatorState,
} }
impl VaultCoordinator { impl VaultCoordinator {
pub fn new( pub const fn new(db: db::DatabasePool, vault: ActorRef<Vault>) -> Self {
db: db::DatabasePool,
vault: ActorRef<Vault>,
custody: Arc<dyn CustodyStore>,
) -> Self {
Self { Self {
db, db,
vault, vault,
custody,
state: CoordinatorState::Idle, state: CoordinatorState::Idle,
} }
} }
@@ -192,12 +184,14 @@ async fn finalize_bootstrap(
let mut shares = shamir::split_key(threshold, total, &mut seal_key, UnwrapErr(SysRng)) let mut shares = shamir::split_key(threshold, total, &mut seal_key, UnwrapErr(SysRng))
.map_err(|error| Error::Shamir(error.to_string()))?; .map_err(|error| Error::Shamir(error.to_string()))?;
if shares.len() < total {
return Err(Error::Shamir("missing share for operator".to_owned()));
}
let mut encrypted = Vec::with_capacity(total); let mut encrypted = Vec::with_capacity(total);
for (index, (operator_id, passphrase)) in contributions.0.iter_mut().enumerate() { for ((operator_id, passphrase), share) in contributions.0.iter_mut().zip(shares.iter_mut()) {
let share = shares let share = share.read_inline(|share| encrypt_share(passphrase, share))?;
.read_inline(|shares| shares.get(index).cloned()) encrypted.push((*operator_id, share));
.ok_or_else(|| Error::Shamir("missing share for operator".to_owned()))?;
encrypted.push((*operator_id, encrypt_share(passphrase, &share)?));
} }
vault vault
@@ -215,24 +209,18 @@ async fn finalize_bootstrap(
/// Reconstruct the seal key from the contributed passphrases and unseal. /// Reconstruct the seal key from the contributed passphrases and unseal.
async fn finalize_unseal( async fn finalize_unseal(
db: &db::DatabasePool, db: &db::DatabasePool,
custody: &Arc<dyn CustodyStore>,
vault: &ActorRef<Vault>, vault: &ActorRef<Vault>,
threshold: usize, threshold: usize,
contributions: &mut Contributions, contributions: &mut Contributions,
) -> Result<(), Error> { ) -> Result<(), Error> {
let stored = { let stored = {
let mut conn = db.get().await?; let mut conn = db.get().await?;
custody custody::shares(&mut conn, &contributions.operators()).await?
.shares(&mut conn, &contributions.operators())
.await?
}; };
let mut plaintext = SafeCell::new(Vec::with_capacity(stored.len())); let mut plaintext = Vec::with_capacity(stored.len());
for ((_, passphrase), share) in contributions.0.iter_mut().zip(stored) { for ((_, passphrase), share) in contributions.0.iter_mut().zip(stored) {
let mut decrypted = decrypt_share(passphrase, share)?; plaintext.push(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) let seal_key = shamir::combine_shares(threshold, &mut plaintext)
@@ -337,7 +325,7 @@ impl VaultCoordinator {
if matches!(self.state, CoordinatorState::Idle) { if matches!(self.state, CoordinatorState::Idle) {
let threshold = { let threshold = {
let mut conn = self.db.get().await?; let mut conn = self.db.get().await?;
self.custody.threshold(&mut conn).await? custody::threshold(&mut conn).await?
}; };
self.state = CoordinatorState::Unsealing { self.state = CoordinatorState::Unsealing {
threshold, threshold,
@@ -375,15 +363,7 @@ impl VaultCoordinator {
unreachable!("state was matched as Unsealing above") unreachable!("state was matched as Unsealing above")
}; };
match finalize_unseal( match finalize_unseal(&self.db, &self.vault, threshold, &mut contributions).await {
&self.db,
&self.custody,
&self.vault,
threshold,
&mut contributions,
)
.await
{
Ok(()) => Ok(true), Ok(()) => Ok(true),
Err(error) => { Err(error) => {
self.state = CoordinatorState::Unsealing { self.state = CoordinatorState::Unsealing {

View File

@@ -206,9 +206,6 @@ pub async fn is_signing_available(vault: &ActorRef<Vault>) -> Result<bool, Error
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::sync::Arc;
use crate::db::custody::DieselCustodyStore;
use diesel::{ExpressionMethods as _, QueryDsl}; use diesel::{ExpressionMethods as _, QueryDsl};
use diesel_async::RunQueryDsl; use diesel_async::RunQueryDsl;
use kameo::{actor::ActorRef, prelude::Spawn}; use kameo::{actor::ActorRef, prelude::Spawn};
@@ -234,13 +231,9 @@ mod tests {
async fn bootstrapped_vault(db: &db::DatabasePool) -> ActorRef<Vault> { async fn bootstrapped_vault(db: &db::DatabasePool) -> ActorRef<Vault> {
let actor = Vault::spawn( let actor = Vault::spawn(
Vault::new( Vault::new(db.clone(), GlobalActors::spawn_message_bus())
db.clone(), .await
GlobalActors::spawn_message_bus(), .unwrap(),
Arc::new(DieselCustodyStore),
)
.await
.unwrap(),
); );
actor actor
.ask(Bootstrap { .ask(Bootstrap {

View File

@@ -16,10 +16,13 @@ pub enum ShamirError {
Combine(String), Combine(String),
} }
/// Return the required majority threshold for an ordinary operator committee. /// Return the required threshold for a Shamir share pool of `committee_size`.
/// ///
/// Committees of two are rejected: a majority of two is two, which gives each /// A pool of two is rejected: a majority of two is two, which gives each holder
/// member a veto over every unseal without giving either one recovery. /// a veto over every unseal without giving either one recovery. That rejects no
/// supported committee, because a two-operator vault must carry at least one
/// recovery share and so never splits into a pool of two -- see
/// `docs/ARCHITECTURE.md` 3.9.
#[expect( #[expect(
clippy::integer_division, clippy::integer_division,
reason = "majority thresholds use integer arithmetic" reason = "majority thresholds use integer arithmetic"
@@ -40,7 +43,7 @@ pub fn split_key(
total: usize, total: usize,
key: &mut KeyCell, key: &mut KeyCell,
rng: impl CryptoRng, rng: impl CryptoRng,
) -> Result<SafeCell<Vec<Vec<u8>>>, ShamirError> { ) -> Result<Vec<SafeCell<Vec<u8>>>, ShamirError> {
if total == 0 || threshold == 0 || threshold > total || total == 2 || total > MAX_COMMITTEE_SIZE if total == 0 || threshold == 0 || threshold > total || total == 2 || total > MAX_COMMITTEE_SIZE
{ {
return Err(ShamirError::Split( return Err(ShamirError::Split(
@@ -48,20 +51,23 @@ pub fn split_key(
)); ));
} }
// Nothing to interpolate when one share suffices.
if threshold == 1 {
return Ok(key.0.read_inline(|key| {
std::iter::repeat_with(|| SafeCell::new(key.as_slice().to_vec()))
.take(total)
.collect()
}));
}
key.0.read_inline(|key| { key.0.read_inline(|key| {
let key: &[u8; 32] = key let key: &[u8; 32] = key
.as_slice() .as_slice()
.try_into() .try_into()
.map_err(|_| ShamirError::Split("unexpected seal key length".to_owned()))?; .map_err(|_| ShamirError::Split("unexpected seal key length".to_owned()))?;
if threshold == 1 {
return Ok(SafeCell::new(
std::iter::repeat_n(key.to_vec(), total).collect(),
));
}
Gf256::split_array(threshold, total, key, rng) Gf256::split_array(threshold, total, key, rng)
.map(SafeCell::new) .map(|shares| shares.into_iter().map(SafeCell::new).collect())
.map_err(|error| ShamirError::Split(format!("{error:?}"))) .map_err(|error| ShamirError::Split(format!("{error:?}")))
}) })
} }
@@ -74,30 +80,43 @@ pub fn split_key(
/// sized. /// sized.
pub fn combine_shares( pub fn combine_shares(
threshold: usize, threshold: usize,
shares: &mut SafeCell<Vec<Vec<u8>>>, shares: &mut [SafeCell<Vec<u8>>],
) -> Result<KeyCell, ShamirError> { ) -> Result<KeyCell, ShamirError> {
if threshold == 0 { if threshold == 0 {
return Err(ShamirError::Combine("threshold is zero".to_owned())); return Err(ShamirError::Combine("threshold is zero".to_owned()));
} }
if shares.read().len() < threshold { if shares.len() < threshold {
return Err(ShamirError::Combine( return Err(ShamirError::Combine(
"not enough shares supplied".to_owned(), "not enough shares supplied".to_owned(),
)); ));
} }
let combined = shares.read_inline(|shares| { // Mirror of the one-of-one case in [`split_key`]: the share is the key.
if threshold == 1 { if threshold == 1 {
let share = shares let share = shares
.first() .first_mut()
.ok_or_else(|| ShamirError::Combine("no shares supplied".to_owned()))?; .ok_or_else(|| ShamirError::Combine("no shares supplied".to_owned()))?;
return Ok(SafeCell::new(share.clone())); return reconstructed_key(share.read_inline(|share| SafeCell::new(share.clone())));
} }
Gf256::combine_array(shares)
let mut gathered = SafeCell::new(Vec::with_capacity(shares.len()));
for share in shares.iter_mut() {
share.read_inline(|share| {
gathered.write_inline(|gathered| gathered.push(share.clone()));
});
}
let combined = gathered.read_inline(|gathered| {
Gf256::combine_array(gathered.as_slice())
.map(SafeCell::new) .map(SafeCell::new)
.map_err(|error| ShamirError::Combine(format!("{error:?}"))) .map_err(|error| ShamirError::Combine(format!("{error:?}")))
})?; })?;
KeyCell::try_from(combined) reconstructed_key(combined)
}
fn reconstructed_key(bytes: SafeCell<Vec<u8>>) -> Result<KeyCell, ShamirError> {
KeyCell::try_from(bytes)
.map_err(|()| ShamirError::Combine("unexpected reconstructed key length".to_owned())) .map_err(|()| ShamirError::Combine("unexpected reconstructed key length".to_owned()))
} }
@@ -108,6 +127,7 @@ mod tests {
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _}; use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use rand::rngs::SysRng; use rand::rngs::SysRng;
use rand_core::UnwrapErr; use rand_core::UnwrapErr;
use rstest::rstest;
fn key_bytes(mut key: KeyCell) -> [u8; 32] { fn key_bytes(mut key: KeyCell) -> [u8; 32] {
key.0.read_inline(|key| { key.0.read_inline(|key| {
@@ -117,28 +137,29 @@ mod tests {
}) })
} }
fn select(shares: &mut SafeCell<Vec<Vec<u8>>>, indexes: &[usize]) -> SafeCell<Vec<Vec<u8>>> { fn select(shares: &mut [SafeCell<Vec<u8>>], indexes: &[usize]) -> Vec<SafeCell<Vec<u8>>> {
shares.read_inline(|shares| { indexes
SafeCell::new( .iter()
indexes .filter_map(|index| {
.iter() shares
.filter_map(|index| shares.get(*index).cloned()) .get_mut(*index)
.collect(), .map(|share| share.read_inline(|share| SafeCell::new(share.clone())))
) })
}) .collect()
} }
#[test] #[rstest]
fn threshold_shares_reconstruct_fixed_key() { #[case(&[0, 1])]
#[case(&[0, 2])]
#[case(&[1, 2])]
fn threshold_shares_reconstruct_fixed_key(#[case] indexes: &[usize]) {
let expected = [9_u8; 32]; let expected = [9_u8; 32];
let mut key = KeyCell::from(expected); let mut key = KeyCell::from(expected);
let rng = UnwrapErr(SysRng); let rng = UnwrapErr(SysRng);
let mut shares = split_key(2, 3, &mut key, rng).expect("split should succeed"); let mut shares = split_key(2, 3, &mut key, rng).expect("split should succeed");
for indexes in [[0_usize, 1_usize], [0, 2], [1, 2]] { let mut selected = select(&mut shares, indexes);
let mut selected = select(&mut shares, &indexes); let combined = combine_shares(2, &mut selected).expect("combine should succeed");
let combined = combine_shares(2, &mut selected).expect("combine should succeed"); assert_eq!(key_bytes(combined), expected);
assert_eq!(key_bytes(combined), expected);
}
} }
#[test] #[test]
@@ -163,22 +184,23 @@ mod tests {
); );
} }
#[test] #[rstest]
fn empty_committee_has_no_threshold() { #[case(0, None)]
assert_eq!(shamir_threshold(0), None); #[case(1, Some(1))]
#[case(2, None)]
#[case(3, Some(2))]
#[case(4, Some(3))]
#[case(MAX_COMMITTEE_SIZE, Some(128))]
#[case(MAX_COMMITTEE_SIZE + 1, None)]
fn committee_threshold_is_a_majority(
#[case] committee_size: usize,
#[case] expected: Option<usize>,
) {
assert_eq!(shamir_threshold(committee_size), expected);
} }
#[test] #[test]
fn committee_threshold_is_majority_for_three_or_more() { fn oversized_committee_is_rejected_by_split() {
assert_eq!(shamir_threshold(1), Some(1));
assert_eq!(shamir_threshold(3), Some(2));
assert_eq!(shamir_threshold(4), Some(3));
}
#[test]
fn oversized_committee_has_no_threshold() {
assert_eq!(shamir_threshold(MAX_COMMITTEE_SIZE), Some(128));
assert_eq!(shamir_threshold(MAX_COMMITTEE_SIZE + 1), None);
let mut key = KeyCell::from([1_u8; 32]); let mut key = KeyCell::from([1_u8; 32]);
let rng = UnwrapErr(SysRng); let rng = UnwrapErr(SysRng);
assert!( assert!(
@@ -189,7 +211,6 @@ mod tests {
#[test] #[test]
fn two_operator_committee_is_explicitly_unsupported() { fn two_operator_committee_is_explicitly_unsupported() {
assert_eq!(shamir_threshold(2), None);
let mut key = KeyCell::from([7_u8; 32]); let mut key = KeyCell::from([7_u8; 32]);
let rng = UnwrapErr(SysRng); let rng = UnwrapErr(SysRng);
assert!( assert!(

View File

@@ -1,15 +1,13 @@
//! Storage for Shamir custody material: the reconstruction threshold and the //! Storage for Shamir custody material: the reconstruction threshold and the
//! per-operator encrypted shares of the vault seal key. //! per-operator encrypted shares of the vault seal key.
//! //!
//! Every query lives behind [`CustodyStore`] so that the actors above it hold //! The queries live here so that the actors above hold no Diesel code of their
//! no Diesel code of their own. [`CustodyStore::write_record`] borrows the //! own. Every one of them borrows the caller's connection instead of taking one
//! caller's connection instead of taking one from the pool, which lets the //! from the pool, which is what lets the vault write custody material inside
//! vault write custody material inside the same transaction that stores the //! the same transaction that stores the root key.
//! root key.
use std::collections::HashMap; use std::collections::HashMap;
use async_trait::async_trait;
use diesel::{ExpressionMethods as _, QueryDsl}; use diesel::{ExpressionMethods as _, QueryDsl};
use diesel_async::RunQueryDsl; use diesel_async::RunQueryDsl;
@@ -46,117 +44,94 @@ pub enum Error {
MissingShare(OperatorId), MissingShare(OperatorId),
} }
#[async_trait] /// Persist threshold and shares on the caller's connection, joining any
pub trait CustodyStore: std::fmt::Debug + Send + Sync { /// transaction the caller has already opened.
/// Persist threshold and shares on the caller's connection, joining any pub async fn write_record(
/// transaction the caller has already opened. conn: &mut db::DatabaseConnection,
async fn write_record( record: &CustodyRecord,
&self, ) -> Result<(), Error> {
conn: &mut db::DatabaseConnection, let threshold = i32::try_from(record.threshold).map_err(|_| Error::BrokenThreshold)?;
record: &CustodyRecord,
) -> Result<(), Error>;
/// Number of shares required to reconstruct the seal key. // SQLite has no batch form for REPLACE INTO in diesel, so the rows go
async fn threshold(&self, conn: &mut db::DatabaseConnection) -> Result<usize, Error>; // in one at a time. The caller's transaction still makes them atomic.
let now = SqliteTimestamp::now();
/// Load the shares of `operators` in one query, in the order requested. for (operator_id, share) in &record.shares {
async fn shares( diesel::replace_into(schema::operator::table)
&self, .values((
conn: &mut db::DatabaseConnection, schema::operator::id.eq(Some(*operator_id)),
operators: &[OperatorId], schema::operator::share.eq(&share.ciphertext),
) -> Result<Vec<EncryptedShare>, Error>; schema::operator::share_nonce.eq(&share.nonce),
} schema::operator::share_salt.eq(&share.salt),
schema::operator::created_at.eq(now.clone()),
/// The production [`CustodyStore`], backed by the `SQLite` schema. schema::operator::updated_at.eq(now.clone()),
#[derive(Debug, Clone, Copy, Default)] ))
pub struct DieselCustodyStore;
#[async_trait]
impl CustodyStore for DieselCustodyStore {
async fn write_record(
&self,
conn: &mut db::DatabaseConnection,
record: &CustodyRecord,
) -> Result<(), Error> {
let threshold = i32::try_from(record.threshold).map_err(|_| Error::BrokenThreshold)?;
// SQLite has no batch form for REPLACE INTO in diesel, so the rows go
// in one at a time. The caller's transaction still makes them atomic.
let now = SqliteTimestamp::now();
for (operator_id, share) in &record.shares {
diesel::replace_into(schema::operator::table)
.values((
schema::operator::id.eq(Some(*operator_id)),
schema::operator::share.eq(&share.ciphertext),
schema::operator::share_nonce.eq(&share.nonce),
schema::operator::share_salt.eq(&share.salt),
schema::operator::created_at.eq(now.clone()),
schema::operator::updated_at.eq(now.clone()),
))
.execute(&mut *conn)
.await?;
}
let updated = diesel::update(schema::arbiter_settings::table)
.set(schema::arbiter_settings::shamir_threshold.eq(Some(threshold)))
.execute(&mut *conn) .execute(&mut *conn)
.await?; .await?;
if updated != 1 {
return Err(Error::MissingSettings);
}
Ok(())
} }
async fn threshold(&self, conn: &mut db::DatabaseConnection) -> Result<usize, Error> { let updated = diesel::update(schema::arbiter_settings::table)
let stored: Option<i32> = schema::arbiter_settings::table .set(schema::arbiter_settings::shamir_threshold.eq(Some(threshold)))
.select(schema::arbiter_settings::shamir_threshold) .execute(&mut *conn)
.first(conn) .await?;
.await?; if updated != 1 {
return Err(Error::MissingSettings);
stored
.and_then(|value| usize::try_from(value).ok())
.filter(|threshold| *threshold > 0)
.ok_or(Error::BrokenThreshold)
} }
async fn shares( Ok(())
&self, }
conn: &mut db::DatabaseConnection,
operators: &[OperatorId], /// Number of shares required to reconstruct the seal key.
) -> Result<Vec<EncryptedShare>, Error> { pub async fn threshold(conn: &mut db::DatabaseConnection) -> Result<usize, Error> {
let wanted: Vec<Option<OperatorId>> = operators.iter().copied().map(Some).collect(); let stored: Option<i32> = schema::arbiter_settings::table
.select(schema::arbiter_settings::shamir_threshold)
let rows: Vec<(Option<OperatorId>, Vec<u8>, Vec<u8>, Vec<u8>)> = schema::operator::table .first(conn)
.filter(schema::operator::id.eq_any(wanted)) .await?;
.select((
schema::operator::id, stored
schema::operator::share, .and_then(|value| usize::try_from(value).ok())
schema::operator::share_nonce, .filter(|threshold| *threshold > 0)
schema::operator::share_salt, .ok_or(Error::BrokenThreshold)
)) }
.load(conn)
.await?; /// Load the shares of `operators` in one query, in the order requested.
pub async fn shares(
let mut found: HashMap<OperatorId, EncryptedShare> = rows conn: &mut db::DatabaseConnection,
.into_iter() operators: &[OperatorId],
.filter_map(|(id, ciphertext, nonce, salt)| { ) -> Result<Vec<EncryptedShare>, Error> {
id.map(|id| { /// (id, then the three columns that make up [`EncryptedShare`])
( type ShareRow = (Option<OperatorId>, Vec<u8>, Vec<u8>, Vec<u8>);
id,
EncryptedShare { let wanted: Vec<Option<OperatorId>> = operators.iter().copied().map(Some).collect();
ciphertext,
nonce, let rows: Vec<ShareRow> = schema::operator::table
salt, .filter(schema::operator::id.eq_any(wanted))
}, .select((
) schema::operator::id,
}) schema::operator::share,
}) schema::operator::share_nonce,
.collect(); schema::operator::share_salt,
))
operators .load(conn)
.iter() .await?;
.map(|id| found.remove(id).ok_or(Error::MissingShare(*id)))
.collect() let mut found: HashMap<OperatorId, EncryptedShare> = rows
} .into_iter()
.filter_map(|(id, ciphertext, nonce, salt)| {
id.map(|id| {
(
id,
EncryptedShare {
ciphertext,
nonce,
salt,
},
)
})
})
.collect();
operators
.iter()
.map(|id| found.remove(id).ok_or(Error::MissingShare(*id)))
.collect()
} }

View File

@@ -501,9 +501,6 @@ impl Engine {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::sync::Arc;
use crate::db::custody::DieselCustodyStore;
use alloy::primitives::{Address, Bytes, U256, address}; use alloy::primitives::{Address, Bytes, U256, address};
use chrono::{Duration, Utc}; use chrono::{Duration, Utc};
use diesel::{SelectableHelper, insert_into}; use diesel::{SelectableHelper, insert_into};
@@ -769,13 +766,9 @@ mod tests {
async fn bootstrapped_vault(db: &db::DatabasePool) -> ActorRef<Vault> { async fn bootstrapped_vault(db: &db::DatabasePool) -> ActorRef<Vault> {
let actor = Vault::spawn( let actor = Vault::spawn(
Vault::new( Vault::new(db.clone(), GlobalActors::spawn_message_bus())
db.clone(), .await
GlobalActors::spawn_message_bus(), .unwrap(),
Arc::new(DieselCustodyStore),
)
.await
.unwrap(),
); );
actor actor
.ask(Bootstrap { .ask(Bootstrap {

View File

@@ -7,23 +7,18 @@ use arbiter_proto::transport::{Bi, Error, Receiver, Sender};
use arbiter_server::{ use arbiter_server::{
actors::{GlobalActors, vault::Vault}, actors::{GlobalActors, vault::Vault},
crypto::KeyCell, crypto::KeyCell,
db::{self, custody::DieselCustodyStore, schema}, db::{self, schema},
}; };
use async_trait::async_trait; use async_trait::async_trait;
use diesel::QueryDsl; use diesel::QueryDsl;
use diesel_async::RunQueryDsl; use diesel_async::RunQueryDsl;
use std::sync::Arc;
use tokio::sync::mpsc; use tokio::sync::mpsc;
pub(crate) async fn bootstrapped_vault(db: &db::DatabasePool) -> Vault { pub(crate) async fn bootstrapped_vault(db: &db::DatabasePool) -> Vault {
let mut actor = Vault::new( let mut actor = Vault::new(db.clone(), GlobalActors::spawn_message_bus())
db.clone(), .await
GlobalActors::spawn_message_bus(), .unwrap();
Arc::new(DieselCustodyStore),
)
.await
.unwrap();
actor actor
.bootstrap(KeyCell::from([0u8; 32]), None) .bootstrap(KeyCell::from([0u8; 32]), None)
.await .await

View File

@@ -6,16 +6,13 @@ use arbiter_server::{
vault::{CreateNew, Error, Vault}, vault::{CreateNew, Error, Vault},
}, },
crypto::KeyCell, crypto::KeyCell,
db::{self, custody::DieselCustodyStore, models, schema}, db::{self, models, schema},
}; };
use diesel::{ExpressionMethods as _, QueryDsl, SelectableHelper, dsl::sql_query}; use diesel::{ExpressionMethods as _, QueryDsl, SelectableHelper, dsl::sql_query};
use diesel_async::RunQueryDsl; use diesel_async::RunQueryDsl;
use kameo::actor::{ActorRef, Spawn as _}; use kameo::actor::{ActorRef, Spawn as _};
use std::{ use std::collections::{HashMap, HashSet};
collections::{HashMap, HashSet},
sync::Arc,
};
use tokio::task::JoinSet; use tokio::task::JoinSet;
const TEST_AAD: &[u8] = b"test-aad"; const TEST_AAD: &[u8] = b"test-aad";
@@ -169,13 +166,9 @@ async fn decrypt_roundtrip_after_high_concurrency() {
let writes = write_concurrently(actor, "roundtrip", 40).await; let writes = write_concurrently(actor, "roundtrip", 40).await;
let expected: HashMap<i32, Vec<u8>> = writes.into_iter().collect(); let expected: HashMap<i32, Vec<u8>> = writes.into_iter().collect();
let mut decryptor = Vault::new( let mut decryptor = Vault::new(db.clone(), GlobalActors::spawn_message_bus())
db.clone(), .await
GlobalActors::spawn_message_bus(), .unwrap();
Arc::new(DieselCustodyStore),
)
.await
.unwrap();
decryptor decryptor
.try_unseal(KeyCell::from([0u8; 32])) .try_unseal(KeyCell::from([0u8; 32]))
.await .await

View File

@@ -9,12 +9,11 @@ use arbiter_server::{
KeyCell, KeyCell,
encryption::v1::{Nonce, ROOT_KEY_TAG}, encryption::v1::{Nonce, ROOT_KEY_TAG},
}, },
db::{self, custody::DieselCustodyStore, models, schema}, db::{self, models, schema},
}; };
use diesel::{QueryDsl, SelectableHelper}; use diesel::{QueryDsl, SelectableHelper};
use diesel_async::RunQueryDsl; use diesel_async::RunQueryDsl;
use std::sync::Arc;
const TEST_AAD: &[u8] = b"test-aad"; const TEST_AAD: &[u8] = b"test-aad";
@@ -22,13 +21,9 @@ const TEST_AAD: &[u8] = b"test-aad";
#[test_log::test] #[test_log::test]
async fn bootstrap() { async fn bootstrap() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
let mut actor = Vault::new( let mut actor = Vault::new(db.clone(), GlobalActors::spawn_message_bus())
db.clone(), .await
GlobalActors::spawn_message_bus(), .unwrap();
Arc::new(DieselCustodyStore),
)
.await
.unwrap();
let seal_key = KeyCell::from([0u8; 32]); let seal_key = KeyCell::from([0u8; 32]);
actor.bootstrap(seal_key, None).await.unwrap(); actor.bootstrap(seal_key, None).await.unwrap();
@@ -62,13 +57,9 @@ async fn bootstrap_rejects_double() {
#[test_log::test] #[test_log::test]
async fn create_new_before_bootstrap_fails() { async fn create_new_before_bootstrap_fails() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
let mut actor = Vault::new( let mut actor = Vault::new(db, GlobalActors::spawn_message_bus())
db, .await
GlobalActors::spawn_message_bus(), .unwrap();
Arc::new(DieselCustodyStore),
)
.await
.unwrap();
let err = actor let err = actor
.create_new(SafeCell::new(b"data".to_vec()), TEST_AAD.to_vec()) .create_new(SafeCell::new(b"data".to_vec()), TEST_AAD.to_vec())
@@ -81,13 +72,9 @@ async fn create_new_before_bootstrap_fails() {
#[test_log::test] #[test_log::test]
async fn decrypt_before_bootstrap_fails() { async fn decrypt_before_bootstrap_fails() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
let mut actor = Vault::new( let mut actor = Vault::new(db, GlobalActors::spawn_message_bus())
db, .await
GlobalActors::spawn_message_bus(), .unwrap();
Arc::new(DieselCustodyStore),
)
.await
.unwrap();
let err = actor.decrypt(1, TEST_AAD.to_vec()).await.unwrap_err(); let err = actor.decrypt(1, TEST_AAD.to_vec()).await.unwrap_err();
assert!(matches!(err, Error::NotBootstrapped)); assert!(matches!(err, Error::NotBootstrapped));
@@ -100,13 +87,9 @@ async fn new_restores_sealed_state() {
let actor = common::bootstrapped_vault(&db).await; let actor = common::bootstrapped_vault(&db).await;
drop(actor); drop(actor);
let mut actor2 = Vault::new( let mut actor2 = Vault::new(db, GlobalActors::spawn_message_bus())
db, .await
GlobalActors::spawn_message_bus(), .unwrap();
Arc::new(DieselCustodyStore),
)
.await
.unwrap();
let err = actor2.decrypt(1, TEST_AAD.to_vec()).await.unwrap_err(); let err = actor2.decrypt(1, TEST_AAD.to_vec()).await.unwrap_err();
assert!(matches!(err, Error::Sealed)); assert!(matches!(err, Error::Sealed));
} }
@@ -124,13 +107,9 @@ async fn unseal_correct_password() {
.unwrap(); .unwrap();
drop(actor); drop(actor);
let mut actor = Vault::new( let mut actor = Vault::new(db.clone(), GlobalActors::spawn_message_bus())
db.clone(), .await
GlobalActors::spawn_message_bus(), .unwrap();
Arc::new(DieselCustodyStore),
)
.await
.unwrap();
let seal_key = KeyCell::from([0u8; 32]); let seal_key = KeyCell::from([0u8; 32]);
actor.try_unseal(seal_key).await.unwrap(); actor.try_unseal(seal_key).await.unwrap();
@@ -151,13 +130,9 @@ async fn unseal_wrong_then_correct_password() {
.unwrap(); .unwrap();
drop(actor); drop(actor);
let mut actor = Vault::new( let mut actor = Vault::new(db.clone(), GlobalActors::spawn_message_bus())
db.clone(), .await
GlobalActors::spawn_message_bus(), .unwrap();
Arc::new(DieselCustodyStore),
)
.await
.unwrap();
let bad_key = KeyCell::from([1u8; 32]); let bad_key = KeyCell::from([1u8; 32]);
let err = actor.try_unseal(bad_key).await.unwrap_err(); let err = actor.try_unseal(bad_key).await.unwrap_err();