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

This commit is contained in:
CleverWild
2026-09-09 16:42:11 +02:00
parent 0677695b16
commit 666cf52755
37 changed files with 2105 additions and 279 deletions

View File

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

View File

@@ -0,0 +1,317 @@
//! End-to-end coverage for the Shamir custody ceremonies.
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use arbiter_server::{
actors::{
GlobalActors,
vault::{Bootstrap, GetState, Seal, VaultState},
vault_coordinator::{ContributeBootstrap, ContributeUnseal, StartBootstrap},
},
crypto::{KeyCell, shamir},
db::{self, models::OperatorId, schema},
};
use diesel::{ExpressionMethods as _, QueryDsl};
use diesel_async::RunQueryDsl;
/// Register `count` operator identities so committee members satisfy the
/// foreign key from `operator` to `operator_identity`.
async fn register_operators(db: &db::DatabasePool, count: usize) -> Vec<OperatorId> {
let mut conn = db.get().await.unwrap();
let mut ids = Vec::with_capacity(count);
for index in 0..count {
let pubkey = vec![u8::try_from(index).unwrap(); 32];
let id: OperatorId = diesel::insert_into(schema::operator_identity::table)
.values((schema::operator_identity::public_key.eq(pubkey),))
.returning(schema::operator_identity::id)
.get_result(&mut conn)
.await
.unwrap();
ids.push(id);
}
ids
}
async fn stored_share_count(db: &db::DatabasePool) -> i64 {
let mut conn = db.get().await.unwrap();
schema::operator::table
.count()
.get_result(&mut conn)
.await
.unwrap()
}
async fn stored_threshold(db: &db::DatabasePool) -> Option<i32> {
let mut conn = db.get().await.unwrap();
schema::arbiter_settings::table
.select(schema::arbiter_settings::shamir_threshold)
.first(&mut conn)
.await
.unwrap()
}
fn passphrase(seed: u8) -> SafeCell<Vec<u8>> {
SafeCell::new(vec![seed; 16])
}
/// The happy path: three operators bootstrap, and any two of them reopen the
/// vault after it is sealed.
#[tokio::test]
#[test_log::test]
async fn committee_of_three_unseals_with_two_passphrases() {
let db = db::create_test_pool().await;
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
let operators = register_operators(&db, 3).await;
actors
.vault_coordinator
.ask(StartBootstrap {
operator_id: operators[0],
declared_count: 3,
})
.await
.unwrap();
for (index, operator_id) in operators.iter().enumerate() {
let finished = actors
.vault_coordinator
.ask(ContributeBootstrap {
operator_id: *operator_id,
passphrase: passphrase(u8::try_from(index).unwrap()),
})
.await
.unwrap();
assert_eq!(
finished,
index == 2,
"the ceremony finishes only on the last contribution"
);
}
assert_eq!(
actors.vault.ask(GetState {}).await.unwrap(),
VaultState::Unsealed
);
assert_eq!(stored_share_count(&db).await, 3);
assert_eq!(stored_threshold(&db).await, Some(2));
actors.vault.ask(Seal {}).await.unwrap();
let first = actors
.vault_coordinator
.ask(ContributeUnseal {
operator_id: operators[0],
passphrase: passphrase(0),
})
.await
.unwrap();
assert!(!first, "one of two shares must not unseal");
let second = actors
.vault_coordinator
.ask(ContributeUnseal {
operator_id: operators[2],
passphrase: passphrase(2),
})
.await
.unwrap();
assert!(second, "the threshold contribution should unseal the vault");
assert_eq!(
actors.vault.ask(GetState {}).await.unwrap(),
VaultState::Unsealed
);
}
/// Shares that describe a seal key the vault never adopted would make the vault
/// permanently un-unsealable, so a refused bootstrap must leave the table empty.
#[tokio::test]
#[test_log::test]
async fn refused_bootstrap_stores_no_shares() {
let db = db::create_test_pool().await;
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
let operators = register_operators(&db, 1).await;
// Another path bootstraps the vault first; the ceremony now has nowhere to go.
actors
.vault
.ask(Bootstrap {
seal_key: KeyCell::from([4u8; 32]),
custody: None,
})
.await
.unwrap();
actors
.vault_coordinator
.ask(StartBootstrap {
operator_id: operators[0],
declared_count: 1,
})
.await
.unwrap();
let error = actors
.vault_coordinator
.ask(ContributeBootstrap {
operator_id: operators[0],
passphrase: passphrase(1),
})
.await
.expect_err("bootstrapping an already bootstrapped vault must fail");
assert!(
format!("{error:?}").contains("AlreadyBootstrapped"),
"expected AlreadyBootstrapped, got {error:?}"
);
assert_eq!(
stored_share_count(&db).await,
0,
"a refused bootstrap must not leave shares behind"
);
assert_eq!(
stored_threshold(&db).await,
None,
"a refused bootstrap must not leave a threshold behind"
);
}
#[tokio::test]
#[test_log::test]
async fn oversized_and_degenerate_committees_are_rejected() {
let db = db::create_test_pool().await;
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
let operators = register_operators(&db, 1).await;
for (count, expected) in [
(0_usize, "EmptyCommittee"),
(2, "UnsupportedCommittee"),
(shamir::MAX_COMMITTEE_SIZE + 1, "CommitteeTooLarge"),
(usize::MAX, "CommitteeTooLarge"),
] {
let error = actors
.vault_coordinator
.ask(StartBootstrap {
operator_id: operators[0],
declared_count: count,
})
.await
.expect_err("committee size must be rejected");
assert!(
format!("{error:?}").contains(expected),
"expected {expected} for count {count}, got {error:?}"
);
}
}
/// A committee whose members never all show up would otherwise wedge the
/// coordinator until restart. Only the operator that declared it may reset it.
#[tokio::test]
#[test_log::test]
async fn only_the_declarer_may_restart_a_stalled_committee() {
let db = db::create_test_pool().await;
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
let operators = register_operators(&db, 3).await;
actors
.vault_coordinator
.ask(StartBootstrap {
operator_id: operators[0],
declared_count: 3,
})
.await
.unwrap();
actors
.vault_coordinator
.ask(ContributeBootstrap {
operator_id: operators[0],
passphrase: passphrase(0),
})
.await
.unwrap();
let error = actors
.vault_coordinator
.ask(StartBootstrap {
operator_id: operators[1],
declared_count: 3,
})
.await
.expect_err("a bystander must not reset someone else's ceremony");
assert!(
format!("{error:?}").contains("AlreadyBootstrapping"),
"expected AlreadyBootstrapping, got {error:?}"
);
actors
.vault_coordinator
.ask(StartBootstrap {
operator_id: operators[0],
declared_count: 1,
})
.await
.expect("the declarer may restart the ceremony");
let finished = actors
.vault_coordinator
.ask(ContributeBootstrap {
operator_id: operators[0],
passphrase: passphrase(0),
})
.await
.unwrap();
assert!(
finished,
"the restarted one-operator ceremony should complete"
);
assert_eq!(stored_share_count(&db).await, 1);
}
/// A wrong passphrase must not unseal, and the operator must be able to retry.
#[tokio::test]
#[test_log::test]
async fn wrong_passphrase_is_rejected_and_retryable() {
let db = db::create_test_pool().await;
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
let operators = register_operators(&db, 1).await;
actors
.vault_coordinator
.ask(StartBootstrap {
operator_id: operators[0],
declared_count: 1,
})
.await
.unwrap();
actors
.vault_coordinator
.ask(ContributeBootstrap {
operator_id: operators[0],
passphrase: passphrase(7),
})
.await
.unwrap();
actors.vault.ask(Seal {}).await.unwrap();
let error = actors
.vault_coordinator
.ask(ContributeUnseal {
operator_id: operators[0],
passphrase: passphrase(8),
})
.await
.expect_err("a wrong passphrase must not unseal");
assert!(
format!("{error:?}").contains("InvalidPassphrase"),
"expected InvalidPassphrase, got {error:?}"
);
let unsealed = actors
.vault_coordinator
.ask(ContributeUnseal {
operator_id: operators[0],
passphrase: passphrase(7),
})
.await
.expect("the operator may retry with the correct passphrase");
assert!(unsealed, "the retry should unseal the vault");
}

