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