feat(operator): authenticate recovery operators as a distinct peer type
This commit is contained in:
@@ -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,
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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? {
|
||||
|
||||
Reference in New Issue
Block a user