fix(vault): derive the recovery operator id from the authenticated peer

This commit is contained in:
CleverWild
2026-09-08 10:27:58 +02:00
parent 8d25d6640b
commit 8402691514
14 changed files with 522 additions and 78 deletions

View File

@@ -129,14 +129,23 @@ pub async fn start(
let (oob_sender, oob_receiver) = mpsc::channel(16);
let oob_adapter = OutOfBandAdapter(oob_sender);
let actor = {
let started = {
let transport = auth::AuthTransportAdapter::new(&mut bi, &mut request_tracker);
match crate::peers::operator::start(&mut conn, transport, Box::new(oob_adapter)).await {
Ok(actor) => actor,
Err(e) => {
warn!(error = ?e, "Operator connection failed");
return;
}
crate::peers::operator::start(&mut conn, transport, Box::new(oob_adapter)).await
};
let actor = match started {
Ok(actor) => actor,
// §3.5: a recovery operator is turned away from the session rather than failing. Say so
// on the stream, so it does not look like the server dropped the connection.
Err(e @ crate::peers::operator::Error::RecoveryOperatorHasNoSession) => {
info!("Recovery operator connection closed after the vault gate");
let _ = bi.send(Err(Status::permission_denied(e.to_string()))).await;
return;
}
Err(e) => {
warn!(error = ?e, "Operator connection failed");
return;
}
};

View File

@@ -2,9 +2,12 @@ use crate::{
actors::vault::VaultState,
peers::operator::{
OperatorSession,
session::handlers::{
HandleContributeRecoveryRekeyPassphrase, HandleContributeRekeyPassphrase,
HandleQueryVaultState,
session::{
Error as SessionError,
handlers::{
HandleContributeRecoveryRekeyPassphrase, HandleContributeRekeyPassphrase,
HandleQueryVaultState,
},
},
},
};
@@ -21,7 +24,7 @@ use arbiter_proto::{
proto::shared::VaultState as ProtoVaultState,
};
use kameo::actor::ActorRef;
use kameo::{actor::ActorRef, error::SendError};
use tonic::Status;
use tracing::warn;
@@ -50,6 +53,20 @@ pub(super) async fn dispatch(
}
}
/// A re-key share belongs to exactly one role (§3.3), so a contribution from the wrong one is a
/// policy answer and must not reach the peer as an opaque `internal`.
fn rekey_status<M>(err: SendError<M, SessionError>, context: &'static str) -> Status {
match err {
SendError::HandlerError(err @ SessionError::RoleNotPermitted) => {
Status::permission_denied(err.to_string())
}
err => {
warn!(?err, "{context}");
Status::internal(context)
}
}
}
async fn handle_rekey(
actor: &ActorRef<OperatorSession>,
req: proto_rekey::Request,
@@ -66,20 +83,13 @@ async fn handle_rekey(
passphrase: cp.passphrase,
})
.await
.map_err(|e| {
warn!(?e, "rekey passphrase contribution failed");
Status::internal("Rekey contribution failed")
})?,
.map_err(|e| rekey_status(e, "Rekey contribution failed"))?,
RekeyPayload::ContributeRecoveryPassphrase(crp) => actor
.ask(HandleContributeRecoveryRekeyPassphrase {
recovery_operator_id: crp.recovery_operator_id,
passphrase: crp.passphrase,
})
.await
.map_err(|e| {
warn!(?e, "rekey recovery passphrase contribution failed");
Status::internal("Rekey recovery contribution failed")
})?,
.map_err(|e| rekey_status(e, "Rekey recovery contribution failed"))?,
};
let proto_result = if done {

View File

@@ -89,7 +89,6 @@ impl TryConvert for UnsealRequestPayload {
Self::ContributeRecoveryPassphrase(crp) => Ok(
vault_gate::Inbound::HandleContributeRecoveryUnsealPassphrase(
HandleContributeRecoveryUnsealPassphrase {
recovery_operator_id: crp.recovery_operator_id,
passphrase: crp.passphrase,
},
),
@@ -157,7 +156,6 @@ impl TryConvert for BootstrapRequestPayload {
Self::ContributeRecoveryPassphrase(crp) => Ok(
vault_gate::Inbound::HandleContributeRecoveryBootstrapPassphrase(
HandleContributeRecoveryBootstrapPassphrase {
recovery_operator_id: crp.recovery_operator_id,
passphrase: crp.passphrase,
},
),

View File

@@ -103,6 +103,9 @@ impl TryConvert for vault_gate::Outbound {
Err(vault_gate::Error::AlreadyBootstrapped) => {
ProtoBootstrapResult::AlreadyBootstrapped
}
Err(err @ vault_gate::Error::RoleNotPermitted) => {
return Err(Status::permission_denied(err.to_string()));
}
Err(err) => {
warn!(?err, "bootstrap failed");
return Err(Status::internal("Failed to bootstrap vault"));
@@ -113,6 +116,11 @@ impl TryConvert for vault_gate::Outbound {
Self::HandleDeclareCommittee(result) => {
let proto_result = match result {
Ok(()) => ProtoBootstrapResult::Success,
// A role refusal is a policy answer, not a server fault, so it leaves the
// gate as `PERMISSION_DENIED` rather than as an opaque `internal`.
Err(err @ vault_gate::Error::RoleNotPermitted) => {
return Err(Status::permission_denied(err.to_string()));
}
Err(err) => {
warn!(?err, "declare committee failed");
return Err(Status::internal("Failed to declare committee"));
@@ -124,6 +132,9 @@ impl TryConvert for vault_gate::Outbound {
let proto_result = match result {
Ok(true) => ProtoBootstrapResult::Success,
Ok(false) => ProtoBootstrapResult::AwaitingContributions,
Err(err @ vault_gate::Error::RoleNotPermitted) => {
return Err(Status::permission_denied(err.to_string()));
}
Err(err) => {
warn!(?err, "contribute bootstrap passphrase failed");
return Err(Status::internal("Failed to contribute bootstrap passphrase"));
@@ -135,6 +146,9 @@ impl TryConvert for vault_gate::Outbound {
let proto_result = match result {
Ok(true) => ProtoBootstrapResult::Success,
Ok(false) => ProtoBootstrapResult::AwaitingContributions,
Err(err @ vault_gate::Error::RoleNotPermitted) => {
return Err(Status::permission_denied(err.to_string()));
}
Err(err) => {
warn!(?err, "contribute recovery bootstrap passphrase failed");
return Err(Status::internal(
@@ -148,6 +162,9 @@ impl TryConvert for vault_gate::Outbound {
let proto_result = match result {
Ok(true) => ProtoUnsealResult::Success,
Ok(false) => ProtoUnsealResult::AwaitingContributions,
Err(err @ vault_gate::Error::RoleNotPermitted) => {
return Err(Status::permission_denied(err.to_string()));
}
Err(err) => {
warn!(?err, "contribute unseal passphrase failed");
return Err(Status::internal("Failed to contribute unseal passphrase"));
@@ -161,6 +178,9 @@ impl TryConvert for vault_gate::Outbound {
let proto_result = match result {
Ok(true) => ProtoUnsealResult::Success,
Ok(false) => ProtoUnsealResult::AwaitingContributions,
Err(err @ vault_gate::Error::RoleNotPermitted) => {
return Err(Status::permission_denied(err.to_string()));
}
Err(err) => {
warn!(?err, "contribute recovery unseal passphrase failed");
return Err(Status::internal(

View File

@@ -240,6 +240,13 @@ where
})
}
None => {
// The tables are searched in this order, so a key registered in both resolves
// as `Ordinary` and could never submit its recovery share. Nothing enforces
// that the two sets are disjoint: `unique` is per table, and the only writer
// today is `register_key` above -- `recovery_operator_identity` has no
// registration path yet. Whoever builds one must refuse a key that
// `operator_identity` already holds, and vice versa, or §3.5's "separate peer
// type" holds only by convention.
if let Some(id) = get_client_id(&self.conn.db, pubkey).await? {
AuthenticatedOperator::Ordinary(Credentials {
id,

View File

@@ -53,17 +53,6 @@ pub enum AuthenticatedOperator {
Recovery(RecoveryCredentials),
}
impl AuthenticatedOperator {
/// The peer's id within its own identity table.
#[must_use]
pub const fn id(&self) -> i32 {
match self {
Self::Ordinary(credentials) => credentials.id,
Self::Recovery(credentials) => credentials.id,
}
}
}
// Messages, sent by operator to connection client without having a request
#[derive(Debug)]
pub enum OutOfBand {
@@ -93,6 +82,12 @@ pub enum Error {
Transport,
#[error("database error: {0}")]
Database(DatabaseError),
/// §3.5: a recovery operator's authority stops at the vault gate. It has no operator
/// session, because a session is the whole ordinary-governance surface -- wallets, grants,
/// SDK clients, proposals -- which §3.5 puts out of a recovery operator's reach. Named
/// rather than folded into `Internal`, so a policy refusal is not logged as a fault.
#[error("recovery operators do not have an operator session")]
RecoveryOperatorHasNoSession,
#[error("internal: {0}")]
Internal(String),
}
@@ -139,7 +134,7 @@ async fn should_run_gate(vault: &ActorRef<Vault>) -> Result<bool, Error> {
async fn run_vault_gate<T>(
props: &OperatorConnection,
transport: &mut T,
auth_creds: Credentials,
auth_creds: AuthenticatedOperator,
) -> Result<(), Error>
where
T: Bi<vault_gate::Inbound, Result<vault_gate::Outbound, vault_gate::Error>> + Send + ?Sized,
@@ -201,26 +196,25 @@ where
{
let authenticated = authenticate(props, &mut transport).await?;
// A recovery operator has no session of its own yet: everything below this point is written
// against an ordinary operator's `Credentials`, so the handshake is refused rather than
// silently treated as an ordinary one.
let AuthenticatedOperator::Ordinary(creds) = authenticated else {
return Err(Error::Internal(
"recovery operators have no session yet".into(),
));
};
// should run vault gate only if sealed / unbootstrapped
if should_run_gate(&props.actors.vault).await? {
run_vault_gate(props, &mut transport, creds.clone()).await?;
// §3.5 lets a recovery operator take part in unsealing, and the gate is where that
// happens, so both roles run it. The gate decides per message which role may send it.
run_vault_gate(props, &mut transport, authenticated.clone()).await?;
}
// Past the gate the connection turns into an ordinary operator session, which a recovery
// operator may not have.
let AuthenticatedOperator::Ordinary(creds) = &authenticated else {
return Err(Error::RecoveryOperatorHasNoSession);
};
// checking the integrity
verify_integrity(&props.db, &props.actors.vault, &creds).await?;
verify_integrity(&props.db, &props.actors.vault, creds).await?;
Ok(OperatorSession::spawn(OperatorSession::new(
props.clone(),
creds.clone(),
authenticated.clone(),
oob_sender,
)))
}

View File

@@ -312,7 +312,7 @@ impl OperatorSession {
ttl_secs: Option<u32>,
) -> Result<ProposalId, Error> {
use crate::actors::proposal_manager::CreateProposal;
let initiator_id = OperatorIdentityId::from_raw(self.credentials.id);
let initiator_id = OperatorIdentityId::from_raw(self.ordinary_id()?);
self.props
.actors
.proposal_manager
@@ -332,7 +332,10 @@ impl OperatorSession {
signature: Vec<u8>,
) -> Result<crate::actors::proposal_manager::VoteOutcome, crate::actors::proposal_manager::Error> {
use crate::actors::proposal_manager::CastVote;
let operator_id = OperatorIdentityId::from_raw(self.credentials.id);
let operator_id = OperatorIdentityId::from_raw(
self.ordinary_id()
.map_err(|_| crate::actors::proposal_manager::Error::NotAllowedForRecoveryOperator)?,
);
self.props
.actors
.proposal_manager
@@ -349,7 +352,11 @@ impl OperatorSession {
&mut self,
) -> Vec<crate::actors::proposal_manager::ProposalSummary> {
use crate::actors::proposal_manager::QueryPending;
let operator_id = OperatorIdentityId::from_raw(self.credentials.id);
let Ok(id) = self.ordinary_id() else {
// The pending list is per ordinary operator; a recovery operator has no view of it.
return Vec::new();
};
let operator_id = OperatorIdentityId::from_raw(id);
self.props
.actors
.proposal_manager
@@ -369,7 +376,7 @@ impl OperatorSession {
use crate::actors::vault_coordinator::ContributeRekey;
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
let operator_id = self.credentials.id;
let operator_id = self.ordinary_id()?;
self.props
.actors
.vault_coordinator
@@ -381,15 +388,23 @@ impl OperatorSession {
.map_err(|_| Error::internal("VaultCoordinator unavailable"))
}
/// §3.3: a re-key refreshes every share, recovery shares included, so a recovery operator
/// has one to contribute here.
///
/// It cannot reach this handler yet: `peers::operator::start` refuses a recovery peer an
/// operator session, because a session carries the whole ordinary-governance surface that
/// §3.5 keeps out of a recovery operator's hands. Until a recovery-scoped session exists,
/// this refuses every caller -- which is the safe direction, and the id it would use comes
/// from the handshake either way.
#[message]
pub(crate) async fn handle_contribute_recovery_rekey_passphrase(
&mut self,
recovery_operator_id: i32,
passphrase: Vec<u8>,
) -> Result<bool, Error> {
use crate::actors::vault_coordinator::ContributeRecoveryRekey;
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
let recovery_operator_id = self.recovery_id()?;
self.props
.actors
.vault_coordinator

View File

@@ -1,4 +1,4 @@
use super::{Credentials, OutOfBand, OperatorConnection};
use super::{AuthenticatedOperator, OutOfBand, OperatorConnection};
use crate::{
actors::{
flow_coordinator::client_connect_approval::ClientApprovalController,
@@ -19,6 +19,11 @@ pub enum Error {
#[error("State transition failed")]
State,
/// §3.5: the ordinary and recovery roles reach for different handlers here. A refusal is a
/// policy answer, so it is named rather than folded into `Internal` beside real faults.
#[error("This operator role may not perform that action")]
RoleNotPermitted,
#[error("Internal error: {message}")]
Internal { message: Cow<'static, str> },
}
@@ -51,7 +56,7 @@ pub struct PendingClientApproval {
pub struct OperatorSession {
props: OperatorConnection,
credentials: Credentials,
credentials: AuthenticatedOperator,
sender: Box<dyn Sender<OutOfBand>>,
pending_client_approvals: HashMap<Vec<u8>, PendingClientApproval>,
@@ -60,7 +65,7 @@ pub struct OperatorSession {
pub mod handlers;
impl OperatorSession {
pub(crate) fn new(props: OperatorConnection, credentials: Credentials, sender: Box<dyn Sender<OutOfBand>>) -> Self {
pub(crate) fn new(props: OperatorConnection, credentials: AuthenticatedOperator, sender: Box<dyn Sender<OutOfBand>>) -> Self {
Self {
props,
credentials,
@@ -68,6 +73,25 @@ impl OperatorSession {
pending_client_approvals: HashMap::default(),
}
}
/// The id of the ordinary operator on the other end, or a refusal.
///
/// Read from the handshake, never from a request body, so a peer cannot act under an id it
/// did not authenticate as.
const fn ordinary_id(&self) -> Result<i32, Error> {
match &self.credentials {
AuthenticatedOperator::Ordinary(credentials) => Ok(credentials.id),
AuthenticatedOperator::Recovery(_) => Err(Error::RoleNotPermitted),
}
}
/// The id of the recovery operator on the other end, or a refusal. See `ordinary_id`.
const fn recovery_id(&self) -> Result<i32, Error> {
match &self.credentials {
AuthenticatedOperator::Recovery(credentials) => Ok(credentials.id),
AuthenticatedOperator::Ordinary(_) => Err(Error::RoleNotPermitted),
}
}
}
#[messages]

View File

@@ -1,4 +1,4 @@
use super::Credentials;
use super::AuthenticatedOperator;
use crate::{
actors::{
GlobalActors,
@@ -36,6 +36,12 @@ pub enum Error {
#[error("State transition failed")]
State,
/// §3.5: ordinary and recovery operators hold different shares of the same split, so each
/// contribution belongs to exactly one of the two roles. A refusal here is a policy answer
/// and is kept out of `Internal`, which carries genuine faults.
#[error("This operator role may not perform that vault action")]
RoleNotPermitted,
#[error("Internal error: {0}")]
Internal(String),
}
@@ -50,7 +56,7 @@ pub struct HandshakeResponse {
}
pub struct VaultGate {
pub auth_creds: Credentials,
pub auth_creds: AuthenticatedOperator,
pub promotion_tx: Option<oneshot::Sender<Result<(), Error>>>,
pub state: State,
pub actors: GlobalActors,
@@ -59,7 +65,7 @@ pub struct VaultGate {
impl VaultGate {
pub fn new(
auth_creds: Credentials,
auth_creds: AuthenticatedOperator,
actors: GlobalActors,
db: DatabasePool,
promotion_tx: oneshot::Sender<Result<(), Error>>,
@@ -100,6 +106,25 @@ impl Actor for VaultGate {
}
impl VaultGate {
/// The id of the ordinary operator on the other end, or a refusal.
///
/// The id is read from the handshake rather than from the request body, so a peer cannot
/// name an operator it did not authenticate as.
const fn ordinary_id(&self) -> Result<i32, Error> {
match &self.auth_creds {
AuthenticatedOperator::Ordinary(credentials) => Ok(credentials.id),
AuthenticatedOperator::Recovery(_) => Err(Error::RoleNotPermitted),
}
}
/// The id of the recovery operator on the other end, or a refusal. See `ordinary_id`.
const fn recovery_id(&self) -> Result<i32, Error> {
match &self.auth_creds {
AuthenticatedOperator::Recovery(credentials) => Ok(credentials.id),
AuthenticatedOperator::Ordinary(_) => Err(Error::RoleNotPermitted),
}
}
fn decrypt_key(
secret: &SharedSecret,
nonce: &[u8],
@@ -148,6 +173,13 @@ impl VaultGate {
})
}
/// Deliberately open to both roles, unlike `handle_bootstrap_encrypted_key` below.
///
/// Handing over the whole seal key to open a sealed vault is participating in unsealing,
/// which §3.5 grants a recovery operator, and the peer has to hold that key already -- it
/// gains nothing here it did not bring. Bootstrap is the opposite: it *chooses* the key for
/// a vault that has none, which is sole custody of the root key and belongs to no §3.5
/// power. The reasoning that admits one does not admit the other.
#[message]
pub async fn handle_unseal_encrypted_key(
&mut self,
@@ -185,6 +217,10 @@ impl VaultGate {
}
}
/// §3.4/§3.5: bootstrapping picks the root key for a vault that has none, so whoever gets
/// here holds sole custody until the committee splits it. That is not one of a recovery
/// operator's two powers, and the check comes first because the vault commits before this
/// handler could refuse anything afterwards.
#[message]
pub async fn handle_bootstrap_encrypted_key(
&mut self,
@@ -192,6 +228,8 @@ impl VaultGate {
ciphertext: Vec<u8>,
associated_data: Vec<u8>,
) -> Result<(), Error> {
let _ = self.ordinary_id()?;
let State::ReadyForExchange { secret, .. } = &self.state else {
return Err(Error::State);
};
@@ -242,10 +280,12 @@ impl VaultGate {
count: usize,
recovery_count: usize,
) -> Result<(), Error> {
let operator_id = self.ordinary_id()?;
self.actors
.vault_coordinator
.ask(StartBootstrap {
operator_id: self.auth_creds.id,
operator_id,
declared_count: count,
recovery_count,
})
@@ -258,11 +298,13 @@ impl VaultGate {
&mut self,
passphrase: Vec<u8>,
) -> Result<bool, Error> {
let operator_id = self.ordinary_id()?;
let passphrase_cell = SafeCell::new(passphrase);
self.actors
.vault_coordinator
.ask(ContributeBootstrap {
operator_id: self.auth_creds.id,
operator_id,
passphrase: passphrase_cell,
})
.await
@@ -272,9 +314,10 @@ impl VaultGate {
#[message]
pub async fn handle_contribute_recovery_bootstrap_passphrase(
&mut self,
recovery_operator_id: i32,
passphrase: Vec<u8>,
) -> Result<bool, Error> {
let recovery_operator_id = self.recovery_id()?;
let passphrase_cell = SafeCell::new(passphrase);
self.actors
.vault_coordinator
@@ -291,11 +334,13 @@ impl VaultGate {
&mut self,
passphrase: Vec<u8>,
) -> Result<bool, Error> {
let operator_id = self.ordinary_id()?;
let passphrase_cell = SafeCell::new(passphrase);
self.actors
.vault_coordinator
.ask(ContributeUnseal {
operator_id: self.auth_creds.id,
operator_id,
passphrase: passphrase_cell,
})
.await
@@ -305,9 +350,10 @@ impl VaultGate {
#[message]
pub async fn handle_contribute_recovery_unseal_passphrase(
&mut self,
recovery_operator_id: i32,
passphrase: Vec<u8>,
) -> Result<bool, Error> {
let recovery_operator_id = self.recovery_id()?;
let passphrase_cell = SafeCell::new(passphrase);
self.actors
.vault_coordinator
@@ -334,13 +380,28 @@ impl Message<events::Bootstrapped> for VaultGate {
.get()
.await
.map_err(|_| Error::internal("DB unavailable"))?;
integrity::sign_entity(
&mut conn,
&self.actors.vault,
&self.auth_creds,
self.auth_creds.id,
)
.await
// Each role signs under its own `Integrable::KIND`, so the two id spaces cannot
// collide in `integrity_envelope`.
match &self.auth_creds {
AuthenticatedOperator::Ordinary(credentials) => {
integrity::sign_entity(
&mut conn,
&self.actors.vault,
credentials,
credentials.id,
)
.await
}
AuthenticatedOperator::Recovery(credentials) => {
integrity::sign_entity(
&mut conn,
&self.actors.vault,
credentials,
credentials.id,
)
.await
}
}
.map_err(|e| {
error!(?e, "Failed to sign integrity envelope on bootstrap");
Error::internal("Integrity sign failed")