View File

@@ -5,12 +5,16 @@ use arbiter_server::{
GlobalActors,
vault::{Error, Vault},
},
crypto::encryption::v1::{Nonce, ROOT_KEY_TAG},
db::{self, models, schema},
crypto::{
KeyCell,
encryption::v1::{Nonce, ROOT_KEY_TAG},
},
db::{self, custody::DieselCustodyStore, models, schema},
};
use diesel::{QueryDsl, SelectableHelper};
use diesel_async::RunQueryDsl;
use std::sync::Arc;
const TEST_AAD: &[u8] = b"test-aad";
@@ -18,12 +22,16 @@ const TEST_AAD: &[u8] = b"test-aad";
#[test_log::test]
async fn bootstrap() {
let db = db::create_test_pool().await;
let mut actor = Vault::new(db.clone(), GlobalActors::spawn_message_bus())
.await
.unwrap();
let mut actor = Vault::new(
db.clone(),
GlobalActors::spawn_message_bus(),
Arc::new(DieselCustodyStore),
)
.await
.unwrap();
let seal_key = SafeCell::new(b"test-seal-key".to_vec());
actor.bootstrap(seal_key).await.unwrap();
let seal_key = KeyCell::from([0u8; 32]);
actor.bootstrap(seal_key, None).await.unwrap();
let mut conn = db.get().await.unwrap();
let row: models::RootKeyHistory = schema::root_key_history::table
@@ -45,8 +53,8 @@ async fn bootstrap_rejects_double() {
let db = db::create_test_pool().await;
let mut actor = common::bootstrapped_vault(&db).await;
let seal_key2 = SafeCell::new(b"test-seal-key".to_vec());
let err = actor.bootstrap(seal_key2).await.unwrap_err();
let seal_key2 = KeyCell::from([0u8; 32]);
let err = actor.bootstrap(seal_key2, None).await.unwrap_err();
assert!(matches!(err, Error::AlreadyBootstrapped));
}
@@ -54,9 +62,13 @@ async fn bootstrap_rejects_double() {
#[test_log::test]
async fn create_new_before_bootstrap_fails() {
let db = db::create_test_pool().await;
let mut actor = Vault::new(db, GlobalActors::spawn_message_bus())
.await
.unwrap();
let mut actor = Vault::new(
db,
GlobalActors::spawn_message_bus(),
Arc::new(DieselCustodyStore),
)
.await
.unwrap();
let err = actor
.create_new(SafeCell::new(b"data".to_vec()), TEST_AAD.to_vec())
@@ -69,9 +81,13 @@ async fn create_new_before_bootstrap_fails() {
#[test_log::test]
async fn decrypt_before_bootstrap_fails() {
let db = db::create_test_pool().await;
let mut actor = Vault::new(db, GlobalActors::spawn_message_bus())
.await
.unwrap();
let mut actor = Vault::new(
db,
GlobalActors::spawn_message_bus(),
Arc::new(DieselCustodyStore),
)
.await
.unwrap();
let err = actor.decrypt(1, TEST_AAD.to_vec()).await.unwrap_err();
assert!(matches!(err, Error::NotBootstrapped));
@@ -84,9 +100,13 @@ async fn new_restores_sealed_state() {
let actor = common::bootstrapped_vault(&db).await;
drop(actor);
let mut actor2 = Vault::new(db, GlobalActors::spawn_message_bus())
.await
.unwrap();
let mut actor2 = Vault::new(
db,
GlobalActors::spawn_message_bus(),
Arc::new(DieselCustodyStore),
)
.await
.unwrap();
let err = actor2.decrypt(1, TEST_AAD.to_vec()).await.unwrap_err();
assert!(matches!(err, Error::Sealed));
}
@@ -104,10 +124,14 @@ async fn unseal_correct_password() {
.unwrap();
drop(actor);
let mut actor = Vault::new(db.clone(), GlobalActors::spawn_message_bus())
.await
.unwrap();
let seal_key = SafeCell::new(b"test-seal-key".to_vec());
let mut actor = Vault::new(
db.clone(),
GlobalActors::spawn_message_bus(),
Arc::new(DieselCustodyStore),
)
.await
.unwrap();
let seal_key = KeyCell::from([0u8; 32]);
actor.try_unseal(seal_key).await.unwrap();
let mut decrypted = actor.decrypt(aead_id, TEST_AAD.to_vec()).await.unwrap();
@@ -127,15 +151,19 @@ async fn unseal_wrong_then_correct_password() {
.unwrap();
drop(actor);
let mut actor = Vault::new(db.clone(), GlobalActors::spawn_message_bus())
.await
.unwrap();
let mut actor = Vault::new(
db.clone(),
GlobalActors::spawn_message_bus(),
Arc::new(DieselCustodyStore),
)
.await
.unwrap();
let bad_key = SafeCell::new(b"wrong-password".to_vec());
let bad_key = KeyCell::from([1u8; 32]);
let err = actor.try_unseal(bad_key).await.unwrap_err();
assert!(matches!(err, Error::InvalidKey));
let good_key = SafeCell::new(b"test-seal-key".to_vec());
let good_key = KeyCell::from([0u8; 32]);
actor.try_unseal(good_key).await.unwrap();
let mut decrypted = actor.decrypt(aead_id, TEST_AAD.to_vec()).await.unwrap();