diff --git a/protobufs/operator/vault/bootstrap.proto b/protobufs/operator/vault/bootstrap.proto index fc0edf8..ab72c73 100644 --- a/protobufs/operator/vault/bootstrap.proto +++ b/protobufs/operator/vault/bootstrap.proto @@ -18,8 +18,7 @@ message ContributePassphrase { } message ContributeRecoveryPassphrase { - int32 recovery_operator_id = 1; - bytes passphrase = 2; + bytes passphrase = 1; } enum BootstrapResult { diff --git a/protobufs/operator/vault/rekey.proto b/protobufs/operator/vault/rekey.proto index 5a1de2c..f6d1a63 100644 --- a/protobufs/operator/vault/rekey.proto +++ b/protobufs/operator/vault/rekey.proto @@ -7,8 +7,7 @@ message ContributePassphrase { } message ContributeRecoveryPassphrase { - int32 recovery_operator_id = 1; - bytes passphrase = 2; + bytes passphrase = 1; } enum RekeyResult { diff --git a/protobufs/operator/vault/unseal.proto b/protobufs/operator/vault/unseal.proto index 5e770fd..ef981a5 100644 --- a/protobufs/operator/vault/unseal.proto +++ b/protobufs/operator/vault/unseal.proto @@ -20,8 +20,7 @@ message ContributePassphrase { } message ContributeRecoveryPassphrase { - int32 recovery_operator_id = 1; - bytes passphrase = 2; + bytes passphrase = 1; } enum UnsealResult { diff --git a/server/crates/arbiter-server/src/grpc/operator.rs b/server/crates/arbiter-server/src/grpc/operator.rs index aef89a6..16a4e8a 100644 --- a/server/crates/arbiter-server/src/grpc/operator.rs +++ b/server/crates/arbiter-server/src/grpc/operator.rs @@ -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; } }; diff --git a/server/crates/arbiter-server/src/grpc/operator/vault.rs b/server/crates/arbiter-server/src/grpc/operator/vault.rs index 793e254..a49a306 100644 --- a/server/crates/arbiter-server/src/grpc/operator/vault.rs +++ b/server/crates/arbiter-server/src/grpc/operator/vault.rs @@ -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(err: SendError, 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, 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 { diff --git a/server/crates/arbiter-server/src/grpc/operator/vault_gate/inbound.rs b/server/crates/arbiter-server/src/grpc/operator/vault_gate/inbound.rs index 6e90a9c..919f0f1 100644 --- a/server/crates/arbiter-server/src/grpc/operator/vault_gate/inbound.rs +++ b/server/crates/arbiter-server/src/grpc/operator/vault_gate/inbound.rs @@ -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, }, ), diff --git a/server/crates/arbiter-server/src/grpc/operator/vault_gate/outbound.rs b/server/crates/arbiter-server/src/grpc/operator/vault_gate/outbound.rs index fc8bf7f..7b5eb75 100644 --- a/server/crates/arbiter-server/src/grpc/operator/vault_gate/outbound.rs +++ b/server/crates/arbiter-server/src/grpc/operator/vault_gate/outbound.rs @@ -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( diff --git a/server/crates/arbiter-server/src/peers/operator/auth/state.rs b/server/crates/arbiter-server/src/peers/operator/auth/state.rs index add4c5f..a38a33a 100644 --- a/server/crates/arbiter-server/src/peers/operator/auth/state.rs +++ b/server/crates/arbiter-server/src/peers/operator/auth/state.rs @@ -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, diff --git a/server/crates/arbiter-server/src/peers/operator/mod.rs b/server/crates/arbiter-server/src/peers/operator/mod.rs index 82c6444..f3a8759 100644 --- a/server/crates/arbiter-server/src/peers/operator/mod.rs +++ b/server/crates/arbiter-server/src/peers/operator/mod.rs @@ -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) -> Result { async fn run_vault_gate( props: &OperatorConnection, transport: &mut T, - auth_creds: Credentials, + auth_creds: AuthenticatedOperator, ) -> Result<(), Error> where T: Bi> + 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, ))) } diff --git a/server/crates/arbiter-server/src/peers/operator/session/handlers.rs b/server/crates/arbiter-server/src/peers/operator/session/handlers.rs index 60cc16e..a3c2173 100644 --- a/server/crates/arbiter-server/src/peers/operator/session/handlers.rs +++ b/server/crates/arbiter-server/src/peers/operator/session/handlers.rs @@ -312,7 +312,7 @@ impl OperatorSession { ttl_secs: Option, ) -> Result { 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, ) -> Result { 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 { 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, ) -> Result { 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 diff --git a/server/crates/arbiter-server/src/peers/operator/session/mod.rs b/server/crates/arbiter-server/src/peers/operator/session/mod.rs index 083f106..9bf0777 100644 --- a/server/crates/arbiter-server/src/peers/operator/session/mod.rs +++ b/server/crates/arbiter-server/src/peers/operator/session/mod.rs @@ -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>, pending_client_approvals: HashMap, PendingClientApproval>, @@ -60,7 +65,7 @@ pub struct OperatorSession { pub mod handlers; impl OperatorSession { - pub(crate) fn new(props: OperatorConnection, credentials: Credentials, sender: Box>) -> Self { + pub(crate) fn new(props: OperatorConnection, credentials: AuthenticatedOperator, sender: Box>) -> 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 { + 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 { + match &self.credentials { + AuthenticatedOperator::Recovery(credentials) => Ok(credentials.id), + AuthenticatedOperator::Ordinary(_) => Err(Error::RoleNotPermitted), + } + } } #[messages] diff --git a/server/crates/arbiter-server/src/peers/operator/vault_gate/mod.rs b/server/crates/arbiter-server/src/peers/operator/vault_gate/mod.rs index d416401..3c20e7b 100644 --- a/server/crates/arbiter-server/src/peers/operator/vault_gate/mod.rs +++ b/server/crates/arbiter-server/src/peers/operator/vault_gate/mod.rs @@ -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>>, 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>, @@ -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 { + 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 { + 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, associated_data: Vec, ) -> 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, ) -> Result { + 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, ) -> Result { + 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, ) -> Result { + 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, ) -> Result { + let recovery_operator_id = self.recovery_id()?; + let passphrase_cell = SafeCell::new(passphrase); self.actors .vault_coordinator @@ -334,13 +380,28 @@ impl Message 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") diff --git a/server/crates/arbiter-server/tests/operator/unseal.rs b/server/crates/arbiter-server/tests/operator/unseal.rs index 38f931d..4d3b8cb 100644 --- a/server/crates/arbiter-server/tests/operator/unseal.rs +++ b/server/crates/arbiter-server/tests/operator/unseal.rs @@ -4,7 +4,7 @@ use arbiter_server::{ actors::vault::{Bootstrap, Seal}, db, peers::operator::{ - Credentials, + AuthenticatedOperator, Credentials, vault_gate::{ Error as VaultGateError, HandleHandshake, HandleUnsealEncryptedKey, VaultGate, }, @@ -37,7 +37,7 @@ async fn setup_sealed_gate( let (promotion_tx, promotion_rx) = oneshot::channel(); let pubkey = authn::SigningKey::generate().public_key(); - let auth_creds = Credentials { id: 1, pubkey }; + let auth_creds = AuthenticatedOperator::Ordinary(Credentials { id: 1, pubkey }); let gate = VaultGate::spawn(VaultGate::new(auth_creds, actors, db.clone(), promotion_tx)); (db, gate, promotion_rx) diff --git a/server/crates/arbiter-server/tests/vault/lifecycle.rs b/server/crates/arbiter-server/tests/vault/lifecycle.rs index 8a655eb..42cc971 100644 --- a/server/crates/arbiter-server/tests/vault/lifecycle.rs +++ b/server/crates/arbiter-server/tests/vault/lifecycle.rs @@ -1,5 +1,8 @@ use crate::common; -use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _}; +use arbiter_crypto::{ + authn, + safecell::{SafeCell, SafeCellHandle as _}, +}; use arbiter_server::{ actors::{ GlobalActors, @@ -11,11 +14,23 @@ use arbiter_server::{ }, crypto::{KeyCell, encryption::v1::{Nonce, ROOT_KEY_TAG}}, db::{self, models, schema}, + peers::operator::{ + AuthenticatedOperator, Credentials, RecoveryCredentials, + vault_gate::{ + Error as VaultGateError, HandleBootstrapEncryptedKey, + HandleContributeBootstrapPassphrase, HandleContributeRecoveryBootstrapPassphrase, + HandleContributeRecoveryUnsealPassphrase, HandleContributeUnsealPassphrase, + HandleDeclareCommittee, HandleHandshake, VaultGate, + }, + }, }; +use chacha20poly1305::{AeadInPlace, XChaCha20Poly1305, XNonce, aead::KeyInit}; use diesel::{ExpressionMethods, QueryDsl, SelectableHelper, insert_into, sql_query}; use diesel_async::RunQueryDsl; use kameo::actor::Spawn as _; +use tokio::sync::oneshot; +use x25519_dalek::{EphemeralSecret, PublicKey}; #[tokio::test] #[test_log::test] @@ -511,3 +526,297 @@ async fn sleeping_recovery_operator_cannot_contribute_to_unseal() { "a sleeping recovery operator unsealed the vault" ); } + +type PromotionRx = oneshot::Receiver>; + +/// One `VaultGate` per authenticated role against a shared `GlobalActors`, which is what +/// `peers::operator::start` builds for two connected peers. +struct RoleGates { + ordinary: kameo::actor::ActorRef, + recovery: kameo::actor::ActorRef, + ordinary_id: i32, + recovery_id: i32, + /// Held only so the gates' promotion channels stay open for the fixture's lifetime. + _promotions: (PromotionRx, PromotionRx), +} + +/// Registers one ordinary and one recovery identity, then spawns a gate for each. +async fn spawn_role_gates(db: &db::DatabasePool, actors: &GlobalActors) -> RoleGates { + let ordinary_pubkey = authn::SigningKey::generate().public_key(); + let recovery_pubkey = authn::SigningKey::generate().public_key(); + + let ordinary_id: i32 = { + let mut conn = db.get().await.unwrap(); + insert_into(schema::operator_identity::table) + .values(schema::operator_identity::public_key.eq(ordinary_pubkey.to_bytes())) + .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(recovery_pubkey.to_bytes())) + .returning(schema::recovery_operator_identity::id) + .get_result(&mut conn) + .await + .unwrap() + }; + + let (ordinary_promotion_tx, ordinary_promotion_rx) = oneshot::channel(); + let ordinary = VaultGate::spawn(VaultGate::new( + AuthenticatedOperator::Ordinary(Credentials { + id: ordinary_id, + pubkey: ordinary_pubkey, + }), + actors.clone(), + db.clone(), + ordinary_promotion_tx, + )); + + let (recovery_promotion_tx, recovery_promotion_rx) = oneshot::channel(); + let recovery = VaultGate::spawn(VaultGate::new( + AuthenticatedOperator::Recovery(RecoveryCredentials { + id: recovery_id, + pubkey: recovery_pubkey, + }), + actors.clone(), + db.clone(), + recovery_promotion_tx, + )); + + RoleGates { + ordinary, + recovery, + ordinary_id, + recovery_id, + _promotions: (ordinary_promotion_rx, recovery_promotion_rx), + } +} + +/// Runs the gate's X25519 handshake and encrypts `seal_key` to the shared secret, producing the +/// message a peer would send to bootstrap the vault. Mirrors `tests/operator/unseal.rs`'s +/// `client_dh_encrypt`, which does the same for the unseal side. +async fn bootstrap_key_for( + gate: &kameo::actor::ActorRef, + seal_key: &[u8; 32], +) -> HandleBootstrapEncryptedKey { + let client_secret = EphemeralSecret::random(); + let client_public = PublicKey::from(&client_secret); + + let response = gate + .ask(HandleHandshake { + client_pubkey: client_public, + }) + .await + .unwrap(); + + let shared_secret = client_secret.diffie_hellman(&response.server_pubkey); + let cipher = XChaCha20Poly1305::new(shared_secret.as_bytes().into()); + let nonce = XNonce::from([0u8; 24]); + let associated_data = b"bootstrap"; + let mut ciphertext = seal_key.to_vec(); + cipher + .encrypt_in_place(&nonce, associated_data, &mut ciphertext) + .unwrap(); + + HandleBootstrapEncryptedKey { + nonce: nonce.to_vec(), + ciphertext, + associated_data: associated_data.to_vec(), + } +} + +/// Asserts a gate turned a request down on the peer's role rather than on anything else -- +/// notably not on coordinator state, which is what an unguarded handler would have reported. +#[track_caller] +fn assert_role_refused( + what: &str, + result: Result>, +) { + match result { + Err(kameo::error::SendError::HandlerError(VaultGateError::RoleNotPermitted)) => {} + other => panic!("{what}: expected RoleNotPermitted, got {other:?}"), + } +} + +/// §3.5: which committee seat a passphrase fills is decided by the handshake, not by the +/// request, so neither role can spend the other's slot. +/// +/// Neither request carries an operator id, so the ordinary peer has nothing left to forge; the +/// point of running the bootstrap to completion afterwards is that its refusal left the +/// recovery seat empty rather than filling it under a chosen id. +#[tokio::test] +#[test_log::test] +async fn ordinary_operator_cannot_contribute_a_recovery_share() { + let db = db::create_test_pool().await; + let actors = common::spawn_actors(db.clone()).await; + let gates = spawn_role_gates(&db, &actors).await; + assert_eq!( + gates.ordinary_id, gates.recovery_id, + "the two ids must collide for the attestation check at the end to mean anything" + ); + + gates + .ordinary + .ask(HandleDeclareCommittee { + count: 1, + recovery_count: 1, + }) + .await + .unwrap(); + + assert_role_refused( + "an ordinary operator contributed a recovery share", + gates + .ordinary + .ask(HandleContributeRecoveryBootstrapPassphrase { + passphrase: b"forged-recovery-pass".to_vec(), + }) + .await, + ); + + assert_role_refused( + "a recovery operator contributed an ordinary share", + gates + .recovery + .ask(HandleContributeBootstrapPassphrase { + passphrase: b"forged-ordinary-pass".to_vec(), + }) + .await, + ); + + // The recovery seat is still empty: had the forged contribution landed, this one would come + // back as a duplicate instead of being accepted. + let done = gates + .recovery + .ask(HandleContributeRecoveryBootstrapPassphrase { + passphrase: b"recovery-pass".to_vec(), + }) + .await + .unwrap(); + assert!(!done, "the ordinary share is still outstanding"); + + let done = gates + .ordinary + .ask(HandleContributeBootstrapPassphrase { + passphrase: b"ordinary-pass".to_vec(), + }) + .await + .unwrap(); + assert!(done, "both seats are filled, so bootstrap must finalize"); + assert_eq!( + actors.vault.ask(GetState {}).await.unwrap(), + VaultState::Unsealed + ); + + // Both peers hold the same id in their own table (asserted above), so only the attestation + // kind tells the two envelopes apart. Two rows means the recovery peer signed as itself + // rather than overwriting the ordinary operator's attestation. + let kinds = common::eventually("both bootstrap attestations are written", || { + let db = db.clone(); + async move { + let mut conn = db.get().await.unwrap(); + let mut kinds: Vec = schema::integrity_envelope::table + .select(schema::integrity_envelope::entity_kind) + .load(&mut conn) + .await + .unwrap(); + kinds.sort(); + (kinds.len() == 2).then_some(kinds) + } + }) + .await; + assert_eq!( + kinds, + vec![ + "operator_credentials".to_owned(), + "recovery_operator_credentials".to_owned(), + ] + ); +} + +/// Every gate action that belongs to one role refuses the other, and refuses it before the +/// action takes effect. +/// +/// The vault is left unbootstrapped and the coordinator idle on purpose: an unguarded handler +/// would reach the vault or the coordinator and come back with `State`, `NotBootstrapping` or +/// `NotUnsealing`, so `RoleNotPermitted` can only come from the role check itself. +/// +/// §3.4/§3.5: `HandleBootstrapEncryptedKey` matters most here. It hands the vault a root key of +/// the peer's choosing, and the window it needs -- an unbootstrapped vault that already holds +/// recovery identity rows -- is exactly the state committee formation has to pass through. +#[tokio::test] +#[test_log::test] +async fn vault_gate_refuses_the_actions_of_the_other_role() { + let db = db::create_test_pool().await; + let actors = common::spawn_actors(db.clone()).await; + let gates = spawn_role_gates(&db, &actors).await; + + assert_role_refused( + "a recovery operator declared the committee", + gates + .recovery + .ask(HandleDeclareCommittee { + count: 1, + recovery_count: 1, + }) + .await, + ); + + // A key the vault would have accepted, negotiated through the gate's own handshake -- so + // the refusal comes from the role and not from a malformed request. + let seized_key = bootstrap_key_for(&gates.recovery, b"recovery-seized-32-byte-seal-key").await; + assert_role_refused( + "a recovery operator bootstrapped the vault", + gates.recovery.ask(seized_key).await, + ); + + assert_role_refused( + "a recovery operator contributed an ordinary bootstrap share", + gates + .recovery + .ask(HandleContributeBootstrapPassphrase { + passphrase: b"forged-ordinary-pass".to_vec(), + }) + .await, + ); + + assert_role_refused( + "an ordinary operator contributed a recovery bootstrap share", + gates + .ordinary + .ask(HandleContributeRecoveryBootstrapPassphrase { + passphrase: b"forged-recovery-pass".to_vec(), + }) + .await, + ); + + assert_role_refused( + "a recovery operator contributed an ordinary unseal share", + gates + .recovery + .ask(HandleContributeUnsealPassphrase { + passphrase: b"forged-ordinary-pass".to_vec(), + }) + .await, + ); + + assert_role_refused( + "an ordinary operator contributed a recovery unseal share", + gates + .ordinary + .ask(HandleContributeRecoveryUnsealPassphrase { + passphrase: b"forged-recovery-pass".to_vec(), + }) + .await, + ); + + // Nothing above took effect: the refusals came before the vault and the coordinator. + assert_eq!( + actors.vault.ask(GetState {}).await.unwrap(), + VaultState::Unbootstrapped, + "a refused request still reached the vault" + ); +}