fix(vault): read the unseal threshold from the recorded split parameters

This commit is contained in:
CleverWild
2026-09-07 16:22:33 +02:00
parent e10cc762d6
commit e80dc53b0e
4 changed files with 152 additions and 15 deletions

View File

@@ -142,6 +142,36 @@ fn decrypt_share(
Ok(share_buffer.read().clone())
}
/// Records the threshold of the split that produced the stored shares.
async fn store_threshold(conn: &mut db::DatabaseConnection, threshold: usize) -> Result<(), Error> {
// A threshold that doesn't fit in the column is a bug, not a real empty-committee refusal.
let threshold = i32::try_from(threshold).map_err(|_| Error::BrokenDatabase)?;
let rows_updated = diesel::update(schema::arbiter_settings::table)
.set(schema::arbiter_settings::shamir_threshold.eq(Some(threshold)))
.execute(conn)
.await?;
// The singleton row always exists (up.sql seeds it), so anything else means the update did
// not land -- bootstrap would then report success while the threshold stays NULL forever.
if rows_updated != 1 {
return Err(Error::BrokenDatabase);
}
Ok(())
}
/// Reads back the threshold recorded at bootstrap or re-key time.
async fn load_threshold(conn: &mut db::DatabaseConnection) -> Result<usize, Error> {
let stored: Option<i32> = schema::arbiter_settings::table
.select(schema::arbiter_settings::shamir_threshold)
.first(conn)
.await?;
// A missing or out-of-range value here means the recorded threshold is corrupt, not that the
// committee is genuinely empty -- `EmptyCommittee` is reserved for the real domain refusal.
stored
.and_then(|threshold| usize::try_from(threshold).ok())
.ok_or(Error::BrokenDatabase)
}
/// §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,
@@ -212,6 +242,8 @@ async fn finalize_bootstrap(
.await?;
}
store_threshold(&mut conn, threshold).await?;
vault.ask(Bootstrap { seal_key }).await.map_err(|err| {
error!(?err, "Vault bootstrap failed");
Error::VaultError
@@ -230,12 +262,7 @@ async fn finalize_unseal(
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).ok_or(Error::EmptyCommittee)?;
let threshold = load_threshold(&mut conn).await?;
let mut shares: Vec<Vec<u8>> = Vec::new();
@@ -372,6 +399,8 @@ async fn finalize_rekey(
.await?;
}
store_threshold(&mut conn, threshold).await?;
drop(conn);
let new_seal_key = KeyCell::from(new_seal_key_bytes);
@@ -582,16 +611,12 @@ impl VaultCoordinator {
impl VaultCoordinator {
/// Initializes `CoordinatorState::Unsealing` on first call if still `Idle`.
/// Threshold is based on ordinary operator count only (§3.4).
/// Threshold comes from the recorded split parameters (§3.4), not from a live row count.
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())
.ok_or(Error::EmptyCommittee)?;
let threshold = load_threshold(&mut conn).await?;
drop(conn);
self.state = CoordinatorState::Unsealing {
threshold,
ordinary_passphrases: HashMap::new(),

View File

@@ -17,6 +17,7 @@ diesel::table! {
id -> Integer,
root_key_id -> Nullable<Integer>,
tls_id -> Nullable<Integer>,
shamir_threshold -> Nullable<Integer>,
}
}