feat(operator): authenticate recovery operators as a distinct peer type

This commit is contained in:
CleverWild
2026-09-07 18:54:00 +02:00
parent 2b02d4a9b1
commit 8d25d6640b
4 changed files with 359 additions and 28 deletions

View File

@@ -1,4 +1,4 @@
use super::{Credentials, OperatorConnection};
use super::{AuthenticatedOperator, OperatorConnection};
use arbiter_crypto::authn::{self, AuthChallenge};
use arbiter_proto::transport::Bi;
@@ -71,7 +71,7 @@ fn parse_auth_event(payload: Inbound) -> AuthEvents {
pub async fn authenticate<T>(
props: &mut OperatorConnection,
transport: &mut T,
) -> Result<Credentials, Error>
) -> Result<AuthenticatedOperator, Error>
where
T: Bi<Inbound, Result<Outbound, Error>> + Send + ?Sized,
{

View File

@@ -1,12 +1,12 @@
use super::{
super::{Credentials, OperatorConnection},
super::{AuthenticatedOperator, Credentials, OperatorConnection, RecoveryCredentials},
Error,
};
use crate::{
actors::bootstrap::VerifyToken,
db::{
DatabasePool,
schema::{arbiter_settings, operator_identity},
schema::{arbiter_settings, operator_identity, recovery_operator_identity},
},
peers::operator::auth::Outbound,
};
@@ -37,7 +37,7 @@ smlang::statemachine!(
custom_error: true,
transitions: {
*Init + AuthRequest(ChallengeRequest) / async prepare_challenge = SentChallenge(ChallengeContext),
SentChallenge(ChallengeContext) + ReceivedSolution(ChallengeSolution) / async verify_solution = AuthOk(Credentials),
SentChallenge(ChallengeContext) + ReceivedSolution(ChallengeSolution) / async verify_solution = AuthOk(AuthenticatedOperator),
}
);
@@ -59,6 +59,27 @@ async fn get_client_id(db: &DatabasePool, pubkey: &authn::PublicKey) -> Result<O
})
}
async fn get_recovery_operator_id(
db: &DatabasePool,
pubkey: &authn::PublicKey,
) -> Result<Option<i32>, Error> {
let mut conn = db.get().await.map_err(|e| {
error!(error = ?e, "Database pool error");
Error::internal("Database unavailable")
})?;
recovery_operator_identity::table
.filter(recovery_operator_identity::public_key.eq(pubkey.to_bytes()))
.select(recovery_operator_identity::id)
.first::<i32>(&mut conn)
.await
.optional()
.map_err(|e| {
error!(error = ?e, "Database error");
Error::internal("Database operation failed")
})
}
async fn register_key(db: &DatabasePool, pubkey: &authn::PublicKey) -> Result<i32, Error> {
let pubkey_bytes = pubkey.to_bytes();
let mut conn = db.get().await.map_err(|e| {
@@ -118,12 +139,14 @@ where
bootstrap_token,
}: ChallengeRequest,
) -> Result<ChallengeContext, Self::Error> {
// Verify pubkey is registered (unless bootstrapping)
if bootstrap_token.is_none() {
let id = get_client_id(&self.conn.db, &pubkey).await?;
if id.is_none() {
return Err(Error::UnregisteredPublicKey);
}
// Verify pubkey is registered in either identity table (unless bootstrapping)
if bootstrap_token.is_none()
&& get_client_id(&self.conn.db, &pubkey).await?.is_none()
&& get_recovery_operator_id(&self.conn.db, &pubkey)
.await?
.is_none()
{
return Err(Error::UnregisteredPublicKey);
}
let challenge = AuthChallenge::generate(&mut rand::rng());
@@ -153,7 +176,7 @@ where
bootstrap_token,
}: &ChallengeContext,
ChallengeSolution { solution }: ChallengeSolution,
) -> Result<Credentials, Self::Error> {
) -> Result<AuthenticatedOperator, Self::Error> {
let signature = authn::Signature::try_from(solution.as_slice()).map_err(|()| {
error!("Failed to decode signature in challenge solution");
Error::InvalidChallengeSolution
@@ -169,8 +192,9 @@ where
return Err(Error::InvalidChallengeSolution);
}
// Resolve client id: bootstrap (verify token, then register) or lookup
let id = match bootstrap_token {
// Resolve the peer's role: bootstrap (verify token, then register as an ordinary
// operator) or look the key up in whichever identity table holds it.
let authenticated = match bootstrap_token {
Some(token) => {
let token_ok: bool = self
.conn
@@ -194,7 +218,7 @@ where
return Err(Error::InvalidBootstrapToken);
}
match register_key(&self.conn.db, pubkey).await {
let id = match register_key(&self.conn.db, pubkey).await {
Ok(id) => id,
// `register_key` refuses a token that verified here but lost the race
// against bootstrap. Reported to the peer exactly like the refusal above,
@@ -208,11 +232,38 @@ where
return Err(Error::InvalidBootstrapToken);
}
Err(err) => return Err(err),
};
AuthenticatedOperator::Ordinary(Credentials {
id,
pubkey: pubkey.clone(),
})
}
None => {
if let Some(id) = get_client_id(&self.conn.db, pubkey).await? {
AuthenticatedOperator::Ordinary(Credentials {
id,
pubkey: pubkey.clone(),
})
} else {
// `prepare_challenge` already found the key in one of the tables, so
// arriving here means it was removed mid-handshake. Reported to the peer
// for the same reason `InvalidBootstrapToken` is above: an operator that
// has sent its solution sees a protocol error rather than a handshake that
// stops with nothing on the wire.
let Some(id) = get_recovery_operator_id(&self.conn.db, pubkey).await? else {
self.transport
.send(Err(Error::UnregisteredPublicKey))
.await
.map_err(|_| Error::Transport)?;
return Err(Error::UnregisteredPublicKey);
};
AuthenticatedOperator::Recovery(RecoveryCredentials {
id,
pubkey: pubkey.clone(),
})
}
}
None => get_client_id(&self.conn.db, pubkey)
.await?
.ok_or(Error::UnregisteredPublicKey)?,
};
self.transport
@@ -220,9 +271,6 @@ where
.await
.map_err(|_| Error::Transport)?;
Ok(Credentials {
id,
pubkey: pubkey.clone(),
})
Ok(authenticated)
}
}

View File

@@ -33,6 +33,37 @@ impl Integrable for Credentials {
const KIND: &'static str = "operator_credentials";
}
/// §3.5: recovery operators are a separate peer type with their own identity table. Their
/// attestation kind differs from an ordinary operator's so the two id spaces cannot collide.
#[derive(Debug, Clone, Hashable)]
pub struct RecoveryCredentials {
pub id: i32,
pub pubkey: authn::PublicKey,
}
impl Integrable for RecoveryCredentials {
const KIND: &'static str = "recovery_operator_credentials";
}
/// The outcome of an operator handshake. The variant, not a field, decides what the peer may
/// do — so no call site can pass an unauthenticated recovery id.
#[derive(Debug, Clone)]
pub enum AuthenticatedOperator {
Ordinary(Credentials),
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 {
@@ -168,7 +199,16 @@ where
T: Bi<auth::Inbound, Result<auth::Outbound, auth::Error>> + Send,
T: Bi<vault_gate::Inbound, Result<vault_gate::Outbound, vault_gate::Error>> + Send,
{
let creds = 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
if should_run_gate(&props.actors.vault).await? {