smlang generates a public state enum whose variants contain ChallengeContext, requiring the type itself to be fully public. Also tightens the wildcard arm in client auth to an exhaustive match.
355 lines
10 KiB
Rust
355 lines
10 KiB
Rust
use super::{ClientConnection, ClientCredentials, ClientProfile};
|
|
use crate::{
|
|
actors::{
|
|
GlobalActors,
|
|
flow_coordinator::{self, RequestClientApproval},
|
|
vault::Vault,
|
|
},
|
|
crypto::integrity::{self, AttestationStatus},
|
|
db::{
|
|
self,
|
|
models::{ProgramClientMetadata, SqliteTimestamp},
|
|
schema::program_client,
|
|
},
|
|
};
|
|
use arbiter_crypto::authn::{self, AuthChallenge, CLIENT_CONTEXT};
|
|
use arbiter_proto::{
|
|
ClientMetadata,
|
|
transport::{Bi, expect_message},
|
|
};
|
|
|
|
use chrono::Utc;
|
|
use diesel::{
|
|
ExpressionMethods as _, OptionalExtension as _, QueryDsl as _, SelectableHelper as _,
|
|
dsl::insert_into, update,
|
|
};
|
|
use diesel_async::RunQueryDsl as _;
|
|
use kameo::{actor::ActorRef, error::SendError};
|
|
use tracing::error;
|
|
|
|
#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
|
|
pub enum Error {
|
|
#[error("Database pool unavailable")]
|
|
DatabasePoolUnavailable,
|
|
#[error("Database operation failed")]
|
|
DatabaseOperationFailed,
|
|
#[error("Integrity check failed")]
|
|
IntegrityCheckFailed,
|
|
#[error("Invalid challenge solution")]
|
|
InvalidChallengeSolution,
|
|
#[error("Client approval request failed")]
|
|
ApproveError(#[from] ApproveError),
|
|
#[error("Transport error")]
|
|
Transport,
|
|
}
|
|
|
|
impl From<diesel::result::Error> for Error {
|
|
fn from(e: diesel::result::Error) -> Self {
|
|
error!(?e, "Database error");
|
|
Self::DatabaseOperationFailed
|
|
}
|
|
}
|
|
|
|
#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
|
|
pub enum ApproveError {
|
|
#[error("Internal error")]
|
|
Internal,
|
|
#[error("Client connection denied by operators")]
|
|
Denied,
|
|
#[error("Upstream error: {0}")]
|
|
Upstream(flow_coordinator::ApprovalError),
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub enum Inbound {
|
|
AuthChallengeRequest {
|
|
pubkey: authn::PublicKey,
|
|
metadata: ClientMetadata,
|
|
},
|
|
AuthChallengeSolution {
|
|
signature: authn::Signature,
|
|
},
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub enum Outbound {
|
|
AuthChallenge { challenge: AuthChallenge },
|
|
AuthSuccess,
|
|
}
|
|
|
|
async fn get_client_id(
|
|
db: &db::DatabasePool,
|
|
pubkey: &authn::PublicKey,
|
|
) -> Result<Option<i32>, Error> {
|
|
let pubkey_bytes = pubkey.to_bytes();
|
|
let mut conn = db.get().await.map_err(|e| {
|
|
error!(error = ?e, "Database pool error");
|
|
Error::DatabasePoolUnavailable
|
|
})?;
|
|
program_client::table
|
|
.filter(program_client::public_key.eq(&pubkey_bytes))
|
|
.select(program_client::id)
|
|
.first::<i32>(&mut conn)
|
|
.await
|
|
.optional()
|
|
.map_err(|e| {
|
|
error!(error = ?e, "Database error");
|
|
Error::DatabaseOperationFailed
|
|
})
|
|
}
|
|
|
|
async fn verify_integrity(
|
|
db: &db::DatabasePool,
|
|
vault: &ActorRef<Vault>,
|
|
pubkey: &authn::PublicKey,
|
|
) -> Result<(), Error> {
|
|
let mut db_conn = db.get().await.map_err(|e| {
|
|
error!(error = ?e, "Database pool error");
|
|
Error::DatabasePoolUnavailable
|
|
})?;
|
|
|
|
let id = get_client_id(db, pubkey).await?.ok_or_else(|| {
|
|
error!("Client not found during integrity verification");
|
|
Error::DatabaseOperationFailed
|
|
})?;
|
|
|
|
let attestation = integrity::verify_entity(
|
|
&mut db_conn,
|
|
vault,
|
|
&ClientCredentials {
|
|
pubkey: pubkey.clone(),
|
|
},
|
|
id,
|
|
)
|
|
.await
|
|
.map_err(|e| {
|
|
error!(?e, "Integrity verification failed");
|
|
Error::IntegrityCheckFailed
|
|
})?;
|
|
|
|
if attestation != AttestationStatus::Attested {
|
|
error!("Integrity attestation unavailable for client {id}");
|
|
return Err(Error::IntegrityCheckFailed);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn approve_new_client(actors: &GlobalActors, profile: ClientProfile) -> Result<(), Error> {
|
|
let result = actors
|
|
.flow_coordinator
|
|
.ask(RequestClientApproval { client: profile })
|
|
.await;
|
|
|
|
match result {
|
|
Ok(true) => Ok(()),
|
|
Ok(false) => Err(Error::ApproveError(ApproveError::Denied)),
|
|
Err(SendError::HandlerError(e)) => {
|
|
error!(error = ?e, "Approval upstream error");
|
|
Err(Error::ApproveError(ApproveError::Upstream(e)))
|
|
}
|
|
Err(e) => {
|
|
error!(error = ?e, "Approval request to flow coordinator failed");
|
|
Err(Error::ApproveError(ApproveError::Internal))
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn insert_client(
|
|
db: &db::DatabasePool,
|
|
vault: &ActorRef<Vault>,
|
|
pubkey: &authn::PublicKey,
|
|
metadata: &ClientMetadata,
|
|
) -> Result<i32, Error> {
|
|
use crate::db::schema::client_metadata;
|
|
|
|
let pubkey = pubkey.clone();
|
|
let metadata = metadata.clone();
|
|
|
|
let mut conn = db.get().await.map_err(|e| {
|
|
error!(error = ?e, "Database pool error");
|
|
Error::DatabasePoolUnavailable
|
|
})?;
|
|
|
|
conn.exclusive_transaction(async |conn| {
|
|
let metadata_id = insert_into(client_metadata::table)
|
|
.values((
|
|
client_metadata::name.eq(&metadata.name),
|
|
client_metadata::description.eq(&metadata.description),
|
|
client_metadata::version.eq(&metadata.version),
|
|
))
|
|
.returning(client_metadata::id)
|
|
.get_result::<i32>(&mut *conn)
|
|
.await?;
|
|
|
|
let client_id = insert_into(program_client::table)
|
|
.values((
|
|
program_client::public_key.eq(pubkey.to_bytes()),
|
|
program_client::metadata_id.eq(metadata_id),
|
|
))
|
|
.on_conflict_do_nothing()
|
|
.returning(program_client::id)
|
|
.get_result::<i32>(&mut *conn)
|
|
.await?;
|
|
|
|
integrity::sign_entity(
|
|
&mut *conn,
|
|
vault,
|
|
&ClientCredentials {
|
|
pubkey: pubkey.clone(),
|
|
},
|
|
client_id,
|
|
)
|
|
.await
|
|
.map_err(|e| {
|
|
error!(error = ?e, "Failed to sign integrity tag for new client key");
|
|
Error::DatabaseOperationFailed
|
|
})?;
|
|
|
|
Ok(client_id)
|
|
})
|
|
.await
|
|
}
|
|
|
|
async fn sync_client_metadata(
|
|
db: &db::DatabasePool,
|
|
client_id: i32,
|
|
metadata: &ClientMetadata,
|
|
) -> Result<(), Error> {
|
|
use crate::db::schema::{client_metadata, client_metadata_history};
|
|
|
|
let now = SqliteTimestamp(Utc::now());
|
|
|
|
let mut conn = db.get().await.map_err(|e| {
|
|
error!(error = ?e, "Database pool error");
|
|
Error::DatabasePoolUnavailable
|
|
})?;
|
|
|
|
conn.exclusive_transaction(async |conn| {
|
|
let (current_metadata_id, current): (i32, ProgramClientMetadata) = program_client::table
|
|
.find(client_id)
|
|
.inner_join(client_metadata::table)
|
|
.select((
|
|
program_client::metadata_id,
|
|
ProgramClientMetadata::as_select(),
|
|
))
|
|
.first(&mut *conn)
|
|
.await?;
|
|
|
|
let unchanged = current.name == metadata.name
|
|
&& current.description == metadata.description
|
|
&& current.version == metadata.version;
|
|
if unchanged {
|
|
return Ok(());
|
|
}
|
|
|
|
insert_into(client_metadata_history::table)
|
|
.values((
|
|
client_metadata_history::metadata_id.eq(current_metadata_id),
|
|
client_metadata_history::client_id.eq(client_id),
|
|
))
|
|
.execute(&mut *conn)
|
|
.await?;
|
|
|
|
let metadata_id = insert_into(client_metadata::table)
|
|
.values((
|
|
client_metadata::name.eq(&metadata.name),
|
|
client_metadata::description.eq(&metadata.description),
|
|
client_metadata::version.eq(&metadata.version),
|
|
))
|
|
.returning(client_metadata::id)
|
|
.get_result::<i32>(&mut *conn)
|
|
.await?;
|
|
|
|
update(program_client::table.find(client_id))
|
|
.set((
|
|
program_client::metadata_id.eq(metadata_id),
|
|
program_client::updated_at.eq(now),
|
|
))
|
|
.execute(&mut *conn)
|
|
.await?;
|
|
|
|
Ok::<(), diesel::result::Error>(())
|
|
})
|
|
.await
|
|
.map_err(|e| {
|
|
error!(error = ?e, "Database error");
|
|
Error::DatabaseOperationFailed
|
|
})
|
|
}
|
|
|
|
async fn challenge_client<T>(
|
|
transport: &mut T,
|
|
pubkey: authn::PublicKey,
|
|
challenge: AuthChallenge,
|
|
) -> Result<(), Error>
|
|
where
|
|
T: Bi<Inbound, Result<Outbound, Error>> + ?Sized,
|
|
{
|
|
transport
|
|
.send(Ok(Outbound::AuthChallenge {
|
|
challenge: challenge.clone(),
|
|
}))
|
|
.await
|
|
.map_err(|e| {
|
|
error!(error = ?e, "Failed to send auth challenge");
|
|
Error::Transport
|
|
})?;
|
|
|
|
let signature = expect_message(transport, |req: Inbound| match req {
|
|
Inbound::AuthChallengeSolution { signature } => Some(signature),
|
|
Inbound::AuthChallengeRequest { .. } => None,
|
|
})
|
|
.await
|
|
.map_err(|e| {
|
|
error!(error = ?e, "Failed to receive challenge solution");
|
|
Error::Transport
|
|
})?;
|
|
|
|
if !pubkey.verify(&challenge, CLIENT_CONTEXT, &signature) {
|
|
error!("Challenge solution verification failed");
|
|
return Err(Error::InvalidChallengeSolution);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn authenticate<T>(props: &mut ClientConnection, transport: &mut T) -> Result<i32, Error>
|
|
where
|
|
T: Bi<Inbound, Result<Outbound, Error>> + Send + ?Sized,
|
|
{
|
|
let Some(Inbound::AuthChallengeRequest { pubkey, metadata }) = transport.recv().await else {
|
|
return Err(Error::Transport);
|
|
};
|
|
|
|
let client_id = if let Some(id) = get_client_id(&props.db, &pubkey).await? {
|
|
verify_integrity(&props.db, &props.actors.vault, &pubkey).await?;
|
|
id
|
|
} else {
|
|
approve_new_client(
|
|
&props.actors,
|
|
ClientProfile {
|
|
pubkey: pubkey.clone(),
|
|
metadata: metadata.clone(),
|
|
},
|
|
)
|
|
.await?;
|
|
insert_client(&props.db, &props.actors.vault, &pubkey, &metadata).await?
|
|
};
|
|
|
|
sync_client_metadata(&props.db, client_id, &metadata).await?;
|
|
|
|
let challenge = AuthChallenge::generate(&mut rand::rng());
|
|
challenge_client(transport, pubkey, challenge).await?;
|
|
|
|
transport
|
|
.send(Ok(Outbound::AuthSuccess))
|
|
.await
|
|
.map_err(|e| {
|
|
error!(error = ?e, "Failed to send auth success");
|
|
Error::Transport
|
|
})?;
|
|
|
|
Ok(client_id)
|
|
}
|