feat(vault)!: add multi-operator Shamir custody
This commit is contained in:
@@ -1,15 +1,12 @@
|
||||
use super::common::ChannelTransport;
|
||||
use arbiter_crypto::{
|
||||
authn::{self, AuthChallenge, CLIENT_CONTEXT},
|
||||
safecell::{SafeCell, SafeCellHandle as _},
|
||||
};
|
||||
use arbiter_crypto::authn::{self, AuthChallenge, CLIENT_CONTEXT};
|
||||
use arbiter_proto::{
|
||||
ClientMetadata,
|
||||
transport::{Receiver, Sender},
|
||||
};
|
||||
use arbiter_server::{
|
||||
actors::{GlobalActors, vault::Bootstrap},
|
||||
crypto::integrity,
|
||||
crypto::{KeyCell, integrity},
|
||||
db::{self, schema},
|
||||
peers::client::{ClientConnection, ClientCredentials, auth, connect_client},
|
||||
};
|
||||
@@ -100,7 +97,8 @@ async fn spawn_test_actors(db: &db::DatabasePool) -> GlobalActors {
|
||||
actors
|
||||
.vault
|
||||
.ask(Bootstrap {
|
||||
seal_key_raw: SafeCell::new(b"test-seal-key".to_vec()),
|
||||
seal_key: KeyCell::from([0u8; 32]),
|
||||
custody: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -340,7 +338,10 @@ pub async fn metadata_frozen_after_approval_ignores_reconnect_changes() {
|
||||
.first::<(String, Option<String>, Option<String>)>(&mut conn)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(metadata_count, 1, "frozen: no new metadata row on reconnect");
|
||||
assert_eq!(
|
||||
metadata_count, 1,
|
||||
"frozen: no new metadata row on reconnect"
|
||||
);
|
||||
assert_eq!(history_count, 0, "frozen: no history entry on reconnect");
|
||||
assert_eq!(
|
||||
current,
|
||||
|
||||
@@ -2,24 +2,30 @@
|
||||
dead_code,
|
||||
reason = "Common test utilities that may not be used in every test"
|
||||
)]
|
||||
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
|
||||
|
||||
use arbiter_proto::transport::{Bi, Error, Receiver, Sender};
|
||||
use arbiter_server::{
|
||||
actors::{GlobalActors, vault::Vault},
|
||||
db::{self, schema},
|
||||
crypto::KeyCell,
|
||||
db::{self, custody::DieselCustodyStore, schema},
|
||||
};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use diesel::QueryDsl;
|
||||
use diesel_async::RunQueryDsl;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
pub(crate) async fn bootstrapped_vault(db: &db::DatabasePool) -> Vault {
|
||||
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();
|
||||
actor
|
||||
.bootstrap(SafeCell::new(b"test-seal-key".to_vec()))
|
||||
.bootstrap(KeyCell::from([0u8; 32]), None)
|
||||
.await
|
||||
.unwrap();
|
||||
actor
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
use super::common::ChannelTransport;
|
||||
use arbiter_crypto::{
|
||||
authn::{self, AuthChallenge, OPERATOR_CONTEXT},
|
||||
safecell::{SafeCell, SafeCellHandle as _},
|
||||
};
|
||||
use arbiter_crypto::authn::{self, AuthChallenge, OPERATOR_CONTEXT};
|
||||
use arbiter_proto::transport::{Error as TransportError, Receiver, Sender};
|
||||
use arbiter_server::{
|
||||
actors::{GlobalActors, bootstrap::GetToken, vault::Bootstrap},
|
||||
crypto::integrity,
|
||||
db::{self, schema},
|
||||
crypto::{KeyCell, integrity},
|
||||
db::{self, models::OperatorId, schema},
|
||||
peers::operator::{self, Credentials, OperatorConnection, auth, vault_gate},
|
||||
};
|
||||
|
||||
@@ -154,13 +151,6 @@ impl Sender<auth::Inbound> for StartTestTransport {
|
||||
pub async fn bootstrap_token_auth() {
|
||||
let db = db::create_test_pool().await;
|
||||
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
|
||||
actors
|
||||
.vault
|
||||
.ask(Bootstrap {
|
||||
seal_key_raw: SafeCell::new(b"test-seal-key".to_vec()),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let token = actors.bootstrapper.ask(GetToken).await.unwrap().unwrap();
|
||||
|
||||
let (mut server_transport, mut test_transport) = ChannelTransport::new();
|
||||
@@ -275,7 +265,8 @@ pub async fn challenge_auth() {
|
||||
actors
|
||||
.vault
|
||||
.ask(Bootstrap {
|
||||
seal_key_raw: SafeCell::new(b"test-seal-key".to_vec()),
|
||||
seal_key: KeyCell::from([0u8; 32]),
|
||||
custody: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -285,10 +276,10 @@ pub async fn challenge_auth() {
|
||||
|
||||
{
|
||||
let mut conn = db.get().await.unwrap();
|
||||
let id: i32 = insert_into(schema::operator_identity::table)
|
||||
let id: OperatorId = insert_into(schema::operator_identity::table)
|
||||
.values((schema::operator_identity::public_key.eq(pubkey_bytes.clone()),))
|
||||
.returning(schema::operator_identity::id)
|
||||
.get_result(&mut conn)
|
||||
.get_result::<OperatorId>(&mut conn)
|
||||
.await
|
||||
.unwrap();
|
||||
integrity::sign_entity(
|
||||
@@ -361,7 +352,8 @@ pub async fn challenge_auth_rejects_integrity_tag_mismatch_when_unsealed() {
|
||||
actors
|
||||
.vault
|
||||
.ask(Bootstrap {
|
||||
seal_key_raw: SafeCell::new(b"test-seal-key".to_vec()),
|
||||
seal_key: KeyCell::from([0u8; 32]),
|
||||
custody: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -434,7 +426,8 @@ pub async fn challenge_auth_rejects_invalid_signature() {
|
||||
actors
|
||||
.vault
|
||||
.ask(Bootstrap {
|
||||
seal_key_raw: SafeCell::new(b"test-seal-key".to_vec()),
|
||||
seal_key: KeyCell::from([0u8; 32]),
|
||||
custody: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -444,10 +437,10 @@ pub async fn challenge_auth_rejects_invalid_signature() {
|
||||
|
||||
{
|
||||
let mut conn = db.get().await.unwrap();
|
||||
let id: i32 = insert_into(schema::operator_identity::table)
|
||||
let id: OperatorId = insert_into(schema::operator_identity::table)
|
||||
.values((schema::operator_identity::public_key.eq(pubkey_bytes.clone()),))
|
||||
.returning(schema::operator_identity::id)
|
||||
.get_result(&mut conn)
|
||||
.get_result::<OperatorId>(&mut conn)
|
||||
.await
|
||||
.unwrap();
|
||||
integrity::sign_entity(
|
||||
@@ -506,3 +499,92 @@ pub async fn challenge_auth_rejects_invalid_signature() {
|
||||
Err(auth::Error::InvalidChallengeSolution)
|
||||
));
|
||||
}
|
||||
|
||||
/// The bootstrap token authorises registering committee members *before* the
|
||||
/// vault exists. Once any bootstrap path succeeds it must stop working, or its
|
||||
/// holder could keep minting operator identities until the next restart.
|
||||
#[tokio::test]
|
||||
#[test_log::test]
|
||||
pub async fn bootstrap_token_rejected_after_bootstrap() {
|
||||
let db = db::create_test_pool().await;
|
||||
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
|
||||
let token = actors.bootstrapper.ask(GetToken).await.unwrap().unwrap();
|
||||
|
||||
actors
|
||||
.vault
|
||||
.ask(Bootstrap {
|
||||
seal_key: KeyCell::from([0u8; 32]),
|
||||
custody: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// `Bootstrapped` travels through the message bus, so the token disappears
|
||||
// a couple of actor turns after the bootstrap call returns.
|
||||
let mut retired = false;
|
||||
for _ in 0..100 {
|
||||
if actors.bootstrapper.ask(GetToken).await.unwrap().is_none() {
|
||||
retired = true;
|
||||
break;
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
assert!(
|
||||
retired,
|
||||
"the bootstrap token must be retired once the vault is bootstrapped"
|
||||
);
|
||||
|
||||
let (mut server_transport, mut test_transport) = ChannelTransport::new();
|
||||
let db_for_task = db.clone();
|
||||
let task = tokio::spawn(async move {
|
||||
let mut props = OperatorConnection::new(db_for_task, actors);
|
||||
auth::authenticate(&mut props, &mut server_transport).await
|
||||
});
|
||||
|
||||
let new_key = MlDsa87::key_gen(&mut rand::rng());
|
||||
test_transport
|
||||
.send(auth::Inbound::AuthChallengeRequest {
|
||||
pubkey: verifying_key(&new_key).into(),
|
||||
bootstrap_token: Some(token.into_bytes()),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let response = test_transport
|
||||
.recv()
|
||||
.await
|
||||
.expect("should receive challenge");
|
||||
let challenge = match response {
|
||||
Ok(auth::Outbound::AuthChallenge { challenge }) => challenge,
|
||||
other => panic!("Expected AuthChallenge, got {other:?}"),
|
||||
};
|
||||
|
||||
let signature = sign_operator_challenge(&new_key, &challenge);
|
||||
test_transport
|
||||
.send(auth::Inbound::AuthChallengeSolution {
|
||||
signature: signature.to_bytes(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let response = test_transport
|
||||
.recv()
|
||||
.await
|
||||
.expect("should receive auth result");
|
||||
assert!(
|
||||
matches!(response, Err(auth::Error::InvalidBootstrapToken)),
|
||||
"a spent bootstrap token must not authorise a new identity, got {response:?}"
|
||||
);
|
||||
assert!(task.await.unwrap().is_err(), "authentication must fail");
|
||||
|
||||
let mut conn = db.get().await.unwrap();
|
||||
let registered: i64 = schema::operator_identity::table
|
||||
.count()
|
||||
.get_result(&mut conn)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
registered, 0,
|
||||
"no identity may be registered after the bootstrap"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
use arbiter_crypto::{
|
||||
authn,
|
||||
safecell::{SafeCell, SafeCellHandle as _},
|
||||
};
|
||||
use arbiter_crypto::authn;
|
||||
use arbiter_server::{
|
||||
actors::{
|
||||
GlobalActors,
|
||||
vault::{Bootstrap, Seal},
|
||||
},
|
||||
db,
|
||||
crypto::KeyCell,
|
||||
db::{self, models::OperatorId},
|
||||
peers::operator::{
|
||||
Credentials,
|
||||
vault_gate::{
|
||||
@@ -22,7 +20,7 @@ use tokio::sync::oneshot;
|
||||
use x25519_dalek::{EphemeralSecret, PublicKey};
|
||||
|
||||
async fn setup_sealed_gate(
|
||||
seal_key: &[u8],
|
||||
seal_key: [u8; 32],
|
||||
) -> (
|
||||
db::DatabasePool,
|
||||
kameo::actor::ActorRef<VaultGate>,
|
||||
@@ -34,7 +32,8 @@ async fn setup_sealed_gate(
|
||||
actors
|
||||
.vault
|
||||
.ask(Bootstrap {
|
||||
seal_key_raw: SafeCell::new(seal_key.to_vec()),
|
||||
seal_key: KeyCell::from(seal_key),
|
||||
custody: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -42,7 +41,10 @@ async fn setup_sealed_gate(
|
||||
|
||||
let (promotion_tx, promotion_rx) = oneshot::channel();
|
||||
let pubkey = authn::SigningKey::generate().public_key();
|
||||
let auth_creds = Credentials { id: 1, pubkey };
|
||||
let auth_creds = Credentials {
|
||||
id: OperatorId::from_raw(1),
|
||||
pubkey,
|
||||
};
|
||||
let gate = VaultGate::spawn(VaultGate::new(auth_creds, actors, db.clone(), promotion_tx));
|
||||
|
||||
(db, gate, promotion_rx)
|
||||
@@ -83,10 +85,10 @@ async fn client_dh_encrypt(
|
||||
#[tokio::test]
|
||||
#[test_log::test]
|
||||
pub async fn unseal_success() {
|
||||
let seal_key = b"test-seal-key";
|
||||
let seal_key = [7u8; 32];
|
||||
let (_db, gate, _promotion_rx) = setup_sealed_gate(seal_key).await;
|
||||
|
||||
let encrypted_key = client_dh_encrypt(&gate, seal_key).await;
|
||||
let encrypted_key = client_dh_encrypt(&gate, &seal_key).await;
|
||||
|
||||
let response = gate.ask(encrypted_key).await;
|
||||
assert!(matches!(response, Ok(())));
|
||||
@@ -95,10 +97,10 @@ pub async fn unseal_success() {
|
||||
#[tokio::test]
|
||||
#[test_log::test]
|
||||
pub async fn unseal_wrong_seal_key() {
|
||||
let seal_key = b"test-seal-key";
|
||||
let seal_key = [7u8; 32];
|
||||
let (_db, gate, _promotion_rx) = setup_sealed_gate(seal_key).await;
|
||||
|
||||
let encrypted_key = client_dh_encrypt(&gate, b"wrong-key").await;
|
||||
let encrypted_key = client_dh_encrypt(&gate, &[8u8; 32]).await;
|
||||
|
||||
let response = gate.ask(encrypted_key).await;
|
||||
assert!(matches!(
|
||||
@@ -112,7 +114,7 @@ pub async fn unseal_wrong_seal_key() {
|
||||
#[tokio::test]
|
||||
#[test_log::test]
|
||||
pub async fn unseal_corrupted_ciphertext() {
|
||||
let seal_key = b"test-seal-key";
|
||||
let seal_key = [7u8; 32];
|
||||
let (_db, gate, _promotion_rx) = setup_sealed_gate(seal_key).await;
|
||||
|
||||
let client_secret = EphemeralSecret::random();
|
||||
@@ -143,11 +145,11 @@ pub async fn unseal_corrupted_ciphertext() {
|
||||
#[tokio::test]
|
||||
#[test_log::test]
|
||||
pub async fn unseal_retry_after_invalid_key() {
|
||||
let seal_key = b"real-seal-key";
|
||||
let seal_key = [9u8; 32];
|
||||
let (_db, gate, _promotion_rx) = setup_sealed_gate(seal_key).await;
|
||||
|
||||
{
|
||||
let encrypted_key = client_dh_encrypt(&gate, b"wrong-key").await;
|
||||
let encrypted_key = client_dh_encrypt(&gate, &[8u8; 32]).await;
|
||||
|
||||
let response = gate.ask(encrypted_key).await;
|
||||
assert!(matches!(
|
||||
@@ -159,7 +161,7 @@ pub async fn unseal_retry_after_invalid_key() {
|
||||
}
|
||||
|
||||
{
|
||||
let encrypted_key = client_dh_encrypt(&gate, seal_key).await;
|
||||
let encrypted_key = client_dh_encrypt(&gate, &seal_key).await;
|
||||
|
||||
let response = gate.ask(encrypted_key).await;
|
||||
assert!(matches!(response, Ok(())));
|
||||
|
||||
@@ -2,6 +2,8 @@ mod common;
|
||||
|
||||
#[path = "vault/concurrency.rs"]
|
||||
mod concurrency;
|
||||
#[path = "vault/custody.rs"]
|
||||
mod custody;
|
||||
#[path = "vault/lifecycle.rs"]
|
||||
mod lifecycle;
|
||||
#[path = "vault/storage.rs"]
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
317
server/crates/arbiter-server/tests/vault/custody.rs
Normal file
317
server/crates/arbiter-server/tests/vault/custody.rs
Normal 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");
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user