fix(vault): apply Shamir custody review feedback

This commit is contained in:
CleverWild
2026-09-11 14:13:08 +02:00
parent c722712166
commit d49c39130a
4 changed files with 91 additions and 94 deletions

View File

@@ -78,7 +78,7 @@ pub_underscore_fields = "allow"
redundant_pub_crate = "allow"
uninhabited_references = "allow" # safe with unsafe_code = "forbid" and standard uninhabited pattern (match *self {})
too-many-lines = "allow" # this is a very common pattern in server code, and it's not always possible to break it down into smaller modules without hurting readability
unused_async_trait_impl = "allow" # to pedantic
unused_async_trait_impl = "allow" # too pedantic
# restriction lints
alloc_instead_of_core = "warn"

View File

@@ -76,6 +76,9 @@ pub enum Error {
/// declared, so the token stays valid across several registrations and is
/// retired by the `Bootstrapped` event rather than by first use, whichever
/// bootstrap path fired it.
///
/// Every daemon start mints a fresh token and overwrites the file: a token
/// handed out by an earlier run is dead.
pub struct Bootstrapper {
token: Option<SafeCell<[u8; TOKEN_LENGTH]>>,
token_path: Option<PathBuf>,
@@ -116,41 +119,13 @@ impl Bootstrapper {
});
}
let registered = diesel::select(diesel::dsl::exists(
schema::operator_identity::table.select(schema::operator_identity::id),
))
.get_result::<bool>(&mut conn)
.await?;
let token = if registered {
match tokio::fs::read_to_string(&path).await {
Ok(existing)
if existing.len() == TOKEN_LENGTH
&& existing.chars().all(|c| c.is_ascii_alphanumeric()) =>
{
let mut cell = SafeCell::new([0u8; TOKEN_LENGTH]);
cell.write().copy_from_slice(existing.as_bytes());
cell
}
Ok(_) | Err(_) => generate_token(&path).await?,
}
} else {
generate_token(&path).await?
};
Ok(Self {
token: Some(token),
token: Some(generate_token(&path).await?),
token_path: Some(path),
events,
})
}
fn is_correct_token(&mut self, token: &[u8]) -> bool {
self.token.as_mut().is_some_and(|expected| {
expected.read_inline(|bytes| bool::from(bytes.as_ref().ct_eq(token)))
})
}
async fn forget(&mut self) {
self.token = None;
if let Some(path) = self.token_path.take() {
@@ -175,7 +150,9 @@ impl Message<events::Bootstrapped> for Bootstrapper {
impl Bootstrapper {
#[message]
pub fn verify_token(&mut self, token: Vec<u8>) -> bool {
self.is_correct_token(&token)
self.token.as_mut().is_some_and(|expected| {
expected.read_inline(|bytes| bool::from(bytes.as_ref().ct_eq(token.as_slice())))
})
}
#[message]

View File

@@ -192,12 +192,14 @@ async fn finalize_bootstrap(
let mut shares = shamir::split_key(threshold, total, &mut seal_key, UnwrapErr(SysRng))
.map_err(|error| Error::Shamir(error.to_string()))?;
if shares.len() < total {
return Err(Error::Shamir("missing share for operator".to_owned()));
}
let mut encrypted = Vec::with_capacity(total);
for (index, (operator_id, passphrase)) in contributions.0.iter_mut().enumerate() {
let share = shares
.read_inline(|shares| shares.get(index).cloned())
.ok_or_else(|| Error::Shamir("missing share for operator".to_owned()))?;
encrypted.push((*operator_id, encrypt_share(passphrase, &share)?));
for ((operator_id, passphrase), share) in contributions.0.iter_mut().zip(shares.iter_mut()) {
let share = share.read_inline(|share| encrypt_share(passphrase, share))?;
encrypted.push((*operator_id, share));
}
vault
@@ -227,12 +229,9 @@ async fn finalize_unseal(
.await?
};
let mut plaintext = SafeCell::new(Vec::with_capacity(stored.len()));
let mut plaintext = Vec::with_capacity(stored.len());
for ((_, passphrase), share) in contributions.0.iter_mut().zip(stored) {
let mut decrypted = decrypt_share(passphrase, share)?;
decrypted.read_inline(|share| {
plaintext.write_inline(|shares| shares.push(share.clone()));
});
plaintext.push(decrypt_share(passphrase, share)?);
}
let seal_key = shamir::combine_shares(threshold, &mut plaintext)

View File

@@ -16,10 +16,13 @@ pub enum ShamirError {
Combine(String),
}
/// Return the required majority threshold for an ordinary operator committee.
/// Return the required threshold for a Shamir share pool of `committee_size`.
///
/// Committees of two are rejected: a majority of two is two, which gives each
/// member a veto over every unseal without giving either one recovery.
/// A pool of two is rejected: a majority of two is two, which gives each holder
/// a veto over every unseal without giving either one recovery. That rejects no
/// supported committee, because a two-operator vault must carry at least one
/// recovery share and so never splits into a pool of two -- see
/// `docs/ARCHITECTURE.md` 3.9.
#[expect(
clippy::integer_division,
reason = "majority thresholds use integer arithmetic"
@@ -40,7 +43,7 @@ pub fn split_key(
total: usize,
key: &mut KeyCell,
rng: impl CryptoRng,
) -> Result<SafeCell<Vec<Vec<u8>>>, ShamirError> {
) -> Result<Vec<SafeCell<Vec<u8>>>, ShamirError> {
if total == 0 || threshold == 0 || threshold > total || total == 2 || total > MAX_COMMITTEE_SIZE
{
return Err(ShamirError::Split(
@@ -48,20 +51,23 @@ pub fn split_key(
));
}
// Nothing to interpolate when one share suffices.
if threshold == 1 {
return Ok(key.0.read_inline(|key| {
std::iter::repeat_with(|| SafeCell::new(key.as_slice().to_vec()))
.take(total)
.collect()
}));
}
key.0.read_inline(|key| {
let key: &[u8; 32] = key
.as_slice()
.try_into()
.map_err(|_| ShamirError::Split("unexpected seal key length".to_owned()))?;
if threshold == 1 {
return Ok(SafeCell::new(
std::iter::repeat_n(key.to_vec(), total).collect(),
));
}
Gf256::split_array(threshold, total, key, rng)
.map(SafeCell::new)
.map(|shares| shares.into_iter().map(SafeCell::new).collect())
.map_err(|error| ShamirError::Split(format!("{error:?}")))
})
}
@@ -74,30 +80,43 @@ pub fn split_key(
/// sized.
pub fn combine_shares(
threshold: usize,
shares: &mut SafeCell<Vec<Vec<u8>>>,
shares: &mut [SafeCell<Vec<u8>>],
) -> Result<KeyCell, ShamirError> {
if threshold == 0 {
return Err(ShamirError::Combine("threshold is zero".to_owned()));
}
if shares.read().len() < threshold {
if shares.len() < threshold {
return Err(ShamirError::Combine(
"not enough shares supplied".to_owned(),
));
}
let combined = shares.read_inline(|shares| {
// Mirror of the one-of-one case in [`split_key`]: the share is the key.
if threshold == 1 {
let share = shares
.first()
.first_mut()
.ok_or_else(|| ShamirError::Combine("no shares supplied".to_owned()))?;
return Ok(SafeCell::new(share.clone()));
return reconstructed_key(share.read_inline(|share| SafeCell::new(share.clone())));
}
Gf256::combine_array(shares)
let mut gathered = SafeCell::new(Vec::with_capacity(shares.len()));
for share in shares.iter_mut() {
share.read_inline(|share| {
gathered.write_inline(|gathered| gathered.push(share.clone()));
});
}
let combined = gathered.read_inline(|gathered| {
Gf256::combine_array(gathered.as_slice())
.map(SafeCell::new)
.map_err(|error| ShamirError::Combine(format!("{error:?}")))
})?;
KeyCell::try_from(combined)
reconstructed_key(combined)
}
fn reconstructed_key(bytes: SafeCell<Vec<u8>>) -> Result<KeyCell, ShamirError> {
KeyCell::try_from(bytes)
.map_err(|()| ShamirError::Combine("unexpected reconstructed key length".to_owned()))
}
@@ -108,6 +127,7 @@ mod tests {
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use rand::rngs::SysRng;
use rand_core::UnwrapErr;
use rstest::rstest;
fn key_bytes(mut key: KeyCell) -> [u8; 32] {
key.0.read_inline(|key| {
@@ -117,29 +137,30 @@ mod tests {
})
}
fn select(shares: &mut SafeCell<Vec<Vec<u8>>>, indexes: &[usize]) -> SafeCell<Vec<Vec<u8>>> {
shares.read_inline(|shares| {
SafeCell::new(
fn select(shares: &mut [SafeCell<Vec<u8>>], indexes: &[usize]) -> Vec<SafeCell<Vec<u8>>> {
indexes
.iter()
.filter_map(|index| shares.get(*index).cloned())
.collect(),
)
.filter_map(|index| {
shares
.get_mut(*index)
.map(|share| share.read_inline(|share| SafeCell::new(share.clone())))
})
.collect()
}
#[test]
fn threshold_shares_reconstruct_fixed_key() {
#[rstest]
#[case(&[0, 1])]
#[case(&[0, 2])]
#[case(&[1, 2])]
fn threshold_shares_reconstruct_fixed_key(#[case] indexes: &[usize]) {
let expected = [9_u8; 32];
let mut key = KeyCell::from(expected);
let rng = UnwrapErr(SysRng);
let mut shares = split_key(2, 3, &mut key, rng).expect("split should succeed");
for indexes in [[0_usize, 1_usize], [0, 2], [1, 2]] {
let mut selected = select(&mut shares, &indexes);
let mut selected = select(&mut shares, indexes);
let combined = combine_shares(2, &mut selected).expect("combine should succeed");
assert_eq!(key_bytes(combined), expected);
}
}
#[test]
fn one_of_one_round_trips_a_fixed_size_key() {
@@ -163,22 +184,23 @@ mod tests {
);
}
#[test]
fn empty_committee_has_no_threshold() {
assert_eq!(shamir_threshold(0), None);
#[rstest]
#[case(0, None)]
#[case(1, Some(1))]
#[case(2, None)]
#[case(3, Some(2))]
#[case(4, Some(3))]
#[case(MAX_COMMITTEE_SIZE, Some(128))]
#[case(MAX_COMMITTEE_SIZE + 1, None)]
fn committee_threshold_is_a_majority(
#[case] committee_size: usize,
#[case] expected: Option<usize>,
) {
assert_eq!(shamir_threshold(committee_size), expected);
}
#[test]
fn committee_threshold_is_majority_for_three_or_more() {
assert_eq!(shamir_threshold(1), Some(1));
assert_eq!(shamir_threshold(3), Some(2));
assert_eq!(shamir_threshold(4), Some(3));
}
#[test]
fn oversized_committee_has_no_threshold() {
assert_eq!(shamir_threshold(MAX_COMMITTEE_SIZE), Some(128));
assert_eq!(shamir_threshold(MAX_COMMITTEE_SIZE + 1), None);
fn oversized_committee_is_rejected_by_split() {
let mut key = KeyCell::from([1_u8; 32]);
let rng = UnwrapErr(SysRng);
assert!(
@@ -189,7 +211,6 @@ mod tests {
#[test]
fn two_operator_committee_is_explicitly_unsupported() {
assert_eq!(shamir_threshold(2), None);
let mut key = KeyCell::from([7_u8; 32]);
let rng = UnwrapErr(SysRng);
assert!(