merge: refactor-integrity-check into main
This commit is contained in:
365
server/crates/arbiter-server/src/peers/client/auth.rs
Normal file
365
server/crates/arbiter-server/src/peers/client/auth.rs
Normal file
@@ -0,0 +1,365 @@
|
||||
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;
|
||||
|
||||
use crate::{
|
||||
actors::{
|
||||
GlobalActors,
|
||||
flow_coordinator::{self, RequestClientApproval},
|
||||
vault::Vault,
|
||||
},
|
||||
crypto::integrity::{self, AttestationStatus},
|
||||
db::{
|
||||
self,
|
||||
models::{ProgramClientMetadata, SqliteTimestamp},
|
||||
schema::program_client,
|
||||
},
|
||||
};
|
||||
|
||||
use super::{ClientConnection, ClientCredentials, ClientProfile};
|
||||
|
||||
#[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 user agents")]
|
||||
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, program_client};
|
||||
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(|conn| {
|
||||
let vault = vault.clone();
|
||||
let pubkey = pubkey.clone();
|
||||
Box::pin(async move {
|
||||
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>(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>(conn)
|
||||
.await?;
|
||||
|
||||
integrity::sign_entity(
|
||||
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(|conn| {
|
||||
let metadata = metadata.clone();
|
||||
Box::pin(async move {
|
||||
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(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(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>(conn)
|
||||
.await?;
|
||||
|
||||
update(program_client::table.find(client_id))
|
||||
.set((
|
||||
program_client::metadata_id.eq(metadata_id),
|
||||
program_client::updated_at.eq(now),
|
||||
))
|
||||
.execute(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),
|
||||
_ => 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 = match get_client_id(&props.db, &pubkey).await? {
|
||||
Some(id) => {
|
||||
verify_integrity(&props.db, &props.actors.vault, &pubkey).await?;
|
||||
id
|
||||
}
|
||||
None => {
|
||||
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)
|
||||
}
|
||||
56
server/crates/arbiter-server/src/peers/client/mod.rs
Normal file
56
server/crates/arbiter-server/src/peers/client/mod.rs
Normal file
@@ -0,0 +1,56 @@
|
||||
use arbiter_crypto::authn;
|
||||
use arbiter_macros::Hashable;
|
||||
use arbiter_proto::{ClientMetadata, transport::Bi};
|
||||
use kameo::actor::Spawn;
|
||||
use tracing::{error, info};
|
||||
|
||||
use crate::{
|
||||
actors::GlobalActors, crypto::integrity::Integrable, db, peers::client::session::ClientSession,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ClientProfile {
|
||||
pub pubkey: authn::PublicKey,
|
||||
pub metadata: ClientMetadata,
|
||||
}
|
||||
|
||||
#[derive(Hashable)]
|
||||
pub struct ClientCredentials {
|
||||
pub pubkey: authn::PublicKey,
|
||||
}
|
||||
|
||||
impl Integrable for ClientCredentials {
|
||||
const KIND: &'static str = "client_credentials";
|
||||
}
|
||||
|
||||
pub struct ClientConnection {
|
||||
pub(crate) db: db::DatabasePool,
|
||||
pub(crate) actors: GlobalActors,
|
||||
}
|
||||
|
||||
impl ClientConnection {
|
||||
pub fn new(db: db::DatabasePool, actors: GlobalActors) -> Self {
|
||||
Self { db, actors }
|
||||
}
|
||||
}
|
||||
|
||||
pub mod auth;
|
||||
pub mod session;
|
||||
|
||||
pub async fn connect_client<T>(mut props: ClientConnection, transport: &mut T)
|
||||
where
|
||||
T: Bi<auth::Inbound, Result<auth::Outbound, auth::Error>> + Send + ?Sized,
|
||||
{
|
||||
let fut = auth::authenticate(&mut props, transport);
|
||||
println!("authenticate future size: {}", std::mem::size_of_val(&fut));
|
||||
match fut.await {
|
||||
Ok(client_id) => {
|
||||
ClientSession::spawn(ClientSession::new(props, client_id));
|
||||
info!("Client authenticated, session started");
|
||||
}
|
||||
Err(err) => {
|
||||
let _ = transport.send(Err(err.clone())).await;
|
||||
error!(?err, "Authentication failed, closing connection");
|
||||
}
|
||||
}
|
||||
}
|
||||
120
server/crates/arbiter-server/src/peers/client/session.rs
Normal file
120
server/crates/arbiter-server/src/peers/client/session.rs
Normal file
@@ -0,0 +1,120 @@
|
||||
use kameo::{Actor, messages};
|
||||
use tracing::error;
|
||||
|
||||
use alloy::{consensus::TxEip1559, primitives::Address, signers::Signature};
|
||||
|
||||
use crate::{
|
||||
actors::{
|
||||
GlobalActors,
|
||||
evm::{ClientSignTransaction, SignTransactionError},
|
||||
flow_coordinator::RegisterClient,
|
||||
vault::VaultState,
|
||||
},
|
||||
db,
|
||||
evm::VetError,
|
||||
};
|
||||
|
||||
use super::ClientConnection;
|
||||
|
||||
pub struct ClientSession {
|
||||
props: ClientConnection,
|
||||
client_id: i32,
|
||||
}
|
||||
|
||||
impl ClientSession {
|
||||
pub(crate) fn new(props: ClientConnection, client_id: i32) -> Self {
|
||||
Self { props, client_id }
|
||||
}
|
||||
}
|
||||
|
||||
#[messages]
|
||||
impl ClientSession {
|
||||
#[message]
|
||||
pub(crate) async fn handle_query_vault_state(&mut self) -> Result<VaultState, Error> {
|
||||
use crate::actors::vault::GetState;
|
||||
|
||||
let vault_state = match self.props.actors.vault.ask(GetState {}).await {
|
||||
Ok(state) => state,
|
||||
Err(err) => {
|
||||
error!(?err, actor = "client", "vault.query.failed");
|
||||
return Err(Error::Internal);
|
||||
}
|
||||
};
|
||||
|
||||
Ok(vault_state)
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub(crate) async fn handle_sign_transaction(
|
||||
&mut self,
|
||||
wallet_address: Address,
|
||||
transaction: TxEip1559,
|
||||
) -> Result<Signature, SignTransactionRpcError> {
|
||||
match self
|
||||
.props
|
||||
.actors
|
||||
.evm
|
||||
.ask(ClientSignTransaction {
|
||||
client_id: self.client_id,
|
||||
wallet_address,
|
||||
transaction,
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(signature) => Ok(signature),
|
||||
Err(kameo::error::SendError::HandlerError(SignTransactionError::Vet(vet_error))) => {
|
||||
Err(SignTransactionRpcError::Vet(vet_error))
|
||||
}
|
||||
Err(err) => {
|
||||
error!(?err, "Failed to sign EVM transaction in client session");
|
||||
Err(SignTransactionRpcError::Internal)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Actor for ClientSession {
|
||||
type Args = Self;
|
||||
|
||||
type Error = Error;
|
||||
|
||||
async fn on_start(
|
||||
args: Self::Args,
|
||||
this: kameo::prelude::ActorRef<Self>,
|
||||
) -> Result<Self, Self::Error> {
|
||||
args.props
|
||||
.actors
|
||||
.flow_coordinator
|
||||
.ask(RegisterClient { actor: this })
|
||||
.await
|
||||
.map_err(|_| Error::ConnectionRegistrationFailed)?;
|
||||
Ok(args)
|
||||
}
|
||||
}
|
||||
|
||||
impl ClientSession {
|
||||
pub fn new_test(db: db::DatabasePool, actors: GlobalActors) -> Self {
|
||||
let props = ClientConnection::new(db, actors);
|
||||
Self {
|
||||
props,
|
||||
client_id: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("Connection registration failed")]
|
||||
ConnectionRegistrationFailed,
|
||||
#[error("Internal error")]
|
||||
Internal,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum SignTransactionRpcError {
|
||||
#[error("Policy evaluation failed")]
|
||||
Vet(#[from] VetError),
|
||||
|
||||
#[error("Internal error")]
|
||||
Internal,
|
||||
}
|
||||
2
server/crates/arbiter-server/src/peers/mod.rs
Normal file
2
server/crates/arbiter-server/src/peers/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod client;
|
||||
pub mod user_agent;
|
||||
105
server/crates/arbiter-server/src/peers/user_agent/auth/mod.rs
Normal file
105
server/crates/arbiter-server/src/peers/user_agent/auth/mod.rs
Normal file
@@ -0,0 +1,105 @@
|
||||
use arbiter_crypto::authn::{self, AuthChallenge};
|
||||
use arbiter_proto::transport::Bi;
|
||||
use tracing::error;
|
||||
|
||||
mod state;
|
||||
use state::*;
|
||||
|
||||
use super::Credentials;
|
||||
use super::UserAgentConnection;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Inbound {
|
||||
AuthChallengeRequest {
|
||||
pubkey: authn::PublicKey,
|
||||
bootstrap_token: Option<String>,
|
||||
},
|
||||
AuthChallengeSolution {
|
||||
signature: Vec<u8>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum Error {
|
||||
UnregisteredPublicKey,
|
||||
InvalidChallengeSolution,
|
||||
InvalidBootstrapToken,
|
||||
Internal { details: String },
|
||||
Transport,
|
||||
}
|
||||
|
||||
impl Error {
|
||||
fn internal(details: impl Into<String>) -> Self {
|
||||
Self::Internal {
|
||||
details: details.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<diesel::result::Error> for Error {
|
||||
fn from(e: diesel::result::Error) -> Self {
|
||||
error!(?e, "Database error");
|
||||
Self::internal("Database error")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Outbound {
|
||||
AuthChallenge { challenge: AuthChallenge },
|
||||
AuthSuccess,
|
||||
}
|
||||
|
||||
fn parse_auth_event(payload: Inbound) -> AuthEvents {
|
||||
match payload {
|
||||
Inbound::AuthChallengeRequest {
|
||||
pubkey,
|
||||
bootstrap_token,
|
||||
} => AuthEvents::AuthRequest(ChallengeRequest {
|
||||
pubkey,
|
||||
bootstrap_token,
|
||||
}),
|
||||
Inbound::AuthChallengeSolution { signature } => {
|
||||
AuthEvents::ReceivedSolution(ChallengeSolution {
|
||||
solution: signature,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn authenticate<T>(
|
||||
props: &mut UserAgentConnection,
|
||||
transport: &mut T,
|
||||
) -> Result<Credentials, Error>
|
||||
where
|
||||
T: Bi<Inbound, Result<Outbound, Error>> + Send + ?Sized,
|
||||
{
|
||||
let mut state = AuthStateMachine::new(AuthContext::new(props, transport));
|
||||
|
||||
loop {
|
||||
let Some(payload) = state.context_mut().transport.recv().await else {
|
||||
return Err(Error::Transport);
|
||||
};
|
||||
|
||||
match state.process_event(parse_auth_event(payload)).await {
|
||||
Ok(AuthStates::AuthOk(result)) => return Ok(result.clone()),
|
||||
Err(AuthError::ActionFailed(err)) => {
|
||||
error!(?err, "State machine action failed");
|
||||
return Err(err);
|
||||
}
|
||||
Err(AuthError::GuardFailed(err)) => {
|
||||
error!(?err, "State machine guard failed");
|
||||
return Err(err);
|
||||
}
|
||||
Err(AuthError::InvalidEvent) => {
|
||||
error!("Invalid event for current state");
|
||||
return Err(Error::InvalidChallengeSolution);
|
||||
}
|
||||
Err(AuthError::TransitionsFailed) => {
|
||||
error!("Invalid state transition");
|
||||
return Err(Error::InvalidChallengeSolution);
|
||||
}
|
||||
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
}
|
||||
196
server/crates/arbiter-server/src/peers/user_agent/auth/state.rs
Normal file
196
server/crates/arbiter-server/src/peers/user_agent/auth/state.rs
Normal file
@@ -0,0 +1,196 @@
|
||||
use super::super::{Credentials, UserAgentConnection};
|
||||
use arbiter_crypto::authn::{self, AuthChallenge, USERAGENT_CONTEXT};
|
||||
use arbiter_proto::transport::Bi;
|
||||
use diesel::{ExpressionMethods as _, OptionalExtension as _, QueryDsl};
|
||||
use diesel_async::RunQueryDsl;
|
||||
use tracing::error;
|
||||
|
||||
use super::Error;
|
||||
use crate::{
|
||||
actors::bootstrap::ConsumeToken,
|
||||
db::{DatabasePool, schema::useragent_client},
|
||||
peers::user_agent::auth::Outbound,
|
||||
};
|
||||
|
||||
pub struct ChallengeRequest {
|
||||
pub pubkey: authn::PublicKey,
|
||||
pub bootstrap_token: Option<String>,
|
||||
}
|
||||
|
||||
pub struct ChallengeContext {
|
||||
pub challenge: AuthChallenge,
|
||||
pub pubkey: authn::PublicKey,
|
||||
pub bootstrap_token: Option<String>,
|
||||
}
|
||||
|
||||
pub struct ChallengeSolution {
|
||||
pub solution: Vec<u8>,
|
||||
}
|
||||
|
||||
smlang::statemachine!(
|
||||
name: Auth,
|
||||
custom_error: true,
|
||||
transitions: {
|
||||
*Init + AuthRequest(ChallengeRequest) / async prepare_challenge = SentChallenge(ChallengeContext),
|
||||
SentChallenge(ChallengeContext) + ReceivedSolution(ChallengeSolution) / async verify_solution = AuthOk(Credentials),
|
||||
}
|
||||
);
|
||||
|
||||
async fn get_client_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")
|
||||
})?;
|
||||
|
||||
useragent_client::table
|
||||
.filter(useragent_client::public_key.eq(pubkey.to_bytes()))
|
||||
.select(useragent_client::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| {
|
||||
error!(error = ?e, "Database pool error");
|
||||
Error::internal("Database unavailable")
|
||||
})?;
|
||||
|
||||
let id: i32 = diesel::insert_into(useragent_client::table)
|
||||
.values((useragent_client::public_key.eq(pubkey_bytes),))
|
||||
.returning(useragent_client::id)
|
||||
.get_result(&mut conn)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!(error = ?e, "Database error");
|
||||
Error::internal("Database operation failed")
|
||||
})?;
|
||||
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
pub struct AuthContext<'a, T: ?Sized> {
|
||||
pub(super) conn: &'a mut UserAgentConnection,
|
||||
pub(super) transport: &'a mut T,
|
||||
}
|
||||
|
||||
impl<'a, T: ?Sized> AuthContext<'a, T> {
|
||||
pub fn new(conn: &'a mut UserAgentConnection, transport: &'a mut T) -> Self {
|
||||
Self { conn, transport }
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> AuthStateMachineContext for AuthContext<'_, T>
|
||||
where
|
||||
T: Bi<super::Inbound, Result<super::Outbound, Error>> + Send + ?Sized,
|
||||
{
|
||||
type Error = Error;
|
||||
|
||||
async fn prepare_challenge(
|
||||
&mut self,
|
||||
ChallengeRequest {
|
||||
pubkey,
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
let challenge = AuthChallenge::generate(&mut rand::rng());
|
||||
|
||||
self.transport
|
||||
.send(Ok(Outbound::AuthChallenge {
|
||||
challenge: challenge.clone(),
|
||||
}))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!(?e, "Failed to send auth challenge");
|
||||
Error::Transport
|
||||
})?;
|
||||
|
||||
Ok(ChallengeContext {
|
||||
challenge,
|
||||
pubkey,
|
||||
bootstrap_token,
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(missing_docs)]
|
||||
#[allow(clippy::unused_unit)]
|
||||
async fn verify_solution(
|
||||
&mut self,
|
||||
ChallengeContext {
|
||||
challenge,
|
||||
pubkey,
|
||||
bootstrap_token,
|
||||
}: &ChallengeContext,
|
||||
ChallengeSolution { solution }: ChallengeSolution,
|
||||
) -> Result<Credentials, Self::Error> {
|
||||
let signature = authn::Signature::try_from(solution.as_slice()).map_err(|_| {
|
||||
error!("Failed to decode signature in challenge solution");
|
||||
Error::InvalidChallengeSolution
|
||||
})?;
|
||||
|
||||
let valid = pubkey.verify(challenge, USERAGENT_CONTEXT, &signature);
|
||||
|
||||
if !valid {
|
||||
self.transport
|
||||
.send(Err(Error::InvalidChallengeSolution))
|
||||
.await
|
||||
.map_err(|_| Error::Transport)?;
|
||||
return Err(Error::InvalidChallengeSolution);
|
||||
}
|
||||
|
||||
// Resolve client id: bootstrap (consume token + register) or lookup
|
||||
let id = match bootstrap_token {
|
||||
Some(token) => {
|
||||
let token_ok: bool = self
|
||||
.conn
|
||||
.actors
|
||||
.bootstrapper
|
||||
.ask(ConsumeToken {
|
||||
token: token.clone(),
|
||||
})
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!(?e, "Failed to consume bootstrap token");
|
||||
Error::internal("Failed to consume bootstrap token")
|
||||
})?;
|
||||
|
||||
if !token_ok {
|
||||
error!("Invalid bootstrap token provided");
|
||||
self.transport
|
||||
.send(Err(Error::InvalidBootstrapToken))
|
||||
.await
|
||||
.map_err(|_| Error::Transport)?;
|
||||
return Err(Error::InvalidBootstrapToken);
|
||||
}
|
||||
|
||||
register_key(&self.conn.db, pubkey).await?
|
||||
}
|
||||
None => get_client_id(&self.conn.db, pubkey)
|
||||
.await?
|
||||
.ok_or(Error::UnregisteredPublicKey)?,
|
||||
};
|
||||
|
||||
self.transport
|
||||
.send(Ok(Outbound::AuthSuccess))
|
||||
.await
|
||||
.map_err(|_| Error::Transport)?;
|
||||
|
||||
Ok(Credentials {
|
||||
id,
|
||||
pubkey: pubkey.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
185
server/crates/arbiter-server/src/peers/user_agent/mod.rs
Normal file
185
server/crates/arbiter-server/src/peers/user_agent/mod.rs
Normal file
@@ -0,0 +1,185 @@
|
||||
use crate::{
|
||||
actors::{
|
||||
GlobalActors,
|
||||
vault::{GetState, Vault},
|
||||
},
|
||||
crypto::integrity::{self, AttestationStatus, Integrable},
|
||||
db::{self, DatabaseError, DatabasePool},
|
||||
peers::client::ClientProfile,
|
||||
};
|
||||
use arbiter_crypto::authn;
|
||||
|
||||
use arbiter_macros::Hashable;
|
||||
use arbiter_proto::transport::{Bi, Sender};
|
||||
pub use auth::authenticate;
|
||||
use kameo::actor::{ActorRef, Spawn as _};
|
||||
pub use session::UserAgentSession;
|
||||
use tokio::sync::oneshot;
|
||||
use tracing::{error, warn};
|
||||
use vault_gate::VaultGate;
|
||||
|
||||
pub mod auth;
|
||||
pub mod session;
|
||||
pub mod vault_gate;
|
||||
|
||||
#[derive(Debug, Clone, Hashable)]
|
||||
pub struct Credentials {
|
||||
pub id: i32,
|
||||
pub pubkey: authn::PublicKey,
|
||||
}
|
||||
|
||||
impl Integrable for Credentials {
|
||||
const KIND: &'static str = "useragent_credentials";
|
||||
}
|
||||
|
||||
// Messages, sent by user agent to connection client without having a request
|
||||
#[derive(Debug)]
|
||||
pub enum OutOfBand {
|
||||
ClientConnectionRequest { profile: ClientProfile },
|
||||
ClientConnectionCancel { pubkey: authn::PublicKey },
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct UserAgentConnection {
|
||||
pub(crate) db: db::DatabasePool,
|
||||
pub(crate) actors: GlobalActors,
|
||||
}
|
||||
|
||||
impl UserAgentConnection {
|
||||
pub fn new(db: db::DatabasePool, actors: GlobalActors) -> Self {
|
||||
Self { db, actors }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("authentication failed: {0:?}")]
|
||||
Auth(auth::Error),
|
||||
#[error("vault gate failed: {0}")]
|
||||
VaultGate(#[from] vault_gate::Error),
|
||||
#[error("transport closed unexpectedly")]
|
||||
Transport,
|
||||
#[error("database error: {0}")]
|
||||
Database(DatabaseError),
|
||||
#[error("internal: {0}")]
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
impl From<auth::Error> for Error {
|
||||
fn from(err: auth::Error) -> Self {
|
||||
Self::Auth(err)
|
||||
}
|
||||
}
|
||||
|
||||
async fn verify_integrity(
|
||||
db: &DatabasePool,
|
||||
vault: &ActorRef<Vault>,
|
||||
credentials: &Credentials,
|
||||
) -> Result<(), Error> {
|
||||
let mut conn = db
|
||||
.get()
|
||||
.await
|
||||
.map_err(|_| Error::Internal("DB unavailable".into()))?;
|
||||
match integrity::verify_entity(&mut conn, &vault, credentials, credentials.id).await {
|
||||
Ok(AttestationStatus::Attested) => Ok(()),
|
||||
Ok(AttestationStatus::Unavailable) => {
|
||||
Err(Error::Internal("Vault sealed during promotion".into()))
|
||||
}
|
||||
Err(e) => {
|
||||
error!(?e, "Integrity verification failed during unseal promotion");
|
||||
Err(Error::Internal("Integrity check failed".into()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn should_run_gate(vault: &ActorRef<Vault>) -> Result<bool, Error> {
|
||||
let vault_state = vault
|
||||
.ask(GetState {})
|
||||
.await
|
||||
.map_err(|_| Error::Internal("Failed to contact the vault".into()))?;
|
||||
|
||||
Ok(!matches!(
|
||||
vault_state,
|
||||
crate::actors::vault::VaultState::Unsealed
|
||||
))
|
||||
}
|
||||
|
||||
async fn run_vault_gate<T>(
|
||||
props: &UserAgentConnection,
|
||||
transport: &mut T,
|
||||
auth_creds: Credentials,
|
||||
) -> Result<(), Error>
|
||||
where
|
||||
T: Bi<vault_gate::Inbound, Result<vault_gate::Outbound, vault_gate::Error>> + Send + ?Sized,
|
||||
{
|
||||
let (promotion_tx, mut promotion_rx) = oneshot::channel();
|
||||
let gate = VaultGate::spawn(VaultGate::new(
|
||||
auth_creds,
|
||||
props.actors.clone(),
|
||||
props.db.clone(),
|
||||
promotion_tx,
|
||||
));
|
||||
|
||||
let result = loop {
|
||||
tokio::select! {
|
||||
promotion = &mut promotion_rx => {
|
||||
break match promotion {
|
||||
Ok(Ok(creds)) => Ok(creds),
|
||||
Ok(Err(err)) => Err(Error::VaultGate(err)),
|
||||
Err(_) => Err(Error::Internal(
|
||||
"vault gate promotion channel closed".into(),
|
||||
)),
|
||||
};
|
||||
}
|
||||
|
||||
inbound = transport.recv() => {
|
||||
let Some(inbound) = inbound else {
|
||||
break Err(Error::Transport);
|
||||
};
|
||||
|
||||
match gate.ask(inbound).await {
|
||||
Ok(outbound) => {
|
||||
if transport.send(Ok(outbound)).await.is_err() {
|
||||
break Err(Error::Transport);
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(?err, "VaultGate failed to handle message");
|
||||
break Err(Error::Internal(format!(
|
||||
"vault gate ask failed: {err:?}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
gate.kill();
|
||||
result
|
||||
}
|
||||
|
||||
pub async fn start<T>(
|
||||
props: &mut UserAgentConnection,
|
||||
mut transport: T,
|
||||
oob_sender: Box<dyn Sender<OutOfBand>>,
|
||||
) -> Result<ActorRef<UserAgentSession>, Error>
|
||||
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?;
|
||||
|
||||
// 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?;
|
||||
}
|
||||
|
||||
// checking the integrity
|
||||
verify_integrity(&props.db, &props.actors.vault, &creds).await?;
|
||||
|
||||
Ok(UserAgentSession::spawn(UserAgentSession::new(
|
||||
props.clone(),
|
||||
creds,
|
||||
oob_sender,
|
||||
)))
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
|
||||
use alloy::{consensus::TxEip1559, primitives::Address, signers::Signature};
|
||||
use arbiter_crypto::{
|
||||
authn,
|
||||
safecell::SafeCellHandle as _,
|
||||
};
|
||||
use chacha20poly1305::aead::KeyInit;
|
||||
use diesel::{ExpressionMethods as _, QueryDsl as _, SelectableHelper};
|
||||
use diesel_async::{AsyncConnection, RunQueryDsl};
|
||||
use kameo::error::SendError;
|
||||
use kameo::messages;
|
||||
use kameo::prelude::Context;
|
||||
use tracing::error;
|
||||
|
||||
use crate::actors::flow_coordinator::client_connect_approval::ClientApprovalAnswer;
|
||||
use crate::actors::evm::{
|
||||
ClientSignTransaction, Generate, ListWallets, SignTransactionError as EvmSignError,
|
||||
UseragentCreateGrant, UseragentListGrants,
|
||||
};
|
||||
use crate::db::models::{
|
||||
EvmWalletAccess, NewEvmWalletAccess, ProgramClient, ProgramClientMetadata,
|
||||
};
|
||||
use crate::evm::policies::{Grant, SpecificGrant};
|
||||
use crate::{
|
||||
actors::vault::VaultState,
|
||||
};
|
||||
|
||||
use super::{Error, UserAgentSession};
|
||||
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum SignTransactionError {
|
||||
#[error("Policy evaluation failed")]
|
||||
Vet(#[from] crate::evm::VetError),
|
||||
|
||||
#[error("Internal signing error")]
|
||||
Internal,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum GrantMutationError {
|
||||
#[error("Vault is sealed")]
|
||||
VaultSealed,
|
||||
|
||||
#[error("Internal grant mutation error")]
|
||||
Internal,
|
||||
}
|
||||
|
||||
#[messages]
|
||||
impl UserAgentSession {
|
||||
#[message]
|
||||
pub(crate) async fn handle_query_vault_state(&mut self) -> Result<VaultState, Error> {
|
||||
use crate::actors::vault::GetState;
|
||||
|
||||
let vault_state = match self.props.actors.vault.ask(GetState {}).await {
|
||||
Ok(state) => state,
|
||||
Err(err) => {
|
||||
error!(?err, actor = "useragent", "vault.query.failed");
|
||||
return Err(Error::internal("Vault is in broken state"));
|
||||
}
|
||||
};
|
||||
|
||||
Ok(vault_state)
|
||||
}
|
||||
}
|
||||
|
||||
#[messages]
|
||||
impl UserAgentSession {
|
||||
#[message]
|
||||
pub(crate) async fn handle_evm_wallet_create(&mut self) -> Result<(i32, Address), Error> {
|
||||
match self.props.actors.evm.ask(Generate {}).await {
|
||||
Ok(address) => Ok(address),
|
||||
Err(SendError::HandlerError(err)) => Err(Error::internal(format!(
|
||||
"EVM wallet generation failed: {err}"
|
||||
))),
|
||||
Err(err) => {
|
||||
error!(?err, "EVM actor unreachable during wallet create");
|
||||
Err(Error::internal("EVM actor unreachable"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub(crate) async fn handle_evm_wallet_list(&mut self) -> Result<Vec<(i32, Address)>, Error> {
|
||||
match self.props.actors.evm.ask(ListWallets {}).await {
|
||||
Ok(wallets) => Ok(wallets),
|
||||
Err(err) => {
|
||||
error!(?err, "EVM wallet list failed");
|
||||
Err(Error::internal("Failed to list EVM wallets"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[messages]
|
||||
impl UserAgentSession {
|
||||
#[message]
|
||||
pub(crate) async fn handle_grant_list(&mut self) -> Result<Vec<Grant<SpecificGrant>>, Error> {
|
||||
match self.props.actors.evm.ask(UseragentListGrants {}).await {
|
||||
Ok(grants) => Ok(grants),
|
||||
Err(err) => {
|
||||
error!(?err, "EVM grant list failed");
|
||||
Err(Error::internal("Failed to list EVM grants"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub(crate) async fn handle_grant_create(
|
||||
&mut self,
|
||||
basic: crate::evm::policies::SharedGrantSettings,
|
||||
grant: crate::evm::policies::SpecificGrant,
|
||||
) -> Result<i32, GrantMutationError> {
|
||||
match self
|
||||
.props
|
||||
.actors
|
||||
.evm
|
||||
.ask(UseragentCreateGrant { basic, grant })
|
||||
.await
|
||||
{
|
||||
Ok(grant_id) => Ok(grant_id),
|
||||
Err(err) => {
|
||||
error!(?err, "EVM grant create failed");
|
||||
Err(GrantMutationError::Internal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub(crate) async fn handle_grant_delete(
|
||||
&mut self,
|
||||
grant_id: i32,
|
||||
) -> Result<(), GrantMutationError> {
|
||||
// match self
|
||||
// .props
|
||||
// .actors
|
||||
// .evm
|
||||
// .ask(UseragentDeleteGrant { grant_id })
|
||||
// .await
|
||||
// {
|
||||
// Ok(()) => Ok(()),
|
||||
// Err(err) => {
|
||||
// error!(?err, "EVM grant delete failed");
|
||||
// Err(GrantMutationError::Internal)
|
||||
// }
|
||||
// }
|
||||
let _ = grant_id;
|
||||
todo!()
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub(crate) async fn handle_sign_transaction(
|
||||
&mut self,
|
||||
client_id: i32,
|
||||
wallet_address: Address,
|
||||
transaction: TxEip1559,
|
||||
) -> Result<Signature, SignTransactionError> {
|
||||
match self
|
||||
.props
|
||||
.actors
|
||||
.evm
|
||||
.ask(ClientSignTransaction {
|
||||
client_id,
|
||||
wallet_address,
|
||||
transaction,
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(signature) => Ok(signature),
|
||||
Err(SendError::HandlerError(EvmSignError::Vet(vet_error))) => {
|
||||
Err(SignTransactionError::Vet(vet_error))
|
||||
}
|
||||
Err(err) => {
|
||||
error!(?err, "EVM sign transaction failed in user-agent session");
|
||||
Err(SignTransactionError::Internal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub(crate) async fn handle_grant_evm_wallet_access(
|
||||
&mut self,
|
||||
entries: Vec<NewEvmWalletAccess>,
|
||||
) -> Result<(), Error> {
|
||||
let mut conn = self.props.db.get().await?;
|
||||
conn.transaction(|conn| {
|
||||
Box::pin(async move {
|
||||
use crate::db::schema::evm_wallet_access;
|
||||
|
||||
for entry in entries {
|
||||
diesel::insert_into(evm_wallet_access::table)
|
||||
.values(&entry)
|
||||
.on_conflict_do_nothing()
|
||||
.execute(conn)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Result::<_, Error>::Ok(())
|
||||
})
|
||||
})
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub(crate) async fn handle_revoke_evm_wallet_access(
|
||||
&mut self,
|
||||
entries: Vec<i32>,
|
||||
) -> Result<(), Error> {
|
||||
let mut conn = self.props.db.get().await?;
|
||||
conn.transaction(|conn| {
|
||||
Box::pin(async move {
|
||||
use crate::db::schema::evm_wallet_access;
|
||||
for entry in entries {
|
||||
diesel::delete(evm_wallet_access::table)
|
||||
.filter(evm_wallet_access::wallet_id.eq(entry))
|
||||
.execute(conn)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Result::<_, Error>::Ok(())
|
||||
})
|
||||
})
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub(crate) async fn handle_list_wallet_access(
|
||||
&mut self,
|
||||
) -> Result<Vec<EvmWalletAccess>, Error> {
|
||||
let mut conn = self.props.db.get().await?;
|
||||
use crate::db::schema::evm_wallet_access;
|
||||
let access_entries = evm_wallet_access::table
|
||||
.select(EvmWalletAccess::as_select())
|
||||
.load::<_>(&mut conn)
|
||||
.await?;
|
||||
Ok(access_entries)
|
||||
}
|
||||
}
|
||||
|
||||
#[messages]
|
||||
impl UserAgentSession {
|
||||
#[message(ctx)]
|
||||
pub(crate) async fn handle_new_client_approve(
|
||||
&mut self,
|
||||
approved: bool,
|
||||
pubkey: authn::PublicKey,
|
||||
ctx: &mut Context<Self, Result<(), Error>>,
|
||||
) -> Result<(), Error> {
|
||||
let pending_approval = match self.pending_client_approvals.remove(&pubkey.to_bytes()) {
|
||||
Some(approval) => approval,
|
||||
None => {
|
||||
error!("Received client connection response for unknown client");
|
||||
return Err(Error::internal("Unknown client in connection response"));
|
||||
}
|
||||
};
|
||||
|
||||
pending_approval
|
||||
.controller
|
||||
.tell(ClientApprovalAnswer { approved })
|
||||
.await
|
||||
.map_err(|err| {
|
||||
error!(
|
||||
?err,
|
||||
"Failed to send client approval response to controller"
|
||||
);
|
||||
Error::internal("Failed to send client approval response to controller")
|
||||
})?;
|
||||
|
||||
ctx.actor_ref().unlink(&pending_approval.controller).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub(crate) async fn handle_sdk_client_list(
|
||||
&mut self,
|
||||
) -> Result<Vec<(ProgramClient, ProgramClientMetadata)>, Error> {
|
||||
use crate::db::schema::{client_metadata, program_client};
|
||||
let mut conn = self.props.db.get().await?;
|
||||
|
||||
let clients = program_client::table
|
||||
.inner_join(client_metadata::table)
|
||||
.select((
|
||||
ProgramClient::as_select(),
|
||||
ProgramClientMetadata::as_select(),
|
||||
))
|
||||
.load::<(ProgramClient, ProgramClientMetadata)>(&mut conn)
|
||||
.await?;
|
||||
|
||||
Ok(clients)
|
||||
}
|
||||
}
|
||||
172
server/crates/arbiter-server/src/peers/user_agent/session/mod.rs
Normal file
172
server/crates/arbiter-server/src/peers/user_agent/session/mod.rs
Normal file
@@ -0,0 +1,172 @@
|
||||
use arbiter_crypto::authn;
|
||||
|
||||
use std::{borrow::Cow, collections::HashMap};
|
||||
|
||||
use arbiter_proto::transport::Sender;
|
||||
use kameo::{Actor, actor::ActorRef, messages};
|
||||
use thiserror::Error;
|
||||
use tracing::error;
|
||||
|
||||
use crate::{
|
||||
actors::{
|
||||
flow_coordinator::client_connect_approval::ClientApprovalController,
|
||||
useragent_registry::ConnectUseragent,
|
||||
},
|
||||
peers::{client::ClientProfile, user_agent::Credentials},
|
||||
};
|
||||
|
||||
use super::{OutOfBand, UserAgentConnection};
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum Error {
|
||||
#[error("State transition failed")]
|
||||
State,
|
||||
|
||||
#[error("Internal error: {message}")]
|
||||
Internal { message: Cow<'static, str> },
|
||||
}
|
||||
|
||||
impl From<crate::db::PoolError> for Error {
|
||||
fn from(err: crate::db::PoolError) -> Self {
|
||||
error!(?err, "Database pool error");
|
||||
Self::internal("Database pool error")
|
||||
}
|
||||
}
|
||||
impl From<diesel::result::Error> for Error {
|
||||
fn from(err: diesel::result::Error) -> Self {
|
||||
error!(?err, "Database error");
|
||||
Self::internal("Database error")
|
||||
}
|
||||
}
|
||||
|
||||
impl Error {
|
||||
pub fn internal(message: impl Into<Cow<'static, str>>) -> Self {
|
||||
Self::Internal {
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PendingClientApproval {
|
||||
pubkey: authn::PublicKey,
|
||||
controller: ActorRef<ClientApprovalController>,
|
||||
}
|
||||
|
||||
pub struct UserAgentSession {
|
||||
creds: Credentials,
|
||||
props: UserAgentConnection,
|
||||
sender: Box<dyn Sender<OutOfBand>>,
|
||||
|
||||
pending_client_approvals: HashMap<Vec<u8>, PendingClientApproval>,
|
||||
}
|
||||
|
||||
pub mod handlers;
|
||||
|
||||
impl UserAgentSession {
|
||||
pub(crate) fn new(
|
||||
props: UserAgentConnection,
|
||||
creds: Credentials,
|
||||
sender: Box<dyn Sender<OutOfBand>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
creds,
|
||||
props,
|
||||
sender,
|
||||
pending_client_approvals: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[messages]
|
||||
impl UserAgentSession {
|
||||
#[message]
|
||||
pub async fn begin_new_client_approval(
|
||||
&mut self,
|
||||
client: ClientProfile,
|
||||
controller: ActorRef<ClientApprovalController>,
|
||||
) {
|
||||
if let Err(e) = self
|
||||
.sender
|
||||
.send(OutOfBand::ClientConnectionRequest {
|
||||
profile: client.clone(),
|
||||
})
|
||||
.await
|
||||
{
|
||||
error!(
|
||||
?e,
|
||||
actor = "user_agent",
|
||||
event = "failed to announce new client connection"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
self.pending_client_approvals.insert(
|
||||
client.pubkey.to_bytes(),
|
||||
PendingClientApproval {
|
||||
pubkey: client.pubkey,
|
||||
controller,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl Actor for UserAgentSession {
|
||||
type Args = Self;
|
||||
|
||||
type Error = Error;
|
||||
|
||||
async fn on_start(
|
||||
args: Self::Args,
|
||||
this: kameo::prelude::ActorRef<Self>,
|
||||
) -> Result<Self, Self::Error> {
|
||||
args.props
|
||||
.actors
|
||||
.useragent_registry
|
||||
.ask(ConnectUseragent {
|
||||
actor: this.clone(),
|
||||
})
|
||||
.await
|
||||
.map_err(|err| {
|
||||
error!(
|
||||
?err,
|
||||
"Failed to register user agent connection with user agent registry"
|
||||
);
|
||||
Error::internal("Failed to register user agent connection with user agent registry")
|
||||
})?;
|
||||
Ok(args)
|
||||
}
|
||||
|
||||
async fn on_link_died(
|
||||
&mut self,
|
||||
_: kameo::prelude::WeakActorRef<Self>,
|
||||
id: kameo::prelude::ActorId,
|
||||
_: kameo::prelude::ActorStopReason,
|
||||
) -> Result<std::ops::ControlFlow<kameo::prelude::ActorStopReason>, Self::Error> {
|
||||
let cancelled_pubkey = self
|
||||
.pending_client_approvals
|
||||
.iter()
|
||||
.find_map(|(k, v)| (v.controller.id() == id).then_some(k.clone()));
|
||||
|
||||
if let Some(pubkey_bytes) = cancelled_pubkey {
|
||||
let Some(approval) = self.pending_client_approvals.remove(&pubkey_bytes) else {
|
||||
return Ok(std::ops::ControlFlow::Continue(()));
|
||||
};
|
||||
|
||||
if let Err(e) = self
|
||||
.sender
|
||||
.send(OutOfBand::ClientConnectionCancel {
|
||||
pubkey: approval.pubkey,
|
||||
})
|
||||
.await
|
||||
{
|
||||
error!(
|
||||
?e,
|
||||
actor = "user_agent",
|
||||
event = "failed to announce client connection cancellation"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(std::ops::ControlFlow::Continue(()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
|
||||
use chacha20poly1305::{AeadInPlace, KeyInit as _, XChaCha20Poly1305, XNonce};
|
||||
use kameo::{Actor, error::SendError, messages, prelude::Message};
|
||||
use kameo_actors::message_bus::Register;
|
||||
use tokio::sync::oneshot;
|
||||
use tracing::{error, info};
|
||||
use x25519_dalek::{EphemeralSecret, PublicKey, SharedSecret};
|
||||
|
||||
pub mod state;
|
||||
use state::*;
|
||||
|
||||
use super::Credentials;
|
||||
use crate::{
|
||||
actors::{
|
||||
GlobalActors,
|
||||
vault::{self, Bootstrap, GetState, TryUnseal, VaultState, events},
|
||||
},
|
||||
crypto::integrity::{self},
|
||||
db::DatabasePool,
|
||||
};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("Vault is already bootstrapped")]
|
||||
AlreadyBootstrapped,
|
||||
#[error("Invalid key provided")]
|
||||
InvalidKey,
|
||||
|
||||
#[error("State transition failed")]
|
||||
State,
|
||||
|
||||
#[error("Internal error: {0}")]
|
||||
Internal(String),
|
||||
}
|
||||
impl Error {
|
||||
fn internal(message: impl Into<String>) -> Self {
|
||||
Self::Internal(message.into())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct HandshakeResponse {
|
||||
pub server_pubkey: PublicKey,
|
||||
}
|
||||
|
||||
pub struct VaultGate {
|
||||
pub auth_creds: Credentials,
|
||||
pub promotion_tx: Option<oneshot::Sender<Result<(), Error>>>,
|
||||
pub state: State,
|
||||
pub actors: GlobalActors,
|
||||
pub db: DatabasePool,
|
||||
}
|
||||
|
||||
impl VaultGate {
|
||||
pub fn new(
|
||||
auth_creds: Credentials,
|
||||
actors: GlobalActors,
|
||||
db: DatabasePool,
|
||||
promotion_tx: oneshot::Sender<Result<(), Error>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
auth_creds,
|
||||
state: State::default(),
|
||||
actors,
|
||||
db,
|
||||
promotion_tx: Some(promotion_tx),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Actor for VaultGate {
|
||||
type Args = Self;
|
||||
|
||||
type Error = ();
|
||||
|
||||
async fn on_start(
|
||||
args: Self::Args,
|
||||
actor_ref: kameo::prelude::ActorRef<Self>,
|
||||
) -> Result<Self, Self::Error> {
|
||||
let _ = args
|
||||
.actors
|
||||
.events
|
||||
.tell(Register(
|
||||
actor_ref.clone().recipient::<events::Bootstrapped>(),
|
||||
))
|
||||
.await;
|
||||
let _ = args
|
||||
.actors
|
||||
.events
|
||||
.tell(Register(actor_ref.recipient::<events::Unsealed>()))
|
||||
.await;
|
||||
Ok(args)
|
||||
}
|
||||
}
|
||||
|
||||
impl VaultGate {
|
||||
fn decrypt_key(
|
||||
secret: &SharedSecret,
|
||||
nonce: &[u8],
|
||||
ciphertext: &[u8],
|
||||
associated_data: &[u8],
|
||||
) -> Result<SafeCell<Vec<u8>>, ()> {
|
||||
let nonce = XNonce::from_slice(nonce);
|
||||
|
||||
let cipher = XChaCha20Poly1305::new(secret.as_bytes().into());
|
||||
|
||||
let mut key_buffer = SafeCell::new(ciphertext.to_vec());
|
||||
|
||||
let decryption_result = key_buffer.write_inline(|write_handle| {
|
||||
cipher.decrypt_in_place(nonce, associated_data, write_handle)
|
||||
});
|
||||
|
||||
match decryption_result {
|
||||
Ok(_) => Ok(key_buffer),
|
||||
Err(err) => {
|
||||
error!(?err, "Failed to decrypt encrypted key material");
|
||||
Err(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[messages(messages = Inbound, replies = Outbound)]
|
||||
impl VaultGate {
|
||||
#[message]
|
||||
pub async fn handle_handshake(
|
||||
&mut self,
|
||||
client_pubkey: x25519_dalek::PublicKey,
|
||||
) -> Result<HandshakeResponse, Error> {
|
||||
let ephemeral_secret = EphemeralSecret::random();
|
||||
let public_key = PublicKey::from(&ephemeral_secret);
|
||||
|
||||
let secret = ephemeral_secret.diffie_hellman(&client_pubkey);
|
||||
|
||||
self.state = State::ReadyForExchange {
|
||||
server_key: public_key.clone(),
|
||||
secret,
|
||||
};
|
||||
|
||||
Ok(HandshakeResponse {
|
||||
server_pubkey: public_key,
|
||||
})
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub async fn handle_unseal_encrypted_key(
|
||||
&mut self,
|
||||
nonce: Vec<u8>,
|
||||
ciphertext: Vec<u8>,
|
||||
associated_data: Vec<u8>,
|
||||
) -> Result<(), Error> {
|
||||
let State::ReadyForExchange { secret, .. } = &self.state else {
|
||||
return Err(Error::State);
|
||||
};
|
||||
|
||||
let seal_key_buffer = match Self::decrypt_key(secret, &nonce, &ciphertext, &associated_data)
|
||||
{
|
||||
Ok(buffer) => buffer,
|
||||
Err(()) => {
|
||||
return Err(Error::InvalidKey);
|
||||
}
|
||||
};
|
||||
|
||||
match self
|
||||
.actors
|
||||
.vault
|
||||
.ask(TryUnseal {
|
||||
seal_key_raw: seal_key_buffer,
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
info!("Successfully unsealed key with client-provided key");
|
||||
Ok(())
|
||||
}
|
||||
Err(SendError::HandlerError(vault::Error::InvalidKey)) => Err(Error::InvalidKey),
|
||||
Err(SendError::HandlerError(err)) => {
|
||||
error!(?err, "Vault failed to unseal key");
|
||||
Err(Error::InvalidKey)
|
||||
}
|
||||
Err(err) => {
|
||||
error!(?err, "Failed to send unseal request to vault");
|
||||
Err(Error::internal("Vault actor error").into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub async fn handle_bootstrap_encrypted_key(
|
||||
&mut self,
|
||||
nonce: Vec<u8>,
|
||||
ciphertext: Vec<u8>,
|
||||
associated_data: Vec<u8>,
|
||||
) -> Result<(), Error> {
|
||||
let State::ReadyForExchange { secret, .. } = &self.state else {
|
||||
return Err(Error::State);
|
||||
};
|
||||
|
||||
let seal_key_buffer = match Self::decrypt_key(secret, &nonce, &ciphertext, &associated_data)
|
||||
{
|
||||
Ok(buffer) => buffer,
|
||||
Err(()) => {
|
||||
return Err(Error::InvalidKey);
|
||||
}
|
||||
};
|
||||
|
||||
match self
|
||||
.actors
|
||||
.vault
|
||||
.ask(Bootstrap {
|
||||
seal_key_raw: seal_key_buffer,
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
info!("Successfully bootstrapped vault with client-provided key");
|
||||
Ok(())
|
||||
}
|
||||
Err(SendError::HandlerError(vault::Error::AlreadyBootstrapped)) => {
|
||||
Err(Error::AlreadyBootstrapped)
|
||||
}
|
||||
Err(SendError::HandlerError(err)) => {
|
||||
error!(?err, "Vault failed to bootstrap vault");
|
||||
Err(Error::InvalidKey)
|
||||
}
|
||||
Err(err) => {
|
||||
error!(?err, "Failed to send bootstrap request to vault");
|
||||
Err(Error::internal("Vault error"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub async fn handle_vault_state(&mut self) -> Result<VaultState, Error> {
|
||||
let answer = self
|
||||
.actors
|
||||
.vault
|
||||
.ask(GetState {})
|
||||
.await
|
||||
.map_err(|_| Error::internal("failed to query vault"))?;
|
||||
|
||||
Ok(answer)
|
||||
}
|
||||
}
|
||||
|
||||
impl Message<events::Bootstrapped> for VaultGate {
|
||||
type Reply = ();
|
||||
|
||||
async fn handle(
|
||||
&mut self,
|
||||
_: events::Bootstrapped,
|
||||
ctx: &mut kameo::prelude::Context<Self, Self::Reply>,
|
||||
) -> Self::Reply {
|
||||
let result = async {
|
||||
let mut conn = self
|
||||
.db
|
||||
.get()
|
||||
.await
|
||||
.map_err(|_| Error::internal("DB unavailable"))?;
|
||||
integrity::sign_entity(
|
||||
&mut conn,
|
||||
&self.actors.vault,
|
||||
&self.auth_creds,
|
||||
self.auth_creds.id,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!(?e, "Failed to sign integrity envelope on bootstrap");
|
||||
Error::internal("Integrity sign failed")
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
|
||||
if let Some(tx) = self.promotion_tx.take() {
|
||||
let _ = tx.send(result);
|
||||
}
|
||||
ctx.stop();
|
||||
}
|
||||
}
|
||||
|
||||
impl Message<events::Unsealed> for VaultGate {
|
||||
type Reply = ();
|
||||
|
||||
async fn handle(
|
||||
&mut self,
|
||||
_: events::Unsealed,
|
||||
ctx: &mut kameo::prelude::Context<Self, Self::Reply>,
|
||||
) -> Self::Reply {
|
||||
if let Some(tx) = self.promotion_tx.take() {
|
||||
let _ = tx.send(Ok(()));
|
||||
}
|
||||
ctx.stop();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
|
||||
use x25519_dalek::{PublicKey, SharedSecret};
|
||||
|
||||
pub struct Handshake {
|
||||
client_pubkey: PublicKey,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub enum State {
|
||||
#[default]
|
||||
Idle,
|
||||
ReadyForExchange {
|
||||
server_key: PublicKey,
|
||||
secret: SharedSecret,
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user