fix(crypto): handle 1-of-N Shamir split when ordinary_count=1
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user