feat(vault)!: add multi-operator Shamir custody

This commit is contained in:
CleverWild
2026-09-09 16:42:11 +02:00
parent 0677695b16
commit c722712166
37 changed files with 2148 additions and 301 deletions

View File

@@ -1,11 +1,10 @@
use crate::{
grpc::request_tracker::RequestTracker,
peers::operator::{OutOfBand, OperatorConnection, OperatorSession},
peers::operator::{OperatorConnection, OperatorSession, OutOfBand},
};
use arbiter_proto::{
proto::operator::{
OperatorRequest, OperatorResponse,
operator_request::Payload as OperatorRequestPayload,
OperatorRequest, OperatorResponse, operator_request::Payload as OperatorRequestPayload,
operator_response::Payload as OperatorResponsePayload,
},
transport::{Error as TransportError, Receiver, Sender, grpc::GrpcBi},
@@ -111,6 +110,9 @@ async fn dispatch_inner(
OperatorRequestPayload::Vault(req) => vault::dispatch(actor, req).await,
OperatorRequestPayload::Evm(req) => evm::dispatch(actor, req).await,
OperatorRequestPayload::SdkClient(req) => sdk_client::dispatch(actor, req).await,
OperatorRequestPayload::Governance(_) => {
Err(Status::permission_denied(stringify!(Governance)))
}
OperatorRequestPayload::Auth(..) => {
warn!("Unsupported post-auth operator auth request");
Err(Status::invalid_argument("Unsupported operator request"))

View File

@@ -3,7 +3,6 @@ use crate::{
peers::operator::{OperatorSession, session::handlers::HandleQueryVaultState},
};
use arbiter_proto::{
proto::shared::VaultState as ProtoVaultState,
proto::operator::{
operator_response::Payload as OperatorResponsePayload,
vault::{
@@ -11,6 +10,7 @@ use arbiter_proto::{
response::Payload as VaultResponsePayload,
},
},
proto::shared::VaultState as ProtoVaultState,
};
use kameo::actor::ActorRef;
@@ -33,11 +33,11 @@ pub(super) async fn dispatch(
match payload {
VaultRequestPayload::QueryState(()) => handle_query_vault_state(actor).await,
VaultRequestPayload::Unseal(_) | VaultRequestPayload::Bootstrap(_) => {
Err(Status::permission_denied(
"Vault is already unsealed; unseal/bootstrap not permitted in session",
))
}
VaultRequestPayload::Unseal(_)
| VaultRequestPayload::Bootstrap(_)
| VaultRequestPayload::Rekey(_) => Err(Status::permission_denied(
"Vault is already unsealed; unseal/bootstrap not permitted in session",
)),
}
}

View File

@@ -1,14 +1,17 @@
use crate::{
crypto::shamir,
grpc::{Convert, TryConvert},
peers::operator::vault_gate::{
self as vault_gate, HandleBootstrapEncryptedKey, HandleHandshake, HandleUnsealEncryptedKey,
self as vault_gate, HandleBootstrapEncryptedKey, HandleContributeBootstrapPassphrase,
HandleContributeUnsealPassphrase, HandleDeclareCommittee, HandleHandshake,
HandleUnsealEncryptedKey,
},
};
use arbiter_proto::proto::operator::{
operator_request::Payload as OperatorRequestPayload,
vault::{
self as proto_vault,
bootstrap::{self as proto_bootstrap},
bootstrap::{self as proto_bootstrap, request::Payload as BootstrapRequestPayload},
request::Payload as VaultRequestPayload,
unseal::{self as proto_unseal, request::Payload as UnsealRequestPayload},
},
@@ -50,6 +53,7 @@ impl TryConvert for VaultRequestPayload {
Self::QueryState(()) => Ok(vault_gate::Inbound::HandleVaultState),
Self::Unseal(req) => req.try_convert(),
Self::Bootstrap(req) => req.try_convert(),
Self::Rekey(_) => Err(Status::unimplemented("Vault re-key is not available")),
}
}
}
@@ -73,6 +77,16 @@ impl TryConvert for UnsealRequestPayload {
match self {
Self::Start(start) => start.try_convert(),
Self::EncryptedKey(key) => Ok(key.convert()),
Self::ContributePassphrase(passphrase) => {
Ok(vault_gate::Inbound::HandleContributeUnsealPassphrase(
HandleContributeUnsealPassphrase {
passphrase: passphrase.passphrase,
},
))
}
Self::ContributeRecoveryPassphrase(_) => Err(Status::unimplemented(
"Recovery operator contributions are not available",
)),
}
}
}
@@ -107,12 +121,52 @@ impl TryConvert for proto_bootstrap::Request {
type Error = Status;
fn try_convert(self) -> Result<vault_gate::Inbound, Status> {
self.encrypted_key
.ok_or_else(|| Status::invalid_argument("Missing bootstrap encrypted key"))?
self.payload
.ok_or_else(|| Status::invalid_argument("Missing bootstrap payload"))?
.try_convert()
}
}
impl TryConvert for BootstrapRequestPayload {
type Output = vault_gate::Inbound;
type Error = Status;
fn try_convert(self) -> Result<vault_gate::Inbound, Status> {
match self {
Self::EncryptedKey(key) => key.try_convert(),
Self::DeclareCommittee(dc) => {
if dc.recovery_count != 0 {
return Err(Status::unimplemented(
"Recovery operator contributions are not available",
));
}
let count = usize::try_from(dc.count)
.ok()
.filter(|count| *count <= shamir::MAX_COMMITTEE_SIZE)
.ok_or_else(|| {
Status::invalid_argument(format!(
"Committee count must not exceed {}",
shamir::MAX_COMMITTEE_SIZE
))
})?;
Ok(vault_gate::Inbound::HandleDeclareCommittee(
HandleDeclareCommittee { count },
))
}
Self::ContributePassphrase(cp) => {
Ok(vault_gate::Inbound::HandleContributeBootstrapPassphrase(
HandleContributeBootstrapPassphrase {
passphrase: cp.passphrase,
},
))
}
Self::ContributeRecoveryPassphrase(_) => Err(Status::unimplemented(
"Recovery operator contributions are not available",
)),
}
}
}
impl TryConvert for proto_bootstrap::BootstrapEncryptedKey {
type Output = vault_gate::Inbound;
type Error = Status;

View File

@@ -1,10 +1,9 @@
use crate::{
actors::vault::VaultState,
actors::{vault::VaultState, vault_coordinator},
grpc::{Convert, TryConvert},
peers::operator::vault_gate::{self as vault_gate},
};
use arbiter_proto::proto::{
shared::VaultState as ProtoVaultState,
operator::{
operator_response::Payload as OperatorResponsePayload,
vault::{
@@ -17,6 +16,7 @@ use arbiter_proto::proto::{
},
},
},
shared::VaultState as ProtoVaultState,
};
use tonic::Status;
@@ -34,6 +34,26 @@ const fn wrap_unseal_response(payload: UnsealResponsePayload) -> OperatorRespons
}))
}
/// Ceremony errors are the operator's own doing far more often than ours, so
/// they travel back as a specific status instead of a blanket internal error.
fn ceremony_status(error: &vault_coordinator::Error) -> Status {
match error {
vault_coordinator::Error::AlreadyBootstrapping
| vault_coordinator::Error::AlreadyUnsealing
| vault_coordinator::Error::NotBootstrapping
| vault_coordinator::Error::DuplicateContribution => {
Status::failed_precondition(error.to_string())
}
vault_coordinator::Error::EmptyCommittee
| vault_coordinator::Error::UnsupportedCommittee
| vault_coordinator::Error::CommitteeTooLarge => {
Status::invalid_argument(error.to_string())
}
vault_coordinator::Error::InvalidPassphrase => Status::unauthenticated(error.to_string()),
_ => Status::internal("Vault ceremony failed"),
}
}
fn wrap_bootstrap_response(result: ProtoBootstrapResult) -> OperatorResponsePayload {
wrap_vault_response(VaultResponsePayload::Bootstrap(proto_bootstrap::Response {
result: result.into(),
@@ -87,7 +107,6 @@ impl TryConvert for vault_gate::Outbound {
let proto_result = match result {
Ok(()) => ProtoUnsealResult::Success,
Err(vault_gate::Error::InvalidKey) => ProtoUnsealResult::InvalidKey,
Err(vault_gate::Error::LockedOut) => ProtoUnsealResult::LockedOut,
Err(err) => {
warn!(?err, "unseal failed");
return Err(Status::internal("Failed to unseal vault"));
@@ -111,6 +130,56 @@ impl TryConvert for vault_gate::Outbound {
};
Ok(wrap_bootstrap_response(proto_result))
}
Self::HandleDeclareCommittee(result) => {
let proto_result = match result {
Ok(()) => ProtoBootstrapResult::AwaitingContributions,
Err(vault_gate::Error::Ceremony(
vault_coordinator::Error::AlreadyBootstrapped,
)) => ProtoBootstrapResult::AlreadyBootstrapped,
Err(vault_gate::Error::Ceremony(err)) => {
warn!(?err, "declare committee failed");
return Err(ceremony_status(&err));
}
Err(err) => {
warn!(?err, "declare committee failed");
return Err(Status::internal("Failed to declare committee"));
}
};
Ok(wrap_bootstrap_response(proto_result))
}
Self::HandleContributeBootstrapPassphrase(result) => {
let proto_result = match result {
Ok(true) => ProtoBootstrapResult::Success,
Ok(false) => ProtoBootstrapResult::AwaitingContributions,
Err(vault_gate::Error::Ceremony(
vault_coordinator::Error::AlreadyBootstrapped,
)) => ProtoBootstrapResult::AlreadyBootstrapped,
Err(vault_gate::Error::Ceremony(err)) => {
warn!(?err, "contribute bootstrap passphrase failed");
return Err(ceremony_status(&err));
}
Err(err) => {
warn!(?err, "contribute bootstrap passphrase failed");
return Err(Status::internal(
"Failed to contribute bootstrap passphrase",
));
}
};
Ok(wrap_bootstrap_response(proto_result))
}
Self::HandleContributeUnsealPassphrase(result) => {
let proto_result = match result {
Ok(true) => ProtoUnsealResult::Success,
Ok(false) => ProtoUnsealResult::AwaitingContributions,
Err(err) => {
warn!(?err, "contribute unseal passphrase failed");
return Err(Status::internal("Failed to contribute unseal passphrase"));
}
};
Ok(wrap_unseal_response(UnsealResponsePayload::Result(
proto_result.into(),
)))
}
}
}
}