WIP: feat-shamir (old) #103
@@ -18,8 +18,7 @@ message ContributePassphrase {
|
||||
}
|
||||
|
||||
message ContributeRecoveryPassphrase {
|
||||
int32 recovery_operator_id = 1;
|
||||
bytes passphrase = 2;
|
||||
bytes passphrase = 1;
|
||||
}
|
||||
|
||||
enum BootstrapResult {
|
||||
|
||||
@@ -7,8 +7,7 @@ message ContributePassphrase {
|
||||
}
|
||||
|
||||
message ContributeRecoveryPassphrase {
|
||||
int32 recovery_operator_id = 1;
|
||||
bytes passphrase = 2;
|
||||
bytes passphrase = 1;
|
||||
}
|
||||
|
||||
enum RekeyResult {
|
||||
|
||||
@@ -20,8 +20,7 @@ message ContributePassphrase {
|
||||
}
|
||||
|
||||
message ContributeRecoveryPassphrase {
|
||||
int32 recovery_operator_id = 1;
|
||||
bytes passphrase = 2;
|
||||
bytes passphrase = 1;
|
||||
}
|
||||
|
||||
enum UnsealResult {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user