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

@@ -18,8 +18,7 @@ message ContributePassphrase {
}
message ContributeRecoveryPassphrase {
int32 recovery_operator_id = 1;
bytes passphrase = 2;
bytes passphrase = 1;
}
enum BootstrapResult {

View File

@@ -7,8 +7,7 @@ message ContributePassphrase {
}
message ContributeRecoveryPassphrase {
int32 recovery_operator_id = 1;
bytes passphrase = 2;
bytes passphrase = 1;
}
enum RekeyResult {

View File

@@ -20,8 +20,7 @@ message ContributePassphrase {
}
message ContributeRecoveryPassphrase {
int32 recovery_operator_id = 1;
bytes passphrase = 2;
bytes passphrase = 1;
}
enum UnsealResult {

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")

View File

@@ -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)

View File

@@ -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<Result<(), VaultGateError>>;
/// 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<VaultGate>,
recovery: kameo::actor::ActorRef<VaultGate>,
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<VaultGate>,
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<T: std::fmt::Debug, M>(
what: &str,
result: Result<T, kameo::error::SendError<M, VaultGateError>>,
) {
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<String> = 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"
);
}