fix(vault): derive the recovery operator id from the authenticated peer
This commit is contained in:
@@ -18,8 +18,7 @@ message ContributePassphrase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
message ContributeRecoveryPassphrase {
|
message ContributeRecoveryPassphrase {
|
||||||
int32 recovery_operator_id = 1;
|
bytes passphrase = 1;
|
||||||
bytes passphrase = 2;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum BootstrapResult {
|
enum BootstrapResult {
|
||||||
|
|||||||
@@ -7,8 +7,7 @@ message ContributePassphrase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
message ContributeRecoveryPassphrase {
|
message ContributeRecoveryPassphrase {
|
||||||
int32 recovery_operator_id = 1;
|
bytes passphrase = 1;
|
||||||
bytes passphrase = 2;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum RekeyResult {
|
enum RekeyResult {
|
||||||
|
|||||||
@@ -20,8 +20,7 @@ message ContributePassphrase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
message ContributeRecoveryPassphrase {
|
message ContributeRecoveryPassphrase {
|
||||||
int32 recovery_operator_id = 1;
|
bytes passphrase = 1;
|
||||||
bytes passphrase = 2;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum UnsealResult {
|
enum UnsealResult {
|
||||||
|
|||||||
@@ -129,14 +129,23 @@ pub async fn start(
|
|||||||
let (oob_sender, oob_receiver) = mpsc::channel(16);
|
let (oob_sender, oob_receiver) = mpsc::channel(16);
|
||||||
let oob_adapter = OutOfBandAdapter(oob_sender);
|
let oob_adapter = OutOfBandAdapter(oob_sender);
|
||||||
|
|
||||||
let actor = {
|
let started = {
|
||||||
let transport = auth::AuthTransportAdapter::new(&mut bi, &mut request_tracker);
|
let transport = auth::AuthTransportAdapter::new(&mut bi, &mut request_tracker);
|
||||||
match crate::peers::operator::start(&mut conn, transport, Box::new(oob_adapter)).await {
|
crate::peers::operator::start(&mut conn, transport, Box::new(oob_adapter)).await
|
||||||
Ok(actor) => actor,
|
};
|
||||||
Err(e) => {
|
|
||||||
warn!(error = ?e, "Operator connection failed");
|
let actor = match started {
|
||||||
return;
|
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;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,12 @@ use crate::{
|
|||||||
actors::vault::VaultState,
|
actors::vault::VaultState,
|
||||||
peers::operator::{
|
peers::operator::{
|
||||||
OperatorSession,
|
OperatorSession,
|
||||||
session::handlers::{
|
session::{
|
||||||
HandleContributeRecoveryRekeyPassphrase, HandleContributeRekeyPassphrase,
|
Error as SessionError,
|
||||||
HandleQueryVaultState,
|
handlers::{
|
||||||
|
HandleContributeRecoveryRekeyPassphrase, HandleContributeRekeyPassphrase,
|
||||||
|
HandleQueryVaultState,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -21,7 +24,7 @@ use arbiter_proto::{
|
|||||||
proto::shared::VaultState as ProtoVaultState,
|
proto::shared::VaultState as ProtoVaultState,
|
||||||
};
|
};
|
||||||
|
|
||||||
use kameo::actor::ActorRef;
|
use kameo::{actor::ActorRef, error::SendError};
|
||||||
use tonic::Status;
|
use tonic::Status;
|
||||||
use tracing::warn;
|
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(
|
async fn handle_rekey(
|
||||||
actor: &ActorRef<OperatorSession>,
|
actor: &ActorRef<OperatorSession>,
|
||||||
req: proto_rekey::Request,
|
req: proto_rekey::Request,
|
||||||
@@ -66,20 +83,13 @@ async fn handle_rekey(
|
|||||||
passphrase: cp.passphrase,
|
passphrase: cp.passphrase,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| rekey_status(e, "Rekey contribution failed"))?,
|
||||||
warn!(?e, "rekey passphrase contribution failed");
|
|
||||||
Status::internal("Rekey contribution failed")
|
|
||||||
})?,
|
|
||||||
RekeyPayload::ContributeRecoveryPassphrase(crp) => actor
|
RekeyPayload::ContributeRecoveryPassphrase(crp) => actor
|
||||||
.ask(HandleContributeRecoveryRekeyPassphrase {
|
.ask(HandleContributeRecoveryRekeyPassphrase {
|
||||||
recovery_operator_id: crp.recovery_operator_id,
|
|
||||||
passphrase: crp.passphrase,
|
passphrase: crp.passphrase,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| rekey_status(e, "Rekey recovery contribution failed"))?,
|
||||||
warn!(?e, "rekey recovery passphrase contribution failed");
|
|
||||||
Status::internal("Rekey recovery contribution failed")
|
|
||||||
})?,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let proto_result = if done {
|
let proto_result = if done {
|
||||||
|
|||||||
@@ -89,7 +89,6 @@ impl TryConvert for UnsealRequestPayload {
|
|||||||
Self::ContributeRecoveryPassphrase(crp) => Ok(
|
Self::ContributeRecoveryPassphrase(crp) => Ok(
|
||||||
vault_gate::Inbound::HandleContributeRecoveryUnsealPassphrase(
|
vault_gate::Inbound::HandleContributeRecoveryUnsealPassphrase(
|
||||||
HandleContributeRecoveryUnsealPassphrase {
|
HandleContributeRecoveryUnsealPassphrase {
|
||||||
recovery_operator_id: crp.recovery_operator_id,
|
|
||||||
passphrase: crp.passphrase,
|
passphrase: crp.passphrase,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -157,7 +156,6 @@ impl TryConvert for BootstrapRequestPayload {
|
|||||||
Self::ContributeRecoveryPassphrase(crp) => Ok(
|
Self::ContributeRecoveryPassphrase(crp) => Ok(
|
||||||
vault_gate::Inbound::HandleContributeRecoveryBootstrapPassphrase(
|
vault_gate::Inbound::HandleContributeRecoveryBootstrapPassphrase(
|
||||||
HandleContributeRecoveryBootstrapPassphrase {
|
HandleContributeRecoveryBootstrapPassphrase {
|
||||||
recovery_operator_id: crp.recovery_operator_id,
|
|
||||||
passphrase: crp.passphrase,
|
passphrase: crp.passphrase,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -103,6 +103,9 @@ impl TryConvert for vault_gate::Outbound {
|
|||||||
Err(vault_gate::Error::AlreadyBootstrapped) => {
|
Err(vault_gate::Error::AlreadyBootstrapped) => {
|
||||||
ProtoBootstrapResult::AlreadyBootstrapped
|
ProtoBootstrapResult::AlreadyBootstrapped
|
||||||
}
|
}
|
||||||
|
Err(err @ vault_gate::Error::RoleNotPermitted) => {
|
||||||
|
return Err(Status::permission_denied(err.to_string()));
|
||||||
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
warn!(?err, "bootstrap failed");
|
warn!(?err, "bootstrap failed");
|
||||||
return Err(Status::internal("Failed to bootstrap vault"));
|
return Err(Status::internal("Failed to bootstrap vault"));
|
||||||
@@ -113,6 +116,11 @@ impl TryConvert for vault_gate::Outbound {
|
|||||||
Self::HandleDeclareCommittee(result) => {
|
Self::HandleDeclareCommittee(result) => {
|
||||||
let proto_result = match result {
|
let proto_result = match result {
|
||||||
Ok(()) => ProtoBootstrapResult::Success,
|
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) => {
|
Err(err) => {
|
||||||
warn!(?err, "declare committee failed");
|
warn!(?err, "declare committee failed");
|
||||||
return Err(Status::internal("Failed to declare committee"));
|
return Err(Status::internal("Failed to declare committee"));
|
||||||
@@ -124,6 +132,9 @@ impl TryConvert for vault_gate::Outbound {
|
|||||||
let proto_result = match result {
|
let proto_result = match result {
|
||||||
Ok(true) => ProtoBootstrapResult::Success,
|
Ok(true) => ProtoBootstrapResult::Success,
|
||||||
Ok(false) => ProtoBootstrapResult::AwaitingContributions,
|
Ok(false) => ProtoBootstrapResult::AwaitingContributions,
|
||||||
|
Err(err @ vault_gate::Error::RoleNotPermitted) => {
|
||||||
|
return Err(Status::permission_denied(err.to_string()));
|
||||||
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
warn!(?err, "contribute bootstrap passphrase failed");
|
warn!(?err, "contribute bootstrap passphrase failed");
|
||||||
return Err(Status::internal("Failed to contribute bootstrap passphrase"));
|
return Err(Status::internal("Failed to contribute bootstrap passphrase"));
|
||||||
@@ -135,6 +146,9 @@ impl TryConvert for vault_gate::Outbound {
|
|||||||
let proto_result = match result {
|
let proto_result = match result {
|
||||||
Ok(true) => ProtoBootstrapResult::Success,
|
Ok(true) => ProtoBootstrapResult::Success,
|
||||||
Ok(false) => ProtoBootstrapResult::AwaitingContributions,
|
Ok(false) => ProtoBootstrapResult::AwaitingContributions,
|
||||||
|
Err(err @ vault_gate::Error::RoleNotPermitted) => {
|
||||||
|
return Err(Status::permission_denied(err.to_string()));
|
||||||
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
warn!(?err, "contribute recovery bootstrap passphrase failed");
|
warn!(?err, "contribute recovery bootstrap passphrase failed");
|
||||||
return Err(Status::internal(
|
return Err(Status::internal(
|
||||||
@@ -148,6 +162,9 @@ impl TryConvert for vault_gate::Outbound {
|
|||||||
let proto_result = match result {
|
let proto_result = match result {
|
||||||
Ok(true) => ProtoUnsealResult::Success,
|
Ok(true) => ProtoUnsealResult::Success,
|
||||||
Ok(false) => ProtoUnsealResult::AwaitingContributions,
|
Ok(false) => ProtoUnsealResult::AwaitingContributions,
|
||||||
|
Err(err @ vault_gate::Error::RoleNotPermitted) => {
|
||||||
|
return Err(Status::permission_denied(err.to_string()));
|
||||||
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
warn!(?err, "contribute unseal passphrase failed");
|
warn!(?err, "contribute unseal passphrase failed");
|
||||||
return Err(Status::internal("Failed to contribute unseal passphrase"));
|
return Err(Status::internal("Failed to contribute unseal passphrase"));
|
||||||
@@ -161,6 +178,9 @@ impl TryConvert for vault_gate::Outbound {
|
|||||||
let proto_result = match result {
|
let proto_result = match result {
|
||||||
Ok(true) => ProtoUnsealResult::Success,
|
Ok(true) => ProtoUnsealResult::Success,
|
||||||
Ok(false) => ProtoUnsealResult::AwaitingContributions,
|
Ok(false) => ProtoUnsealResult::AwaitingContributions,
|
||||||
|
Err(err @ vault_gate::Error::RoleNotPermitted) => {
|
||||||
|
return Err(Status::permission_denied(err.to_string()));
|
||||||
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
warn!(?err, "contribute recovery unseal passphrase failed");
|
warn!(?err, "contribute recovery unseal passphrase failed");
|
||||||
return Err(Status::internal(
|
return Err(Status::internal(
|
||||||
|
|||||||
@@ -240,6 +240,13 @@ where
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
None => {
|
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? {
|
if let Some(id) = get_client_id(&self.conn.db, pubkey).await? {
|
||||||
AuthenticatedOperator::Ordinary(Credentials {
|
AuthenticatedOperator::Ordinary(Credentials {
|
||||||
id,
|
id,
|
||||||
|
|||||||
@@ -53,17 +53,6 @@ pub enum AuthenticatedOperator {
|
|||||||
Recovery(RecoveryCredentials),
|
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
|
// Messages, sent by operator to connection client without having a request
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum OutOfBand {
|
pub enum OutOfBand {
|
||||||
@@ -93,6 +82,12 @@ pub enum Error {
|
|||||||
Transport,
|
Transport,
|
||||||
#[error("database error: {0}")]
|
#[error("database error: {0}")]
|
||||||
Database(DatabaseError),
|
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}")]
|
#[error("internal: {0}")]
|
||||||
Internal(String),
|
Internal(String),
|
||||||
}
|
}
|
||||||
@@ -139,7 +134,7 @@ async fn should_run_gate(vault: &ActorRef<Vault>) -> Result<bool, Error> {
|
|||||||
async fn run_vault_gate<T>(
|
async fn run_vault_gate<T>(
|
||||||
props: &OperatorConnection,
|
props: &OperatorConnection,
|
||||||
transport: &mut T,
|
transport: &mut T,
|
||||||
auth_creds: Credentials,
|
auth_creds: AuthenticatedOperator,
|
||||||
) -> Result<(), Error>
|
) -> Result<(), Error>
|
||||||
where
|
where
|
||||||
T: Bi<vault_gate::Inbound, Result<vault_gate::Outbound, vault_gate::Error>> + Send + ?Sized,
|
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?;
|
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
|
// should run vault gate only if sealed / unbootstrapped
|
||||||
if should_run_gate(&props.actors.vault).await? {
|
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
|
// 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(
|
Ok(OperatorSession::spawn(OperatorSession::new(
|
||||||
props.clone(),
|
props.clone(),
|
||||||
creds.clone(),
|
authenticated.clone(),
|
||||||
oob_sender,
|
oob_sender,
|
||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -312,7 +312,7 @@ impl OperatorSession {
|
|||||||
ttl_secs: Option<u32>,
|
ttl_secs: Option<u32>,
|
||||||
) -> Result<ProposalId, Error> {
|
) -> Result<ProposalId, Error> {
|
||||||
use crate::actors::proposal_manager::CreateProposal;
|
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
|
self.props
|
||||||
.actors
|
.actors
|
||||||
.proposal_manager
|
.proposal_manager
|
||||||
@@ -332,7 +332,10 @@ impl OperatorSession {
|
|||||||
signature: Vec<u8>,
|
signature: Vec<u8>,
|
||||||
) -> Result<crate::actors::proposal_manager::VoteOutcome, crate::actors::proposal_manager::Error> {
|
) -> Result<crate::actors::proposal_manager::VoteOutcome, crate::actors::proposal_manager::Error> {
|
||||||
use crate::actors::proposal_manager::CastVote;
|
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
|
self.props
|
||||||
.actors
|
.actors
|
||||||
.proposal_manager
|
.proposal_manager
|
||||||
@@ -349,7 +352,11 @@ impl OperatorSession {
|
|||||||
&mut self,
|
&mut self,
|
||||||
) -> Vec<crate::actors::proposal_manager::ProposalSummary> {
|
) -> Vec<crate::actors::proposal_manager::ProposalSummary> {
|
||||||
use crate::actors::proposal_manager::QueryPending;
|
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
|
self.props
|
||||||
.actors
|
.actors
|
||||||
.proposal_manager
|
.proposal_manager
|
||||||
@@ -369,7 +376,7 @@ impl OperatorSession {
|
|||||||
use crate::actors::vault_coordinator::ContributeRekey;
|
use crate::actors::vault_coordinator::ContributeRekey;
|
||||||
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
|
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
|
||||||
|
|
||||||
let operator_id = self.credentials.id;
|
let operator_id = self.ordinary_id()?;
|
||||||
self.props
|
self.props
|
||||||
.actors
|
.actors
|
||||||
.vault_coordinator
|
.vault_coordinator
|
||||||
@@ -381,15 +388,23 @@ impl OperatorSession {
|
|||||||
.map_err(|_| Error::internal("VaultCoordinator unavailable"))
|
.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]
|
#[message]
|
||||||
pub(crate) async fn handle_contribute_recovery_rekey_passphrase(
|
pub(crate) async fn handle_contribute_recovery_rekey_passphrase(
|
||||||
&mut self,
|
&mut self,
|
||||||
recovery_operator_id: i32,
|
|
||||||
passphrase: Vec<u8>,
|
passphrase: Vec<u8>,
|
||||||
) -> Result<bool, Error> {
|
) -> Result<bool, Error> {
|
||||||
use crate::actors::vault_coordinator::ContributeRecoveryRekey;
|
use crate::actors::vault_coordinator::ContributeRecoveryRekey;
|
||||||
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
|
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
|
||||||
|
|
||||||
|
let recovery_operator_id = self.recovery_id()?;
|
||||||
self.props
|
self.props
|
||||||
.actors
|
.actors
|
||||||
.vault_coordinator
|
.vault_coordinator
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use super::{Credentials, OutOfBand, OperatorConnection};
|
use super::{AuthenticatedOperator, OutOfBand, OperatorConnection};
|
||||||
use crate::{
|
use crate::{
|
||||||
actors::{
|
actors::{
|
||||||
flow_coordinator::client_connect_approval::ClientApprovalController,
|
flow_coordinator::client_connect_approval::ClientApprovalController,
|
||||||
@@ -19,6 +19,11 @@ pub enum Error {
|
|||||||
#[error("State transition failed")]
|
#[error("State transition failed")]
|
||||||
State,
|
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}")]
|
#[error("Internal error: {message}")]
|
||||||
Internal { message: Cow<'static, str> },
|
Internal { message: Cow<'static, str> },
|
||||||
}
|
}
|
||||||
@@ -51,7 +56,7 @@ pub struct PendingClientApproval {
|
|||||||
|
|
||||||
pub struct OperatorSession {
|
pub struct OperatorSession {
|
||||||
props: OperatorConnection,
|
props: OperatorConnection,
|
||||||
credentials: Credentials,
|
credentials: AuthenticatedOperator,
|
||||||
sender: Box<dyn Sender<OutOfBand>>,
|
sender: Box<dyn Sender<OutOfBand>>,
|
||||||
|
|
||||||
pending_client_approvals: HashMap<Vec<u8>, PendingClientApproval>,
|
pending_client_approvals: HashMap<Vec<u8>, PendingClientApproval>,
|
||||||
@@ -60,7 +65,7 @@ pub struct OperatorSession {
|
|||||||
pub mod handlers;
|
pub mod handlers;
|
||||||
|
|
||||||
impl OperatorSession {
|
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 {
|
Self {
|
||||||
props,
|
props,
|
||||||
credentials,
|
credentials,
|
||||||
@@ -68,6 +73,25 @@ impl OperatorSession {
|
|||||||
pending_client_approvals: HashMap::default(),
|
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]
|
#[messages]
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use super::Credentials;
|
use super::AuthenticatedOperator;
|
||||||
use crate::{
|
use crate::{
|
||||||
actors::{
|
actors::{
|
||||||
GlobalActors,
|
GlobalActors,
|
||||||
@@ -36,6 +36,12 @@ pub enum Error {
|
|||||||
#[error("State transition failed")]
|
#[error("State transition failed")]
|
||||||
State,
|
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}")]
|
#[error("Internal error: {0}")]
|
||||||
Internal(String),
|
Internal(String),
|
||||||
}
|
}
|
||||||
@@ -50,7 +56,7 @@ pub struct HandshakeResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct VaultGate {
|
pub struct VaultGate {
|
||||||
pub auth_creds: Credentials,
|
pub auth_creds: AuthenticatedOperator,
|
||||||
pub promotion_tx: Option<oneshot::Sender<Result<(), Error>>>,
|
pub promotion_tx: Option<oneshot::Sender<Result<(), Error>>>,
|
||||||
pub state: State,
|
pub state: State,
|
||||||
pub actors: GlobalActors,
|
pub actors: GlobalActors,
|
||||||
@@ -59,7 +65,7 @@ pub struct VaultGate {
|
|||||||
|
|
||||||
impl VaultGate {
|
impl VaultGate {
|
||||||
pub fn new(
|
pub fn new(
|
||||||
auth_creds: Credentials,
|
auth_creds: AuthenticatedOperator,
|
||||||
actors: GlobalActors,
|
actors: GlobalActors,
|
||||||
db: DatabasePool,
|
db: DatabasePool,
|
||||||
promotion_tx: oneshot::Sender<Result<(), Error>>,
|
promotion_tx: oneshot::Sender<Result<(), Error>>,
|
||||||
@@ -100,6 +106,25 @@ impl Actor for VaultGate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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(
|
fn decrypt_key(
|
||||||
secret: &SharedSecret,
|
secret: &SharedSecret,
|
||||||
nonce: &[u8],
|
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]
|
#[message]
|
||||||
pub async fn handle_unseal_encrypted_key(
|
pub async fn handle_unseal_encrypted_key(
|
||||||
&mut self,
|
&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]
|
#[message]
|
||||||
pub async fn handle_bootstrap_encrypted_key(
|
pub async fn handle_bootstrap_encrypted_key(
|
||||||
&mut self,
|
&mut self,
|
||||||
@@ -192,6 +228,8 @@ impl VaultGate {
|
|||||||
ciphertext: Vec<u8>,
|
ciphertext: Vec<u8>,
|
||||||
associated_data: Vec<u8>,
|
associated_data: Vec<u8>,
|
||||||
) -> Result<(), Error> {
|
) -> Result<(), Error> {
|
||||||
|
let _ = self.ordinary_id()?;
|
||||||
|
|
||||||
let State::ReadyForExchange { secret, .. } = &self.state else {
|
let State::ReadyForExchange { secret, .. } = &self.state else {
|
||||||
return Err(Error::State);
|
return Err(Error::State);
|
||||||
};
|
};
|
||||||
@@ -242,10 +280,12 @@ impl VaultGate {
|
|||||||
count: usize,
|
count: usize,
|
||||||
recovery_count: usize,
|
recovery_count: usize,
|
||||||
) -> Result<(), Error> {
|
) -> Result<(), Error> {
|
||||||
|
let operator_id = self.ordinary_id()?;
|
||||||
|
|
||||||
self.actors
|
self.actors
|
||||||
.vault_coordinator
|
.vault_coordinator
|
||||||
.ask(StartBootstrap {
|
.ask(StartBootstrap {
|
||||||
operator_id: self.auth_creds.id,
|
operator_id,
|
||||||
declared_count: count,
|
declared_count: count,
|
||||||
recovery_count,
|
recovery_count,
|
||||||
})
|
})
|
||||||
@@ -258,11 +298,13 @@ impl VaultGate {
|
|||||||
&mut self,
|
&mut self,
|
||||||
passphrase: Vec<u8>,
|
passphrase: Vec<u8>,
|
||||||
) -> Result<bool, Error> {
|
) -> Result<bool, Error> {
|
||||||
|
let operator_id = self.ordinary_id()?;
|
||||||
|
|
||||||
let passphrase_cell = SafeCell::new(passphrase);
|
let passphrase_cell = SafeCell::new(passphrase);
|
||||||
self.actors
|
self.actors
|
||||||
.vault_coordinator
|
.vault_coordinator
|
||||||
.ask(ContributeBootstrap {
|
.ask(ContributeBootstrap {
|
||||||
operator_id: self.auth_creds.id,
|
operator_id,
|
||||||
passphrase: passphrase_cell,
|
passphrase: passphrase_cell,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
@@ -272,9 +314,10 @@ impl VaultGate {
|
|||||||
#[message]
|
#[message]
|
||||||
pub async fn handle_contribute_recovery_bootstrap_passphrase(
|
pub async fn handle_contribute_recovery_bootstrap_passphrase(
|
||||||
&mut self,
|
&mut self,
|
||||||
recovery_operator_id: i32,
|
|
||||||
passphrase: Vec<u8>,
|
passphrase: Vec<u8>,
|
||||||
) -> Result<bool, Error> {
|
) -> Result<bool, Error> {
|
||||||
|
let recovery_operator_id = self.recovery_id()?;
|
||||||
|
|
||||||
let passphrase_cell = SafeCell::new(passphrase);
|
let passphrase_cell = SafeCell::new(passphrase);
|
||||||
self.actors
|
self.actors
|
||||||
.vault_coordinator
|
.vault_coordinator
|
||||||
@@ -291,11 +334,13 @@ impl VaultGate {
|
|||||||
&mut self,
|
&mut self,
|
||||||
passphrase: Vec<u8>,
|
passphrase: Vec<u8>,
|
||||||
) -> Result<bool, Error> {
|
) -> Result<bool, Error> {
|
||||||
|
let operator_id = self.ordinary_id()?;
|
||||||
|
|
||||||
let passphrase_cell = SafeCell::new(passphrase);
|
let passphrase_cell = SafeCell::new(passphrase);
|
||||||
self.actors
|
self.actors
|
||||||
.vault_coordinator
|
.vault_coordinator
|
||||||
.ask(ContributeUnseal {
|
.ask(ContributeUnseal {
|
||||||
operator_id: self.auth_creds.id,
|
operator_id,
|
||||||
passphrase: passphrase_cell,
|
passphrase: passphrase_cell,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
@@ -305,9 +350,10 @@ impl VaultGate {
|
|||||||
#[message]
|
#[message]
|
||||||
pub async fn handle_contribute_recovery_unseal_passphrase(
|
pub async fn handle_contribute_recovery_unseal_passphrase(
|
||||||
&mut self,
|
&mut self,
|
||||||
recovery_operator_id: i32,
|
|
||||||
passphrase: Vec<u8>,
|
passphrase: Vec<u8>,
|
||||||
) -> Result<bool, Error> {
|
) -> Result<bool, Error> {
|
||||||
|
let recovery_operator_id = self.recovery_id()?;
|
||||||
|
|
||||||
let passphrase_cell = SafeCell::new(passphrase);
|
let passphrase_cell = SafeCell::new(passphrase);
|
||||||
self.actors
|
self.actors
|
||||||
.vault_coordinator
|
.vault_coordinator
|
||||||
@@ -334,13 +380,28 @@ impl Message<events::Bootstrapped> for VaultGate {
|
|||||||
.get()
|
.get()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| Error::internal("DB unavailable"))?;
|
.map_err(|_| Error::internal("DB unavailable"))?;
|
||||||
integrity::sign_entity(
|
// Each role signs under its own `Integrable::KIND`, so the two id spaces cannot
|
||||||
&mut conn,
|
// collide in `integrity_envelope`.
|
||||||
&self.actors.vault,
|
match &self.auth_creds {
|
||||||
&self.auth_creds,
|
AuthenticatedOperator::Ordinary(credentials) => {
|
||||||
self.auth_creds.id,
|
integrity::sign_entity(
|
||||||
)
|
&mut conn,
|
||||||
.await
|
&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| {
|
.map_err(|e| {
|
||||||
error!(?e, "Failed to sign integrity envelope on bootstrap");
|
error!(?e, "Failed to sign integrity envelope on bootstrap");
|
||||||
Error::internal("Integrity sign failed")
|
Error::internal("Integrity sign failed")
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use arbiter_server::{
|
|||||||
actors::vault::{Bootstrap, Seal},
|
actors::vault::{Bootstrap, Seal},
|
||||||
db,
|
db,
|
||||||
peers::operator::{
|
peers::operator::{
|
||||||
Credentials,
|
AuthenticatedOperator, Credentials,
|
||||||
vault_gate::{
|
vault_gate::{
|
||||||
Error as VaultGateError, HandleHandshake, HandleUnsealEncryptedKey, VaultGate,
|
Error as VaultGateError, HandleHandshake, HandleUnsealEncryptedKey, VaultGate,
|
||||||
},
|
},
|
||||||
@@ -37,7 +37,7 @@ async fn setup_sealed_gate(
|
|||||||
|
|
||||||
let (promotion_tx, promotion_rx) = oneshot::channel();
|
let (promotion_tx, promotion_rx) = oneshot::channel();
|
||||||
let pubkey = authn::SigningKey::generate().public_key();
|
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));
|
let gate = VaultGate::spawn(VaultGate::new(auth_creds, actors, db.clone(), promotion_tx));
|
||||||
|
|
||||||
(db, gate, promotion_rx)
|
(db, gate, promotion_rx)
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
use crate::common;
|
use crate::common;
|
||||||
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
|
use arbiter_crypto::{
|
||||||
|
authn,
|
||||||
|
safecell::{SafeCell, SafeCellHandle as _},
|
||||||
|
};
|
||||||
use arbiter_server::{
|
use arbiter_server::{
|
||||||
actors::{
|
actors::{
|
||||||
GlobalActors,
|
GlobalActors,
|
||||||
@@ -11,11 +14,23 @@ use arbiter_server::{
|
|||||||
},
|
},
|
||||||
crypto::{KeyCell, encryption::v1::{Nonce, ROOT_KEY_TAG}},
|
crypto::{KeyCell, encryption::v1::{Nonce, ROOT_KEY_TAG}},
|
||||||
db::{self, models, schema},
|
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::{ExpressionMethods, QueryDsl, SelectableHelper, insert_into, sql_query};
|
||||||
use diesel_async::RunQueryDsl;
|
use diesel_async::RunQueryDsl;
|
||||||
use kameo::actor::Spawn as _;
|
use kameo::actor::Spawn as _;
|
||||||
|
use tokio::sync::oneshot;
|
||||||
|
use x25519_dalek::{EphemeralSecret, PublicKey};
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[test_log::test]
|
#[test_log::test]
|
||||||
@@ -511,3 +526,297 @@ async fn sleeping_recovery_operator_cannot_contribute_to_unseal() {
|
|||||||
"a sleeping recovery operator unsealed the vault"
|
"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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user