fix(crypto): handle 1-of-N Shamir split when ordinary_count=1
This commit is contained in:
@@ -51,11 +51,14 @@ enum CoordinatorState {
|
|||||||
Idle,
|
Idle,
|
||||||
Bootstrapping {
|
Bootstrapping {
|
||||||
declared_count: usize,
|
declared_count: usize,
|
||||||
|
recovery_count: usize,
|
||||||
passphrases: HashMap<i32, Vec<u8>>,
|
passphrases: HashMap<i32, Vec<u8>>,
|
||||||
|
recovery_passphrases: HashMap<i32, Vec<u8>>,
|
||||||
},
|
},
|
||||||
Unsealing {
|
Unsealing {
|
||||||
threshold: usize,
|
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";
|
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(
|
async fn finalize_bootstrap(
|
||||||
db: db::DatabasePool,
|
db: db::DatabasePool,
|
||||||
vault: ActorRef<Vault>,
|
vault: ActorRef<Vault>,
|
||||||
passphrases: HashMap<i32, Vec<u8>>,
|
ordinary_passphrases: HashMap<i32, Vec<u8>>,
|
||||||
|
recovery_passphrases: HashMap<i32, Vec<u8>>,
|
||||||
) -> Result<(), Error> {
|
) -> Result<(), Error> {
|
||||||
let total = passphrases.len();
|
let ordinary_count = ordinary_passphrases.len();
|
||||||
let threshold = shamir_threshold(total);
|
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];
|
let mut seal_key_bytes = [0u8; 32];
|
||||||
OsRng.fill_bytes(&mut seal_key_bytes);
|
OsRng.fill_bytes(&mut seal_key_bytes);
|
||||||
|
|
||||||
// Split seal key into shares using Shamir (OsRng from rand_core 0.6, compatible with vsss-rs)
|
// threshold == 1 means any single share reconstructs the key (degenerate split).
|
||||||
let shares = shamir::split_key(threshold, total, &seal_key_bytes, OsRng)
|
// vsss-rs requires threshold >= 2, so we store the key directly in this case.
|
||||||
.map_err(|e| Error::Shamir(e.to_string()))?;
|
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 seal_key = KeyCell::from(seal_key_bytes);
|
||||||
|
|
||||||
let mut conn = db.get().await?;
|
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) {
|
for (operator_id_raw, passphrase_bytes) in ordinary_passphrases {
|
||||||
// Generate a fresh share_salt for this operator
|
let share = shares_iter.next().expect("split_key returned enough shares");
|
||||||
let mut share_salt = vec![0u8; 32];
|
let (encrypted_share, nonce_bytes, share_salt) =
|
||||||
OsRng.fill_bytes(&mut share_salt);
|
encrypt_share(passphrase_bytes, &share)?;
|
||||||
|
|
||||||
// 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();
|
|
||||||
|
|
||||||
diesel::replace_into(schema::operator::table)
|
diesel::replace_into(schema::operator::table)
|
||||||
.values((
|
.values((
|
||||||
@@ -128,6 +172,24 @@ async fn finalize_bootstrap(
|
|||||||
.await?;
|
.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
|
vault
|
||||||
.ask(Bootstrap { seal_key })
|
.ask(Bootstrap { seal_key })
|
||||||
.await
|
.await
|
||||||
@@ -139,15 +201,25 @@ async fn finalize_bootstrap(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// §3.5: Unseal using any threshold-sized mix of ordinary + recovery shares.
|
||||||
async fn finalize_unseal(
|
async fn finalize_unseal(
|
||||||
db: db::DatabasePool,
|
db: db::DatabasePool,
|
||||||
vault: ActorRef<Vault>,
|
vault: ActorRef<Vault>,
|
||||||
passphrases: HashMap<i32, Vec<u8>>,
|
ordinary_passphrases: HashMap<i32, Vec<u8>>,
|
||||||
|
recovery_passphrases: HashMap<i32, Vec<u8>>,
|
||||||
) -> Result<(), Error> {
|
) -> Result<(), Error> {
|
||||||
let mut conn = db.get().await?;
|
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();
|
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>) =
|
let (encrypted_share, share_nonce_bytes, share_salt): (Vec<u8>, Vec<u8>, Vec<u8>) =
|
||||||
schema::operator::table
|
schema::operator::table
|
||||||
.filter(schema::operator::id.eq(Some(operator_id_raw)))
|
.filter(schema::operator::id.eq(Some(operator_id_raw)))
|
||||||
@@ -160,25 +232,45 @@ async fn finalize_unseal(
|
|||||||
.await
|
.await
|
||||||
.map_err(|_| Error::OperatorNotFound)?;
|
.map_err(|_| Error::OperatorNotFound)?;
|
||||||
|
|
||||||
let nonce = Nonce::try_from(share_nonce_bytes.as_slice()).map_err(|()| {
|
shares.push(decrypt_share(
|
||||||
error!(operator_id = operator_id_raw, "Invalid nonce in DB");
|
passphrase_bytes,
|
||||||
Error::BrokenDatabase
|
encrypted_share,
|
||||||
})?;
|
share_nonce_bytes,
|
||||||
|
share_salt,
|
||||||
let mut passphrase_cell = SafeCell::new(passphrase_bytes);
|
operator_id_raw,
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let seal_key_bytes =
|
for (recovery_id_raw, passphrase_bytes) in recovery_passphrases {
|
||||||
shamir::combine_shares(&shares).map_err(|e| Error::Shamir(e.to_string()))?;
|
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);
|
let seal_key = KeyCell::from(seal_key_bytes);
|
||||||
|
|
||||||
@@ -213,13 +305,15 @@ impl VaultCoordinator {
|
|||||||
}
|
}
|
||||||
self.state = CoordinatorState::Bootstrapping {
|
self.state = CoordinatorState::Bootstrapping {
|
||||||
declared_count,
|
declared_count,
|
||||||
|
recovery_count,
|
||||||
passphrases: HashMap::new(),
|
passphrases: HashMap::new(),
|
||||||
|
recovery_passphrases: HashMap::new(),
|
||||||
};
|
};
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Phase 2 of multi-operator bootstrap: contribute a passphrase.
|
/// Phase 2 of multi-operator bootstrap: ordinary operator contributes a passphrase.
|
||||||
/// Returns Ok(true) when all operators contributed and bootstrap finalized.
|
/// Returns Ok(true) when all ordinary + recovery operators contributed and bootstrap finalized.
|
||||||
#[message]
|
#[message]
|
||||||
pub async fn contribute_bootstrap(
|
pub async fn contribute_bootstrap(
|
||||||
&mut self,
|
&mut self,
|
||||||
@@ -228,8 +322,9 @@ impl VaultCoordinator {
|
|||||||
) -> Result<bool, Error> {
|
) -> Result<bool, Error> {
|
||||||
let CoordinatorState::Bootstrapping {
|
let CoordinatorState::Bootstrapping {
|
||||||
declared_count,
|
declared_count,
|
||||||
|
recovery_count,
|
||||||
passphrases,
|
passphrases,
|
||||||
..
|
recovery_passphrases,
|
||||||
} = &mut self.state
|
} = &mut self.state
|
||||||
else {
|
else {
|
||||||
return Err(Error::NotBootstrapping);
|
return Err(Error::NotBootstrapping);
|
||||||
@@ -239,25 +334,81 @@ impl VaultCoordinator {
|
|||||||
return Err(Error::DuplicateContribution);
|
return Err(Error::DuplicateContribution);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract bytes immediately so state stays Sync
|
|
||||||
let passphrase_bytes = passphrase.read().to_vec();
|
let passphrase_bytes = passphrase.read().to_vec();
|
||||||
passphrases.insert(operator_id, passphrase_bytes);
|
passphrases.insert(operator_id, passphrase_bytes);
|
||||||
|
|
||||||
if passphrases.len() < *declared_count {
|
if passphrases.len() < *declared_count || recovery_passphrases.len() < *recovery_count {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
let CoordinatorState::Bootstrapping { passphrases, .. } =
|
let CoordinatorState::Bootstrapping {
|
||||||
std::mem::replace(&mut self.state, CoordinatorState::Idle)
|
passphrases,
|
||||||
|
recovery_passphrases,
|
||||||
|
..
|
||||||
|
} = std::mem::replace(&mut self.state, CoordinatorState::Idle)
|
||||||
else {
|
else {
|
||||||
unreachable!()
|
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)
|
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.
|
/// Returns Ok(true) when threshold reached and vault is unsealed.
|
||||||
#[message]
|
#[message]
|
||||||
pub async fn contribute_unseal(
|
pub async fn contribute_unseal(
|
||||||
@@ -265,46 +416,105 @@ impl VaultCoordinator {
|
|||||||
operator_id: i32,
|
operator_id: i32,
|
||||||
mut passphrase: SafeCell<Vec<u8>>,
|
mut passphrase: SafeCell<Vec<u8>>,
|
||||||
) -> Result<bool, Error> {
|
) -> Result<bool, Error> {
|
||||||
if matches!(self.state, CoordinatorState::Idle) {
|
self.ensure_unsealing_state().await?;
|
||||||
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(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let CoordinatorState::Unsealing {
|
let CoordinatorState::Unsealing {
|
||||||
threshold,
|
threshold,
|
||||||
passphrases,
|
ordinary_passphrases,
|
||||||
|
recovery_passphrases,
|
||||||
} = &mut self.state
|
} = &mut self.state
|
||||||
else {
|
else {
|
||||||
return Err(Error::NotUnsealing);
|
return Err(Error::NotUnsealing);
|
||||||
};
|
};
|
||||||
|
|
||||||
if passphrases.contains_key(&operator_id) {
|
if ordinary_passphrases.contains_key(&operator_id) {
|
||||||
return Err(Error::DuplicateContribution);
|
return Err(Error::DuplicateContribution);
|
||||||
}
|
}
|
||||||
|
|
||||||
let passphrase_bytes = passphrase.read().to_vec();
|
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);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
let CoordinatorState::Unsealing { passphrases, .. } =
|
self.do_finalize_unseal().await
|
||||||
std::mem::replace(&mut self.state, CoordinatorState::Idle)
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
else {
|
||||||
unreachable!()
|
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)
|
Ok(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,14 +3,17 @@ use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
|
|||||||
use arbiter_server::{
|
use arbiter_server::{
|
||||||
actors::{
|
actors::{
|
||||||
GlobalActors,
|
GlobalActors,
|
||||||
vault::{Error, Vault},
|
vault::{Error, GetState, Vault, VaultState},
|
||||||
vault_coordinator::{Error as CoordinatorError, StartBootstrap, VaultCoordinator},
|
vault_coordinator::{
|
||||||
|
ContributeBootstrap, ContributeRecoveryBootstrap, ContributeRecoveryUnseal,
|
||||||
|
Error as CoordinatorError, StartBootstrap, VaultCoordinator,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
crypto::{KeyCell, encryption::v1::{Nonce, ROOT_KEY_TAG}},
|
crypto::{KeyCell, encryption::v1::{Nonce, ROOT_KEY_TAG}},
|
||||||
db::{self, models, schema},
|
db::{self, models, schema},
|
||||||
};
|
};
|
||||||
|
|
||||||
use diesel::{QueryDsl, SelectableHelper};
|
use diesel::{ExpressionMethods, QueryDsl, SelectableHelper, insert_into};
|
||||||
use diesel_async::RunQueryDsl;
|
use diesel_async::RunQueryDsl;
|
||||||
use kameo::actor::Spawn as _;
|
use kameo::actor::Spawn as _;
|
||||||
|
|
||||||
@@ -167,3 +170,101 @@ async fn two_operator_vault_requires_recovery_share() {
|
|||||||
"expected TwoOperatorsRequireRecovery, got {err:?}"
|
"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