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" redundant_pub_crate = "allow"
uninhabited_references = "allow" # safe with unsafe_code = "forbid" and standard uninhabited pattern (match *self {}) 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 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 # restriction lints
alloc_instead_of_core = "warn" 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 /// declared, so the token stays valid across several registrations and is
/// retired by the `Bootstrapped` event rather than by first use, whichever /// retired by the `Bootstrapped` event rather than by first use, whichever
/// bootstrap path fired it. /// 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 { pub struct Bootstrapper {
token: Option<SafeCell<[u8; TOKEN_LENGTH]>>, token: Option<SafeCell<[u8; TOKEN_LENGTH]>>,
token_path: Option<PathBuf>, 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 { Ok(Self {
token: Some(token), token: Some(generate_token(&path).await?),
token_path: Some(path), token_path: Some(path),
events, 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) { async fn forget(&mut self) {
self.token = None; self.token = None;
if let Some(path) = self.token_path.take() { if let Some(path) = self.token_path.take() {
@@ -170,12 +145,14 @@ impl Message<events::Bootstrapped> for Bootstrapper {
self.forget().await; self.forget().await;
} }
} }
#[messages] #[messages]
impl Bootstrapper { impl Bootstrapper {
#[message] #[message]
pub fn verify_token(&mut self, token: Vec<u8>) -> bool { 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] #[message]

View File

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

View File

@@ -16,10 +16,13 @@ pub enum ShamirError {
Combine(String), 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 /// A pool of two is rejected: a majority of two is two, which gives each holder
/// member a veto over every unseal without giving either one recovery. /// 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( #[expect(
clippy::integer_division, clippy::integer_division,
reason = "majority thresholds use integer arithmetic" reason = "majority thresholds use integer arithmetic"
@@ -40,7 +43,7 @@ pub fn split_key(
total: usize, total: usize,
key: &mut KeyCell, key: &mut KeyCell,
rng: impl CryptoRng, 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 if total == 0 || threshold == 0 || threshold > total || total == 2 || total > MAX_COMMITTEE_SIZE
{ {
return Err(ShamirError::Split( 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| { key.0.read_inline(|key| {
let key: &[u8; 32] = key let key: &[u8; 32] = key
.as_slice() .as_slice()
.try_into() .try_into()
.map_err(|_| ShamirError::Split("unexpected seal key length".to_owned()))?; .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) 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:?}"))) .map_err(|error| ShamirError::Split(format!("{error:?}")))
}) })
} }
@@ -74,30 +80,43 @@ pub fn split_key(
/// sized. /// sized.
pub fn combine_shares( pub fn combine_shares(
threshold: usize, threshold: usize,
shares: &mut SafeCell<Vec<Vec<u8>>>, shares: &mut [SafeCell<Vec<u8>>],
) -> Result<KeyCell, ShamirError> { ) -> Result<KeyCell, ShamirError> {
if threshold == 0 { if threshold == 0 {
return Err(ShamirError::Combine("threshold is zero".to_owned())); return Err(ShamirError::Combine("threshold is zero".to_owned()));
} }
if shares.read().len() < threshold { if shares.len() < threshold {
return Err(ShamirError::Combine( return Err(ShamirError::Combine(
"not enough shares supplied".to_owned(), "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 { if threshold == 1 {
let share = shares let share = shares
.first() .first_mut()
.ok_or_else(|| ShamirError::Combine("no shares supplied".to_owned()))?; .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(SafeCell::new)
.map_err(|error| ShamirError::Combine(format!("{error:?}"))) .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())) .map_err(|()| ShamirError::Combine("unexpected reconstructed key length".to_owned()))
} }
@@ -108,6 +127,7 @@ mod tests {
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _}; use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use rand::rngs::SysRng; use rand::rngs::SysRng;
use rand_core::UnwrapErr; use rand_core::UnwrapErr;
use rstest::rstest;
fn key_bytes(mut key: KeyCell) -> [u8; 32] { fn key_bytes(mut key: KeyCell) -> [u8; 32] {
key.0.read_inline(|key| { key.0.read_inline(|key| {
@@ -117,28 +137,29 @@ mod tests {
}) })
} }
fn select(shares: &mut SafeCell<Vec<Vec<u8>>>, indexes: &[usize]) -> SafeCell<Vec<Vec<u8>>> { fn select(shares: &mut [SafeCell<Vec<u8>>], indexes: &[usize]) -> Vec<SafeCell<Vec<u8>>> {
shares.read_inline(|shares| { indexes
SafeCell::new( .iter()
indexes .filter_map(|index| {
.iter() shares
.filter_map(|index| shares.get(*index).cloned()) .get_mut(*index)
.collect(), .map(|share| share.read_inline(|share| SafeCell::new(share.clone())))
) })
}) .collect()
} }
#[test] #[rstest]
fn threshold_shares_reconstruct_fixed_key() { #[case(&[0, 1])]
#[case(&[0, 2])]
#[case(&[1, 2])]
fn threshold_shares_reconstruct_fixed_key(#[case] indexes: &[usize]) {
let expected = [9_u8; 32]; let expected = [9_u8; 32];
let mut key = KeyCell::from(expected); let mut key = KeyCell::from(expected);
let rng = UnwrapErr(SysRng); let rng = UnwrapErr(SysRng);
let mut shares = split_key(2, 3, &mut key, rng).expect("split should succeed"); 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");
let combined = combine_shares(2, &mut selected).expect("combine should succeed"); assert_eq!(key_bytes(combined), expected);
assert_eq!(key_bytes(combined), expected);
}
} }
#[test] #[test]
@@ -163,22 +184,23 @@ mod tests {
); );
} }
#[test] #[rstest]
fn empty_committee_has_no_threshold() { #[case(0, None)]
assert_eq!(shamir_threshold(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] #[test]
fn committee_threshold_is_majority_for_three_or_more() { fn oversized_committee_is_rejected_by_split() {
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);
let mut key = KeyCell::from([1_u8; 32]); let mut key = KeyCell::from([1_u8; 32]);
let rng = UnwrapErr(SysRng); let rng = UnwrapErr(SysRng);
assert!( assert!(
@@ -189,7 +211,6 @@ mod tests {
#[test] #[test]
fn two_operator_committee_is_explicitly_unsupported() { fn two_operator_committee_is_explicitly_unsupported() {
assert_eq!(shamir_threshold(2), None);
let mut key = KeyCell::from([7_u8; 32]); let mut key = KeyCell::from([7_u8; 32]);
let rng = UnwrapErr(SysRng); let rng = UnwrapErr(SysRng);
assert!( assert!(