fix(crypto): handle 1-of-N Shamir split when ordinary_count=1
This commit is contained in:
@@ -51,11 +51,14 @@ enum CoordinatorState {
|
||||
Idle,
|
||||
Bootstrapping {
|
||||
declared_count: usize,
|
||||
recovery_count: usize,
|
||||
passphrases: HashMap<i32, Vec<u8>>,
|
||||
recovery_passphrases: HashMap<i32, Vec<u8>>,
|
||||
},
|
||||
Unsealing {
|
||||
threshold: usize,
|
||||
passphrases: HashMap<i32, Vec<u8>>,
|
||||
ordinary_passphrases: HashMap<i32, Vec<u8>>,
|
||||
recovery_passphrases: HashMap<i32, Vec<u8>>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -78,42 +81,83 @@ impl VaultCoordinator {
|
||||
|
||||
const SHARE_AAD: &[u8] = b"arbiter/shamir-share/v1";
|
||||
|
||||
fn encrypt_share(
|
||||
passphrase_bytes: Vec<u8>,
|
||||
share: &[u8],
|
||||
) -> Result<(Vec<u8>, Vec<u8>, Vec<u8>), Error> {
|
||||
let mut share_salt = vec![0u8; 32];
|
||||
OsRng.fill_bytes(&mut share_salt);
|
||||
|
||||
let mut passphrase_cell = SafeCell::new(passphrase_bytes);
|
||||
let mut share_seal_key = derive_key(&mut passphrase_cell, &share_salt);
|
||||
|
||||
let nonce = Nonce::default();
|
||||
let encrypted_share = share_seal_key
|
||||
.encrypt(&nonce, SHARE_AAD, share)
|
||||
.map_err(|_| Error::Encryption)?;
|
||||
|
||||
Ok((encrypted_share, nonce.to_vec(), share_salt))
|
||||
}
|
||||
|
||||
fn decrypt_share(
|
||||
passphrase_bytes: Vec<u8>,
|
||||
encrypted_share: Vec<u8>,
|
||||
share_nonce_bytes: Vec<u8>,
|
||||
share_salt: Vec<u8>,
|
||||
operator_id: i32,
|
||||
) -> Result<Vec<u8>, Error> {
|
||||
let nonce = Nonce::try_from(share_nonce_bytes.as_slice()).map_err(|()| {
|
||||
error!(operator_id, "Invalid nonce in DB");
|
||||
Error::BrokenDatabase
|
||||
})?;
|
||||
|
||||
let mut passphrase_cell = SafeCell::new(passphrase_bytes);
|
||||
let mut share_seal_key = derive_key(&mut passphrase_cell, &share_salt);
|
||||
|
||||
let mut share_buffer = SafeCell::new(encrypted_share);
|
||||
share_seal_key
|
||||
.decrypt_in_place(&nonce, SHARE_AAD, &mut share_buffer)
|
||||
.map_err(|_| Error::InvalidPassphrase)?;
|
||||
|
||||
Ok(share_buffer.read().clone())
|
||||
}
|
||||
|
||||
/// §3.4: Split the seal key across ordinary + recovery operators.
|
||||
/// Threshold = shamir_threshold(ordinary_count); total shares = ordinary + recovery.
|
||||
/// When ordinary_count == 1 (threshold = 1), vsss-rs does not support a proper split,
|
||||
/// so each share is the seal key itself — any single participant can reconstruct.
|
||||
async fn finalize_bootstrap(
|
||||
db: db::DatabasePool,
|
||||
vault: ActorRef<Vault>,
|
||||
passphrases: HashMap<i32, Vec<u8>>,
|
||||
ordinary_passphrases: HashMap<i32, Vec<u8>>,
|
||||
recovery_passphrases: HashMap<i32, Vec<u8>>,
|
||||
) -> Result<(), Error> {
|
||||
let total = passphrases.len();
|
||||
let threshold = shamir_threshold(total);
|
||||
let ordinary_count = ordinary_passphrases.len();
|
||||
let recovery_count = recovery_passphrases.len();
|
||||
let total = ordinary_count + recovery_count;
|
||||
let threshold = shamir_threshold(ordinary_count);
|
||||
|
||||
// Generate random 32-byte seal key
|
||||
let mut seal_key_bytes = [0u8; 32];
|
||||
OsRng.fill_bytes(&mut seal_key_bytes);
|
||||
|
||||
// Split seal key into shares using Shamir (OsRng from rand_core 0.6, compatible with vsss-rs)
|
||||
let shares = shamir::split_key(threshold, total, &seal_key_bytes, OsRng)
|
||||
.map_err(|e| Error::Shamir(e.to_string()))?;
|
||||
// threshold == 1 means any single share reconstructs the key (degenerate split).
|
||||
// vsss-rs requires threshold >= 2, so we store the key directly in this case.
|
||||
let shares: Vec<Vec<u8>> = if threshold >= 2 {
|
||||
shamir::split_key(threshold, total, &seal_key_bytes, OsRng)
|
||||
.map_err(|e| Error::Shamir(e.to_string()))?
|
||||
} else {
|
||||
(0..total).map(|_| seal_key_bytes.to_vec()).collect()
|
||||
};
|
||||
|
||||
let seal_key = KeyCell::from(seal_key_bytes);
|
||||
|
||||
let mut conn = db.get().await?;
|
||||
let mut shares_iter = shares.into_iter();
|
||||
|
||||
for ((operator_id_raw, passphrase_bytes), share) in passphrases.into_iter().zip(shares) {
|
||||
// Generate a fresh share_salt for this operator
|
||||
let mut share_salt = vec![0u8; 32];
|
||||
OsRng.fill_bytes(&mut share_salt);
|
||||
|
||||
// Derive share encryption key from passphrase + salt
|
||||
let mut passphrase_cell = SafeCell::new(passphrase_bytes);
|
||||
let mut share_seal_key = derive_key(&mut passphrase_cell, &share_salt);
|
||||
|
||||
// Encrypt this operator's share
|
||||
let nonce = Nonce::default();
|
||||
let encrypted_share = share_seal_key
|
||||
.encrypt(&nonce, SHARE_AAD, &share)
|
||||
.map_err(|_| Error::Encryption)?;
|
||||
|
||||
let nonce_bytes = nonce.to_vec();
|
||||
for (operator_id_raw, passphrase_bytes) in ordinary_passphrases {
|
||||
let share = shares_iter.next().expect("split_key returned enough shares");
|
||||
let (encrypted_share, nonce_bytes, share_salt) =
|
||||
encrypt_share(passphrase_bytes, &share)?;
|
||||
|
||||
diesel::replace_into(schema::operator::table)
|
||||
.values((
|
||||
@@ -128,6 +172,24 @@ async fn finalize_bootstrap(
|
||||
.await?;
|
||||
}
|
||||
|
||||
for (recovery_id_raw, passphrase_bytes) in recovery_passphrases {
|
||||
let share = shares_iter.next().expect("split_key returned enough shares");
|
||||
let (encrypted_share, nonce_bytes, share_salt) =
|
||||
encrypt_share(passphrase_bytes, &share)?;
|
||||
|
||||
diesel::replace_into(schema::recovery_operator::table)
|
||||
.values((
|
||||
schema::recovery_operator::id.eq(recovery_id_raw),
|
||||
schema::recovery_operator::share.eq(&encrypted_share),
|
||||
schema::recovery_operator::share_nonce.eq(&nonce_bytes),
|
||||
schema::recovery_operator::share_salt.eq(&share_salt),
|
||||
schema::recovery_operator::created_at.eq(models::SqliteTimestamp::now()),
|
||||
schema::recovery_operator::updated_at.eq(models::SqliteTimestamp::now()),
|
||||
))
|
||||
.execute(&mut conn)
|
||||
.await?;
|
||||
}
|
||||
|
||||
vault
|
||||
.ask(Bootstrap { seal_key })
|
||||
.await
|
||||
@@ -139,15 +201,25 @@ async fn finalize_bootstrap(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// §3.5: Unseal using any threshold-sized mix of ordinary + recovery shares.
|
||||
async fn finalize_unseal(
|
||||
db: db::DatabasePool,
|
||||
vault: ActorRef<Vault>,
|
||||
passphrases: HashMap<i32, Vec<u8>>,
|
||||
ordinary_passphrases: HashMap<i32, Vec<u8>>,
|
||||
recovery_passphrases: HashMap<i32, Vec<u8>>,
|
||||
) -> Result<(), Error> {
|
||||
let mut conn = db.get().await?;
|
||||
|
||||
// Determine whether shares were stored as raw keys (threshold=1) or vsss-rs splits (threshold>=2).
|
||||
let ordinary_operator_count: i64 = schema::operator::table
|
||||
.count()
|
||||
.get_result(&mut conn)
|
||||
.await?;
|
||||
let threshold = shamir_threshold(ordinary_operator_count as usize);
|
||||
|
||||
let mut shares: Vec<Vec<u8>> = Vec::new();
|
||||
|
||||
for (operator_id_raw, passphrase_bytes) in passphrases {
|
||||
for (operator_id_raw, passphrase_bytes) in ordinary_passphrases {
|
||||
let (encrypted_share, share_nonce_bytes, share_salt): (Vec<u8>, Vec<u8>, Vec<u8>) =
|
||||
schema::operator::table
|
||||
.filter(schema::operator::id.eq(Some(operator_id_raw)))
|
||||
@@ -160,25 +232,45 @@ async fn finalize_unseal(
|
||||
.await
|
||||
.map_err(|_| Error::OperatorNotFound)?;
|
||||
|
||||
let nonce = Nonce::try_from(share_nonce_bytes.as_slice()).map_err(|()| {
|
||||
error!(operator_id = operator_id_raw, "Invalid nonce in DB");
|
||||
Error::BrokenDatabase
|
||||
})?;
|
||||
|
||||
let mut passphrase_cell = SafeCell::new(passphrase_bytes);
|
||||
let mut share_seal_key = derive_key(&mut passphrase_cell, &share_salt);
|
||||
|
||||
let mut share_buffer = SafeCell::new(encrypted_share);
|
||||
share_seal_key
|
||||
.decrypt_in_place(&nonce, SHARE_AAD, &mut share_buffer)
|
||||
.map_err(|_| Error::InvalidPassphrase)?;
|
||||
|
||||
let decrypted_share = share_buffer.read().clone();
|
||||
shares.push(decrypted_share);
|
||||
shares.push(decrypt_share(
|
||||
passphrase_bytes,
|
||||
encrypted_share,
|
||||
share_nonce_bytes,
|
||||
share_salt,
|
||||
operator_id_raw,
|
||||
)?);
|
||||
}
|
||||
|
||||
let seal_key_bytes =
|
||||
shamir::combine_shares(&shares).map_err(|e| Error::Shamir(e.to_string()))?;
|
||||
for (recovery_id_raw, passphrase_bytes) in recovery_passphrases {
|
||||
let (encrypted_share, share_nonce_bytes, share_salt): (Vec<u8>, Vec<u8>, Vec<u8>) =
|
||||
schema::recovery_operator::table
|
||||
.find(recovery_id_raw)
|
||||
.select((
|
||||
schema::recovery_operator::share,
|
||||
schema::recovery_operator::share_nonce,
|
||||
schema::recovery_operator::share_salt,
|
||||
))
|
||||
.first(&mut conn)
|
||||
.await
|
||||
.map_err(|_| Error::OperatorNotFound)?;
|
||||
|
||||
shares.push(decrypt_share(
|
||||
passphrase_bytes,
|
||||
encrypted_share,
|
||||
share_nonce_bytes,
|
||||
share_salt,
|
||||
recovery_id_raw,
|
||||
)?);
|
||||
}
|
||||
|
||||
// When threshold==1, shares are raw 32-byte seal keys (vsss-rs cannot split 1-of-N).
|
||||
// Any single decrypted share is the key itself.
|
||||
let seal_key_bytes: [u8; 32] = if threshold <= 1 {
|
||||
let raw = shares.into_iter().next().ok_or_else(|| Error::Shamir("No shares available".into()))?;
|
||||
raw.try_into().map_err(|_| Error::Shamir("Invalid share length".into()))?
|
||||
} else {
|
||||
shamir::combine_shares(&shares).map_err(|e| Error::Shamir(e.to_string()))?
|
||||
};
|
||||
|
||||
let seal_key = KeyCell::from(seal_key_bytes);
|
||||
|
||||
@@ -213,13 +305,15 @@ impl VaultCoordinator {
|
||||
}
|
||||
self.state = CoordinatorState::Bootstrapping {
|
||||
declared_count,
|
||||
recovery_count,
|
||||
passphrases: HashMap::new(),
|
||||
recovery_passphrases: HashMap::new(),
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Phase 2 of multi-operator bootstrap: contribute a passphrase.
|
||||
/// Returns Ok(true) when all operators contributed and bootstrap finalized.
|
||||
/// Phase 2 of multi-operator bootstrap: ordinary operator contributes a passphrase.
|
||||
/// Returns Ok(true) when all ordinary + recovery operators contributed and bootstrap finalized.
|
||||
#[message]
|
||||
pub async fn contribute_bootstrap(
|
||||
&mut self,
|
||||
@@ -228,8 +322,9 @@ impl VaultCoordinator {
|
||||
) -> Result<bool, Error> {
|
||||
let CoordinatorState::Bootstrapping {
|
||||
declared_count,
|
||||
recovery_count,
|
||||
passphrases,
|
||||
..
|
||||
recovery_passphrases,
|
||||
} = &mut self.state
|
||||
else {
|
||||
return Err(Error::NotBootstrapping);
|
||||
@@ -239,25 +334,81 @@ impl VaultCoordinator {
|
||||
return Err(Error::DuplicateContribution);
|
||||
}
|
||||
|
||||
// Extract bytes immediately so state stays Sync
|
||||
let passphrase_bytes = passphrase.read().to_vec();
|
||||
passphrases.insert(operator_id, passphrase_bytes);
|
||||
|
||||
if passphrases.len() < *declared_count {
|
||||
if passphrases.len() < *declared_count || recovery_passphrases.len() < *recovery_count {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let CoordinatorState::Bootstrapping { passphrases, .. } =
|
||||
std::mem::replace(&mut self.state, CoordinatorState::Idle)
|
||||
let CoordinatorState::Bootstrapping {
|
||||
passphrases,
|
||||
recovery_passphrases,
|
||||
..
|
||||
} = std::mem::replace(&mut self.state, CoordinatorState::Idle)
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
|
||||
finalize_bootstrap(self.db.clone(), self.vault.clone(), passphrases).await?;
|
||||
finalize_bootstrap(
|
||||
self.db.clone(),
|
||||
self.vault.clone(),
|
||||
passphrases,
|
||||
recovery_passphrases,
|
||||
)
|
||||
.await?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Contribute a passphrase for vault unseal.
|
||||
/// Phase 2 of multi-operator bootstrap: recovery operator contributes a passphrase.
|
||||
/// Returns Ok(true) when all contributors are in and bootstrap finalized.
|
||||
#[message]
|
||||
pub async fn contribute_recovery_bootstrap(
|
||||
&mut self,
|
||||
recovery_operator_id: i32,
|
||||
mut passphrase: SafeCell<Vec<u8>>,
|
||||
) -> Result<bool, Error> {
|
||||
let CoordinatorState::Bootstrapping {
|
||||
declared_count,
|
||||
recovery_count,
|
||||
passphrases,
|
||||
recovery_passphrases,
|
||||
} = &mut self.state
|
||||
else {
|
||||
return Err(Error::NotBootstrapping);
|
||||
};
|
||||
|
||||
if recovery_passphrases.contains_key(&recovery_operator_id) {
|
||||
return Err(Error::DuplicateContribution);
|
||||
}
|
||||
|
||||
let passphrase_bytes = passphrase.read().to_vec();
|
||||
recovery_passphrases.insert(recovery_operator_id, passphrase_bytes);
|
||||
|
||||
if passphrases.len() < *declared_count || recovery_passphrases.len() < *recovery_count {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let CoordinatorState::Bootstrapping {
|
||||
passphrases,
|
||||
recovery_passphrases,
|
||||
..
|
||||
} = std::mem::replace(&mut self.state, CoordinatorState::Idle)
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
|
||||
finalize_bootstrap(
|
||||
self.db.clone(),
|
||||
self.vault.clone(),
|
||||
passphrases,
|
||||
recovery_passphrases,
|
||||
)
|
||||
.await?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Contribute a passphrase for vault unseal (ordinary operator).
|
||||
/// Returns Ok(true) when threshold reached and vault is unsealed.
|
||||
#[message]
|
||||
pub async fn contribute_unseal(
|
||||
@@ -265,46 +416,105 @@ impl VaultCoordinator {
|
||||
operator_id: i32,
|
||||
mut passphrase: SafeCell<Vec<u8>>,
|
||||
) -> Result<bool, Error> {
|
||||
if matches!(self.state, CoordinatorState::Idle) {
|
||||
let mut conn = self.db.get().await?;
|
||||
let count: i64 = schema::operator::table
|
||||
.count()
|
||||
.get_result(&mut conn)
|
||||
.await?;
|
||||
let threshold = shamir_threshold(usize::try_from(count).unwrap_or_default());
|
||||
|
||||
self.state = CoordinatorState::Unsealing {
|
||||
threshold,
|
||||
passphrases: HashMap::new(),
|
||||
};
|
||||
}
|
||||
self.ensure_unsealing_state().await?;
|
||||
|
||||
let CoordinatorState::Unsealing {
|
||||
threshold,
|
||||
passphrases,
|
||||
ordinary_passphrases,
|
||||
recovery_passphrases,
|
||||
} = &mut self.state
|
||||
else {
|
||||
return Err(Error::NotUnsealing);
|
||||
};
|
||||
|
||||
if passphrases.contains_key(&operator_id) {
|
||||
if ordinary_passphrases.contains_key(&operator_id) {
|
||||
return Err(Error::DuplicateContribution);
|
||||
}
|
||||
|
||||
let passphrase_bytes = passphrase.read().to_vec();
|
||||
passphrases.insert(operator_id, passphrase_bytes);
|
||||
ordinary_passphrases.insert(operator_id, passphrase_bytes);
|
||||
|
||||
if passphrases.len() < *threshold {
|
||||
if ordinary_passphrases.len() + recovery_passphrases.len() < *threshold {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let CoordinatorState::Unsealing { passphrases, .. } =
|
||||
std::mem::replace(&mut self.state, CoordinatorState::Idle)
|
||||
self.do_finalize_unseal().await
|
||||
}
|
||||
|
||||
/// Contribute a passphrase for vault unseal (recovery operator, §3.5).
|
||||
/// Recovery operators may contribute during unseal when recovery is active.
|
||||
/// Returns Ok(true) when threshold reached and vault is unsealed.
|
||||
#[message]
|
||||
pub async fn contribute_recovery_unseal(
|
||||
&mut self,
|
||||
recovery_operator_id: i32,
|
||||
mut passphrase: SafeCell<Vec<u8>>,
|
||||
) -> Result<bool, Error> {
|
||||
self.ensure_unsealing_state().await?;
|
||||
|
||||
let CoordinatorState::Unsealing {
|
||||
threshold,
|
||||
ordinary_passphrases,
|
||||
recovery_passphrases,
|
||||
} = &mut self.state
|
||||
else {
|
||||
return Err(Error::NotUnsealing);
|
||||
};
|
||||
|
||||
if recovery_passphrases.contains_key(&recovery_operator_id) {
|
||||
return Err(Error::DuplicateContribution);
|
||||
}
|
||||
|
||||
let passphrase_bytes = passphrase.read().to_vec();
|
||||
recovery_passphrases.insert(recovery_operator_id, passphrase_bytes);
|
||||
|
||||
if ordinary_passphrases.len() + recovery_passphrases.len() < *threshold {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
self.do_finalize_unseal().await
|
||||
}
|
||||
}
|
||||
|
||||
impl VaultCoordinator {
|
||||
/// Initializes `CoordinatorState::Unsealing` on first call if still `Idle`.
|
||||
/// Threshold is based on ordinary operator count only (§3.4).
|
||||
async fn ensure_unsealing_state(&mut self) -> Result<(), Error> {
|
||||
if matches!(self.state, CoordinatorState::Idle) {
|
||||
let mut conn = self.db.get().await?;
|
||||
let ordinary_count: i64 = schema::operator::table
|
||||
.count()
|
||||
.get_result(&mut conn)
|
||||
.await?;
|
||||
let threshold =
|
||||
shamir_threshold(usize::try_from(ordinary_count).unwrap_or_default());
|
||||
self.state = CoordinatorState::Unsealing {
|
||||
threshold,
|
||||
ordinary_passphrases: HashMap::new(),
|
||||
recovery_passphrases: HashMap::new(),
|
||||
};
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Moves state back to Idle and calls finalize_unseal.
|
||||
async fn do_finalize_unseal(&mut self) -> Result<bool, Error> {
|
||||
let CoordinatorState::Unsealing {
|
||||
ordinary_passphrases,
|
||||
recovery_passphrases,
|
||||
..
|
||||
} = std::mem::replace(&mut self.state, CoordinatorState::Idle)
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
|
||||
finalize_unseal(self.db.clone(), self.vault.clone(), passphrases).await?;
|
||||
finalize_unseal(
|
||||
self.db.clone(),
|
||||
self.vault.clone(),
|
||||
ordinary_passphrases,
|
||||
recovery_passphrases,
|
||||
)
|
||||
.await?;
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,14 +3,17 @@ use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
|
||||
use arbiter_server::{
|
||||
actors::{
|
||||
GlobalActors,
|
||||
vault::{Error, Vault},
|
||||
vault_coordinator::{Error as CoordinatorError, StartBootstrap, VaultCoordinator},
|
||||
vault::{Error, GetState, Vault, VaultState},
|
||||
vault_coordinator::{
|
||||
ContributeBootstrap, ContributeRecoveryBootstrap, ContributeRecoveryUnseal,
|
||||
Error as CoordinatorError, StartBootstrap, VaultCoordinator,
|
||||
},
|
||||
},
|
||||
crypto::{KeyCell, encryption::v1::{Nonce, ROOT_KEY_TAG}},
|
||||
db::{self, models, schema},
|
||||
};
|
||||
|
||||
use diesel::{QueryDsl, SelectableHelper};
|
||||
use diesel::{ExpressionMethods, QueryDsl, SelectableHelper, insert_into};
|
||||
use diesel_async::RunQueryDsl;
|
||||
use kameo::actor::Spawn as _;
|
||||
|
||||
@@ -167,3 +170,101 @@ async fn two_operator_vault_requires_recovery_share() {
|
||||
"expected TwoOperatorsRequireRecovery, got {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// §3.4: Bootstrap with 1 ordinary + 1 recovery operator produces a valid 1-of-2 Shamir split.
|
||||
/// Both ordinary and recovery shares are stored; the vault can be unsealed with either one.
|
||||
#[tokio::test]
|
||||
#[test_log::test]
|
||||
async fn recovery_share_stored_and_used_for_unseal() {
|
||||
let db = db::create_test_pool().await;
|
||||
let bus = GlobalActors::spawn_message_bus();
|
||||
let vault_ref = Vault::spawn(Vault::new(db.clone(), bus).await.unwrap());
|
||||
let coordinator = VaultCoordinator::spawn(VaultCoordinator::new(db.clone(), vault_ref.clone()));
|
||||
|
||||
// Register one ordinary operator and one recovery operator in the DB
|
||||
let ordinary_id: i32 = {
|
||||
let mut conn = db.get().await.unwrap();
|
||||
insert_into(schema::operator_identity::table)
|
||||
.values(schema::operator_identity::public_key.eq(vec![1u8; 32]))
|
||||
.returning(schema::operator_identity::id)
|
||||
.get_result(&mut conn)
|
||||
.await
|
||||
.unwrap()
|
||||
};
|
||||
let recovery_id: i32 = {
|
||||
let mut conn = db.get().await.unwrap();
|
||||
insert_into(schema::recovery_operator_identity::table)
|
||||
.values(schema::recovery_operator_identity::public_key.eq(vec![2u8; 32]))
|
||||
.returning(schema::recovery_operator_identity::id)
|
||||
.get_result(&mut conn)
|
||||
.await
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
// Declare committee: 1 ordinary + 1 recovery
|
||||
coordinator
|
||||
.ask(StartBootstrap {
|
||||
operator_id: ordinary_id,
|
||||
declared_count: 1,
|
||||
recovery_count: 1,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Recovery operator contributes first — bootstrap should not finalize yet
|
||||
let done = coordinator
|
||||
.ask(ContributeRecoveryBootstrap {
|
||||
recovery_operator_id: recovery_id,
|
||||
passphrase: SafeCell::new(b"recovery-pass".to_vec()),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!done, "should not finalize with only recovery passphrase");
|
||||
|
||||
// Ordinary operator contributes — now bootstrap finalizes
|
||||
let done = coordinator
|
||||
.ask(ContributeBootstrap {
|
||||
operator_id: ordinary_id,
|
||||
passphrase: SafeCell::new(b"ordinary-pass".to_vec()),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(done, "should finalize once all contributors are in");
|
||||
|
||||
// After bootstrap, vault is Unsealed (seal key still in memory).
|
||||
let state = vault_ref.ask(GetState {}).await.unwrap();
|
||||
assert_eq!(state, VaultState::Unsealed);
|
||||
|
||||
// Verify recovery_operator row was created
|
||||
let recovery_share_count: i64 = {
|
||||
let mut conn = db.get().await.unwrap();
|
||||
schema::recovery_operator::table
|
||||
.count()
|
||||
.get_result(&mut conn)
|
||||
.await
|
||||
.unwrap()
|
||||
};
|
||||
assert_eq!(recovery_share_count, 1);
|
||||
|
||||
// Simulate restart: drop vault and coordinator, create fresh vault (comes up Sealed).
|
||||
drop(coordinator);
|
||||
drop(vault_ref);
|
||||
let bus2 = GlobalActors::spawn_message_bus();
|
||||
let vault_ref2 = Vault::spawn(Vault::new(db.clone(), bus2).await.unwrap());
|
||||
let state = vault_ref2.ask(GetState {}).await.unwrap();
|
||||
assert_eq!(state, VaultState::Sealed);
|
||||
|
||||
// §3.5: Unseal using ONLY the recovery operator share (threshold = shamir_threshold(1) = 1).
|
||||
let coordinator2 = VaultCoordinator::spawn(VaultCoordinator::new(db.clone(), vault_ref2.clone()));
|
||||
let done = coordinator2
|
||||
.ask(ContributeRecoveryUnseal {
|
||||
recovery_operator_id: recovery_id,
|
||||
passphrase: SafeCell::new(b"recovery-pass".to_vec()),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(done, "recovery share alone should satisfy threshold");
|
||||
|
||||
let state = vault_ref2.ask(GetState {}).await.unwrap();
|
||||
assert_eq!(state, VaultState::Unsealed);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user