fix(crypto): handle 1-of-N Shamir split when ordinary_count=1

This commit is contained in:
CleverWild
2026-06-13 23:08:53 +02:00
parent eb16da3a20
commit 6017ef29ca
2 changed files with 386 additions and 75 deletions

View File

@@ -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)
} }
} }

View File

@@ -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);
}