From 549a0f5f52963d9fbff1af711c5da1b266bbb7a2 Mon Sep 17 00:00:00 2001 From: hdbg Date: Sun, 15 Mar 2026 23:11:07 +0100 Subject: [PATCH] refactor(server): removed grpc adapter and replaced with concrete implementations --- server/Cargo.lock | 24 - server/crates/arbiter-proto/src/transport.rs | 237 +--------- .../arbiter-server/src/actors/bootstrap.rs | 8 +- .../arbiter-server/src/actors/client/auth.rs | 59 +-- .../arbiter-server/src/actors/client/mod.rs | 27 +- .../src/actors/client/session.rs | 23 +- .../arbiter-server/src/actors/evm/mod.rs | 57 ++- .../src/actors/keyholder/mod.rs | 4 +- .../src/actors/user_agent/auth.rs | 66 +-- .../src/actors/user_agent/auth/state.rs | 68 +-- .../src/actors/user_agent/mod.rs | 114 ++++- .../src/actors/user_agent/session.rs | 262 +++++------ .../crates/arbiter-server/src/context/tls.rs | 7 +- server/crates/arbiter-server/src/evm/mod.rs | 10 +- .../crates/arbiter-server/src/evm/policies.rs | 1 - .../src/evm/policies/ether_transfer/tests.rs | 4 +- .../src/evm/policies/token_transfers/tests.rs | 119 +++-- .../arbiter-server/src/evm/safe_signer.rs | 12 +- .../crates/arbiter-server/src/grpc/client.rs | 137 ++++++ server/crates/arbiter-server/src/grpc/mod.rs | 65 +++ .../arbiter-server/src/grpc/user_agent.rs | 288 ++++++++++++ server/crates/arbiter-server/src/lib.rs | 181 +------- .../arbiter-server/tests/client/auth.rs | 37 +- .../arbiter-server/tests/user_agent/auth.rs | 59 +-- .../arbiter-server/tests/user_agent/unseal.rs | 89 ++-- server/crates/arbiter-useragent/Cargo.toml | 29 -- server/crates/arbiter-useragent/src/grpc.rs | 70 --- server/crates/arbiter-useragent/src/lib.rs | 419 ------------------ server/crates/arbiter-useragent/tests/auth.rs | 146 ------ 29 files changed, 1002 insertions(+), 1620 deletions(-) create mode 100644 server/crates/arbiter-server/src/grpc/client.rs create mode 100644 server/crates/arbiter-server/src/grpc/mod.rs create mode 100644 server/crates/arbiter-server/src/grpc/user_agent.rs delete mode 100644 server/crates/arbiter-useragent/Cargo.toml delete mode 100644 server/crates/arbiter-useragent/src/grpc.rs delete mode 100644 server/crates/arbiter-useragent/src/lib.rs delete mode 100644 server/crates/arbiter-useragent/tests/auth.rs diff --git a/server/Cargo.lock b/server/Cargo.lock index aa3fbc9..a77dfb1 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -755,30 +755,6 @@ dependencies = [ "alloy", ] -[[package]] -name = "arbiter-useragent" -version = "0.1.0" -dependencies = [ - "arbiter-proto", - "async-trait", - "ed25519-dalek", - "http", - "k256", - "kameo", - "rand 0.10.0", - "rsa", - "rustls-webpki", - "sha2 0.10.9", - "smlang", - "spki", - "thiserror", - "tokio", - "tokio-stream", - "tonic", - "tracing", - "x25519-dalek", -] - [[package]] name = "argon2" version = "0.5.3" diff --git a/server/crates/arbiter-proto/src/transport.rs b/server/crates/arbiter-proto/src/transport.rs index 6f89b80..55415c8 100644 --- a/server/crates/arbiter-proto/src/transport.rs +++ b/server/crates/arbiter-proto/src/transport.rs @@ -1,78 +1,39 @@ -//! Transport-facing abstractions for protocol/session code. +//! Transport-facing abstractions shared by protocol/session code. //! -//! This module separates three concerns: -//! -//! - protocol/session logic wants a small duplex interface ([`Bi`]) -//! - transport adapters push concrete stream items to an underlying IO layer -//! - transport boundaries translate between protocol-facing and transport-facing -//! item types via direction-specific converters +//! This module defines a small duplex interface, [`Bi`], that actors and other +//! protocol code can depend on without knowing anything about the concrete +//! transport underneath. //! //! [`Bi`] is intentionally minimal and transport-agnostic: -//! - [`Bi::recv`] yields inbound protocol messages -//! - [`Bi::send`] accepts outbound protocol/domain items +//! - [`Bi::recv`] yields inbound messages +//! - [`Bi::send`] accepts outbound messages +//! +//! Transport-specific adapters, including protobuf or gRPC bridges, live in the +//! crates that own those boundaries rather than in `arbiter-proto`. //! //! # Generic Ordering Rule //! -//! This module uses a single convention consistently: when a type or trait is -//! parameterized by protocol message directions, the generic parameters are -//! declared as `Inbound` first, then `Outbound`. +//! This module consistently uses `Inbound` first and `Outbound` second in +//! generic parameter lists. //! //! For [`Bi`], that means `Bi`: //! - `recv() -> Option` //! - `send(Outbound)` //! -//! For adapter types that are parameterized by direction-specific converters, -//! inbound-related converter parameters are declared before outbound-related -//! converter parameters. +//! [`expect_message`] is a small helper for request/response style flows: it +//! reads one inbound message from a transport and extracts a typed value from +//! it, failing if the channel closes or the message shape is not what the +//! caller expected. //! -//! [`RecvConverter`] and [`SendConverter`] are infallible conversion traits used -//! by adapters to map between protocol-facing and transport-facing item types. -//! The traits themselves are not result-aware; adapters decide how transport -//! errors are handled before (or instead of) conversion. -//! -//! [`grpc::GrpcAdapter`] combines: -//! - a tonic inbound stream -//! - a Tokio sender for outbound transport items -//! - a [`RecvConverter`] for the receive path -//! - a [`SendConverter`] for the send path -//! -//! [`DummyTransport`] is a no-op implementation useful for tests and local actor -//! execution where no real network stream exists. -//! -//! # Component Interaction -//! -//! ```text -//! inbound (network -> protocol) -//! ============================ -//! -//! tonic::Streaming -//! -> grpc::GrpcAdapter::recv() -//! | -//! +--> on `Ok(item)`: RecvConverter::convert(RecvTransport) -> Inbound -//! +--> on `Err(status)`: log error and close stream (`None`) -//! -> Bi::recv() -//! -> protocol/session actor -//! -//! outbound (protocol -> network) -//! ============================== -//! -//! protocol/session actor -//! -> Bi::send(Outbound) -//! -> grpc::GrpcAdapter::send() -//! | -//! +--> SendConverter::convert(Outbound) -> SendTransport -//! -> Tokio mpsc::Sender -//! -> tonic response stream -//! ``` +//! [`DummyTransport`] is a no-op implementation useful for tests and local +//! actor execution where no real stream exists. //! //! # Design Notes //! -//! - `send()` returns [`Error`] only for transport delivery failures (for -//! example, when the outbound channel is closed). -//! - [`grpc::GrpcAdapter`] logs tonic receive errors and treats them as stream -//! closure (`None`). -//! - When protocol-facing and transport-facing types are identical, use -//! [`IdentityRecvConverter`] / [`IdentitySendConverter`]. +//! - [`Bi::send`] returns [`Error`] only for transport delivery failures, such +//! as a closed outbound channel. +//! - [`Bi::recv`] returns `None` when the underlying transport closes. +//! - Message translation is intentionally out of scope for this module. use std::marker::PhantomData; @@ -114,162 +75,6 @@ pub trait Bi: Send + Sync + 'static { async fn recv(&mut self) -> Option; } -/// Converts transport-facing inbound items into protocol-facing inbound items. -pub trait RecvConverter: Send + Sync + 'static { - type Input; - type Output; - - fn convert(&self, item: Self::Input) -> Self::Output; -} - -/// Converts protocol/domain outbound items into transport-facing outbound items. -pub trait SendConverter: Send + Sync + 'static { - type Input; - type Output; - - fn convert(&self, item: Self::Input) -> Self::Output; -} - -/// A [`RecvConverter`] that forwards values unchanged. -pub struct IdentityRecvConverter { - _marker: PhantomData, -} - -impl IdentityRecvConverter { - pub fn new() -> Self { - Self { - _marker: PhantomData, - } - } -} - -impl Default for IdentityRecvConverter { - fn default() -> Self { - Self::new() - } -} - -impl RecvConverter for IdentityRecvConverter -where - T: Send + Sync + 'static, -{ - type Input = T; - type Output = T; - - fn convert(&self, item: Self::Input) -> Self::Output { - item - } -} - -/// A [`SendConverter`] that forwards values unchanged. -pub struct IdentitySendConverter { - _marker: PhantomData, -} - -impl IdentitySendConverter { - pub fn new() -> Self { - Self { - _marker: PhantomData, - } - } -} - -impl Default for IdentitySendConverter { - fn default() -> Self { - Self::new() - } -} - -impl SendConverter for IdentitySendConverter -where - T: Send + Sync + 'static, -{ - type Input = T; - type Output = T; - - fn convert(&self, item: Self::Input) -> Self::Output { - item - } -} - -/// gRPC-specific transport adapters and helpers. -pub mod grpc { - use async_trait::async_trait; - use futures::StreamExt; - use tokio::sync::mpsc; - use tonic::Streaming; - - use super::{Bi, Error, RecvConverter, SendConverter}; - - /// [`Bi`] adapter backed by a tonic gRPC bidirectional stream. - /// - /// Tonic receive errors are logged and treated as stream closure (`None`). - /// The receive converter is only invoked for successful inbound transport - /// items. - pub struct GrpcAdapter - where - InboundConverter: RecvConverter, - OutboundConverter: SendConverter, - { - sender: mpsc::Sender, - receiver: Streaming, - inbound_converter: InboundConverter, - outbound_converter: OutboundConverter, - } - - impl - GrpcAdapter - where - InboundConverter: RecvConverter, - OutboundConverter: SendConverter, - { - pub fn new( - sender: mpsc::Sender, - receiver: Streaming, - inbound_converter: InboundConverter, - outbound_converter: OutboundConverter, - ) -> Self { - Self { - sender, - receiver, - inbound_converter, - outbound_converter, - } - } - } - - #[async_trait] - impl Bi - for GrpcAdapter - where - InboundConverter: RecvConverter, - OutboundConverter: SendConverter, - OutboundConverter::Input: Send + 'static, - OutboundConverter::Output: Send + 'static, - { - #[tracing::instrument(level = "trace", skip(self, item))] - async fn send(&mut self, item: OutboundConverter::Input) -> Result<(), Error> { - let outbound = self.outbound_converter.convert(item); - self.sender - .send(outbound) - .await - .map_err(|_| Error::ChannelClosed) - } - - #[tracing::instrument(level = "trace", skip(self))] - async fn recv(&mut self) -> Option { - match self.receiver.next().await { - Some(Ok(item)) => Some(self.inbound_converter.convert(item)), - Some(Err(error)) => { - tracing::error!(error = ?error, "grpc transport recv failed; closing stream"); - None - } - None => None, - } - } - } -} - /// No-op [`Bi`] transport for tests and manual actor usage. /// /// `send` drops all items and succeeds. [`Bi::recv`] never resolves and therefore diff --git a/server/crates/arbiter-server/src/actors/bootstrap.rs b/server/crates/arbiter-server/src/actors/bootstrap.rs index 366a91a..515ad54 100644 --- a/server/crates/arbiter-server/src/actors/bootstrap.rs +++ b/server/crates/arbiter-server/src/actors/bootstrap.rs @@ -3,12 +3,7 @@ use diesel::QueryDsl; use diesel_async::RunQueryDsl; use kameo::{Actor, messages}; use miette::Diagnostic; -use rand::{ - RngExt, - distr::{Alphanumeric}, - make_rng, - rngs::StdRng, -}; +use rand::{RngExt, distr::Alphanumeric, make_rng, rngs::StdRng}; use thiserror::Error; use crate::db::{self, DatabasePool, schema}; @@ -61,7 +56,6 @@ impl Bootstrapper { drop(conn); - let token = if row_count == 0 { let token = generate_token().await?; Some(token) diff --git a/server/crates/arbiter-server/src/actors/client/auth.rs b/server/crates/arbiter-server/src/actors/client/auth.rs index cb11d9a..c69fb77 100644 --- a/server/crates/arbiter-server/src/actors/client/auth.rs +++ b/server/crates/arbiter-server/src/actors/client/auth.rs @@ -1,13 +1,4 @@ -use arbiter_proto::{ - format_challenge, - proto::client::{ - AuthChallenge, AuthChallengeSolution, ClientConnectError, ClientRequest, ClientResponse, - client_connect_error::Code as ConnectErrorCode, - client_request::Payload as ClientRequestPayload, - client_response::Payload as ClientResponsePayload, - }, - transport::expect_message, -}; +use arbiter_proto::{format_challenge, transport::expect_message}; use diesel::{ ExpressionMethods as _, OptionalExtension as _, QueryDsl as _, dsl::insert_into, update, }; @@ -18,7 +9,7 @@ use tracing::error; use crate::{ actors::{ - client::ClientConnection, + client::{ClientConnection, ConnectErrorCode, Request, Response}, router::{self, RequestClientApproval}, }, db::{self, schema::program_client}, @@ -155,15 +146,13 @@ async fn challenge_client( pubkey: VerifyingKey, nonce: i32, ) -> Result<(), Error> { - let challenge = AuthChallenge { - pubkey: pubkey.as_bytes().to_vec(), - nonce, - }; + let challenge_pubkey = pubkey.as_bytes().to_vec(); props .transport - .send(Ok(ClientResponse { - payload: Some(ClientResponsePayload::AuthChallenge(challenge.clone())), + .send(Ok(Response::AuthChallenge { + pubkey: challenge_pubkey.clone(), + nonce, })) .await .map_err(|e| { @@ -171,20 +160,17 @@ async fn challenge_client( Error::Transport })?; - let AuthChallengeSolution { signature } = - expect_message(&mut *props.transport, |req: ClientRequest| { - match req.payload? { - ClientRequestPayload::AuthChallengeSolution(s) => Some(s), - _ => None, - } - }) - .await - .map_err(|e| { - error!(error = ?e, "Failed to receive challenge solution"); - Error::Transport - })?; + let signature = expect_message(&mut *props.transport, |req: Request| match req { + Request::AuthChallengeSolution { signature } => Some(signature), + _ => None, + }) + .await + .map_err(|e| { + error!(error = ?e, "Failed to receive challenge solution"); + Error::Transport + })?; - let formatted = format_challenge(nonce, &challenge.pubkey); + let formatted = format_challenge(nonce, &challenge_pubkey); let sig = signature.as_slice().try_into().map_err(|_| { error!("Invalid signature length"); Error::InvalidChallengeSolution @@ -209,15 +195,14 @@ fn connect_error_code(err: &Error) -> ConnectErrorCode { } async fn authenticate(props: &mut ClientConnection) -> Result { - let Some(ClientRequest { - payload: Some(ClientRequestPayload::AuthChallengeRequest(challenge)), + let Some(Request::AuthChallengeRequest { + pubkey: challenge_pubkey, }) = props.transport.recv().await else { return Err(Error::Transport); }; - let pubkey_bytes = challenge - .pubkey + let pubkey_bytes = challenge_pubkey .as_array() .ok_or(Error::InvalidClientPubkeyLength)?; let pubkey = @@ -244,11 +229,7 @@ pub async fn authenticate_and_create(mut props: ClientConnection) -> Result> + Send>; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConnectErrorCode { + Unknown, + ApprovalDenied, + NoUserAgentsOnline, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Request { + AuthChallengeRequest { pubkey: Vec }, + AuthChallengeSolution { signature: Vec }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Response { + AuthChallenge { pubkey: Vec, nonce: i32 }, + AuthOk, + ClientConnectError { code: ConnectErrorCode }, +} + +pub type Transport = Box> + Send>; pub struct ClientConnection { pub(crate) db: db::DatabasePool, diff --git a/server/crates/arbiter-server/src/actors/client/session.rs b/server/crates/arbiter-server/src/actors/client/session.rs index a2ae4a4..fb18feb 100644 --- a/server/crates/arbiter-server/src/actors/client/session.rs +++ b/server/crates/arbiter-server/src/actors/client/session.rs @@ -1,11 +1,15 @@ -use arbiter_proto::proto::client::{ClientRequest, ClientResponse}; use kameo::Actor; use tokio::select; use tracing::{error, info}; -use crate::{actors::{ - GlobalActors, client::{ClientError, ClientConnection}, router::RegisterClient -}, db}; +use crate::{ + actors::{ + GlobalActors, + client::{ClientConnection, ClientError, Request, Response}, + router::RegisterClient, + }, + db, +}; pub struct ClientSession { props: ClientConnection, @@ -16,18 +20,13 @@ impl ClientSession { Self { props } } - pub async fn process_transport_inbound(&mut self, req: ClientRequest) -> Output { - let msg = req.payload.ok_or_else(|| { - error!(actor = "client", "Received message with no payload"); - ClientError::MissingRequestPayload - })?; - - let _ = msg; + pub async fn process_transport_inbound(&mut self, req: Request) -> Output { + let _ = req; Err(ClientError::UnexpectedRequestPayload) } } -type Output = Result; +type Output = Result; impl Actor for ClientSession { type Args = Self; diff --git a/server/crates/arbiter-server/src/actors/evm/mod.rs b/server/crates/arbiter-server/src/actors/evm/mod.rs index 012b41c..5c7ff3e 100644 --- a/server/crates/arbiter-server/src/actors/evm/mod.rs +++ b/server/crates/arbiter-server/src/actors/evm/mod.rs @@ -1,5 +1,7 @@ use alloy::{consensus::TxEip1559, primitives::Address, signers::Signature}; -use diesel::{ExpressionMethods, OptionalExtension as _, QueryDsl, SelectableHelper as _, dsl::insert_into}; +use diesel::{ + ExpressionMethods, OptionalExtension as _, QueryDsl, SelectableHelper as _, dsl::insert_into, +}; use diesel_async::RunQueryDsl; use kameo::{Actor, actor::ActorRef, messages}; use memsafe::MemSafe; @@ -7,13 +9,16 @@ use rand::{SeedableRng, rng, rngs::StdRng}; use crate::{ actors::keyholder::{CreateNew, Decrypt, KeyHolder}, - db::{self, DatabasePool, models::{self, EvmBasicGrant, SqliteTimestamp}, schema}, + db::{ + self, DatabasePool, + models::{self, EvmBasicGrant, SqliteTimestamp}, + schema, + }, evm::{ self, RunKind, policies::{ FullGrant, SharedGrantSettings, SpecificGrant, SpecificMeaning, - ether_transfer::EtherTransfer, - token_transfers::TokenTransfer, + ether_transfer::EtherTransfer, token_transfers::TokenTransfer, }, }, }; @@ -88,7 +93,12 @@ impl EvmActor { // todo: audit let rng = StdRng::from_rng(&mut rng()); let engine = evm::Engine::new(db.clone()); - Self { keyholder, db, rng, engine } + Self { + keyholder, + db, + rng, + engine, + } } } @@ -149,12 +159,24 @@ impl EvmActor { match grant { SpecificGrant::EtherTransfer(settings) => { self.engine - .create_grant::(client_id, FullGrant { basic, specific: settings }) + .create_grant::( + client_id, + FullGrant { + basic, + specific: settings, + }, + ) .await } SpecificGrant::TokenTransfer(settings) => { self.engine - .create_grant::(client_id, FullGrant { basic, specific: settings }) + .create_grant::( + client_id, + FullGrant { + basic, + specific: settings, + }, + ) .await } } @@ -204,8 +226,14 @@ impl EvmActor { .ok_or(SignTransactionError::WalletNotFound)?; drop(conn); - let meaning = self.engine - .evaluate_transaction(wallet.id, client_id, transaction.clone(), RunKind::Execution) + let meaning = self + .engine + .evaluate_transaction( + wallet.id, + client_id, + transaction.clone(), + RunKind::Execution, + ) .await?; Ok(meaning) @@ -230,14 +258,21 @@ impl EvmActor { let raw_key: MemSafe> = self .keyholder - .ask(Decrypt { aead_id: wallet.aead_encrypted_id }) + .ask(Decrypt { + aead_id: wallet.aead_encrypted_id, + }) .await .map_err(|_| SignTransactionError::KeyholderSend)?; let signer = safe_signer::SafeSigner::from_memsafe(raw_key)?; self.engine - .evaluate_transaction(wallet.id, client_id, transaction.clone(), RunKind::Execution) + .evaluate_transaction( + wallet.id, + client_id, + transaction.clone(), + RunKind::Execution, + ) .await?; use alloy::network::TxSignerSync as _; diff --git a/server/crates/arbiter-server/src/actors/keyholder/mod.rs b/server/crates/arbiter-server/src/actors/keyholder/mod.rs index c8e97ab..f7cc8cc 100644 --- a/server/crates/arbiter-server/src/actors/keyholder/mod.rs +++ b/server/crates/arbiter-server/src/actors/keyholder/mod.rs @@ -313,7 +313,7 @@ impl KeyHolder { current_nonce: nonce.to_vec(), schema_version: 1, associated_root_key_id: *root_key_history_id, - created_at: Utc::now().into() + created_at: Utc::now().into(), }) .returning(schema::aead_encrypted::id) .get_result(&mut conn) @@ -346,7 +346,7 @@ impl KeyHolder { #[cfg(test)] mod tests { use diesel::SelectableHelper; - + use diesel_async::RunQueryDsl; use memsafe::MemSafe; diff --git a/server/crates/arbiter-server/src/actors/user_agent/auth.rs b/server/crates/arbiter-server/src/actors/user_agent/auth.rs index 1e0fe20..eab7acf 100644 --- a/server/crates/arbiter-server/src/actors/user_agent/auth.rs +++ b/server/crates/arbiter-server/src/actors/user_agent/auth.rs @@ -1,12 +1,9 @@ -use arbiter_proto::proto::user_agent::{ - AuthChallengeRequest, AuthChallengeSolution, KeyType as ProtoKeyType, UserAgentRequest, - user_agent_request::Payload as UserAgentRequestPayload, -}; use tracing::error; use crate::actors::user_agent::{ - UserAgentConnection, - auth::state::{AuthContext, AuthPublicKey, AuthStateMachine}, + Request, UserAgentConnection, + auth::state::{AuthContext, AuthStateMachine}, + AuthPublicKey, session::UserAgentSession, }; @@ -37,54 +34,20 @@ pub enum Error { mod state; use state::*; -fn parse_pubkey(key_type: ProtoKeyType, pubkey: Vec) -> Result { - match key_type { - // UNSPECIFIED treated as Ed25519 for backward compatibility - ProtoKeyType::Unspecified | ProtoKeyType::Ed25519 => { - let pubkey_bytes = pubkey.as_array().ok_or(Error::InvalidClientPubkeyLength)?; - let key = ed25519_dalek::VerifyingKey::from_bytes(pubkey_bytes) - .map_err(|_| Error::InvalidAuthPubkeyEncoding)?; - Ok(AuthPublicKey::Ed25519(key)) - } - ProtoKeyType::EcdsaSecp256k1 => { - // Public key is sent as 33-byte SEC1 compressed point - let key = k256::ecdsa::VerifyingKey::from_sec1_bytes(&pubkey) - .map_err(|_| Error::InvalidAuthPubkeyEncoding)?; - Ok(AuthPublicKey::EcdsaSecp256k1(key)) - } - ProtoKeyType::Rsa => { - use rsa::pkcs8::DecodePublicKey as _; - let key = rsa::RsaPublicKey::from_public_key_der(&pubkey) - .map_err(|_| Error::InvalidAuthPubkeyEncoding)?; - Ok(AuthPublicKey::Rsa(key)) - } - } -} - -fn parse_auth_event(payload: UserAgentRequestPayload) -> Result { +fn parse_auth_event(payload: Request) -> Result { match payload { - UserAgentRequestPayload::AuthChallengeRequest(AuthChallengeRequest { + Request::AuthChallengeRequest { pubkey, bootstrap_token: None, - key_type, - }) => { - let kt = ProtoKeyType::try_from(key_type).unwrap_or(ProtoKeyType::Unspecified); - Ok(AuthEvents::AuthRequest(ChallengeRequest { - pubkey: parse_pubkey(kt, pubkey)?, - })) - } - UserAgentRequestPayload::AuthChallengeRequest(AuthChallengeRequest { + } => Ok(AuthEvents::AuthRequest(ChallengeRequest { pubkey })), + Request::AuthChallengeRequest { pubkey, bootstrap_token: Some(token), - key_type, - }) => { - let kt = ProtoKeyType::try_from(key_type).unwrap_or(ProtoKeyType::Unspecified); - Ok(AuthEvents::BootstrapAuthRequest(BootstrapAuthRequest { - pubkey: parse_pubkey(kt, pubkey)?, - token, - })) - } - UserAgentRequestPayload::AuthChallengeSolution(AuthChallengeSolution { signature }) => { + } => Ok(AuthEvents::BootstrapAuthRequest(BootstrapAuthRequest { + pubkey, + token, + })), + Request::AuthChallengeSolution { signature } => { Ok(AuthEvents::ReceivedSolution(ChallengeSolution { solution: signature, })) @@ -99,10 +62,7 @@ pub async fn authenticate(props: &mut UserAgentConnection) -> Result Vec { - match self { - AuthPublicKey::Ed25519(k) => k.to_bytes().to_vec(), - // SEC1 compressed (33 bytes) is the natural compact format for secp256k1 - AuthPublicKey::EcdsaSecp256k1(k) => k.to_encoded_point(true).as_bytes().to_vec(), - AuthPublicKey::Rsa(k) => { - use rsa::pkcs8::EncodePublicKey as _; - k.to_public_key_der() - .expect("rsa SPKI encoding is infallible") - .to_vec() - } - } - } - - pub fn key_type(&self) -> KeyType { - match self { - AuthPublicKey::Ed25519(_) => KeyType::Ed25519, - AuthPublicKey::EcdsaSecp256k1(_) => KeyType::EcdsaSecp256k1, - AuthPublicKey::Rsa(_) => KeyType::Rsa, - } - } -} - pub struct ChallengeRequest { pub pubkey: AuthPublicKey, } @@ -58,7 +21,7 @@ pub struct BootstrapAuthRequest { } pub struct ChallengeContext { - pub challenge: AuthChallenge, + pub challenge_nonce: i32, pub key: AuthPublicKey, } @@ -155,16 +118,9 @@ impl AuthStateMachineContext for AuthContext<'_> { let stored_bytes = pubkey.to_stored_bytes(); let nonce = create_nonce(&self.conn.db, &stored_bytes).await?; - let challenge = AuthChallenge { - pubkey: stored_bytes, - nonce, - }; - self.conn .transport - .send(Ok(UserAgentResponse { - payload: Some(UserAgentResponsePayload::AuthChallenge(challenge.clone())), - })) + .send(Ok(Response::AuthChallenge { nonce })) .await .map_err(|e| { error!(?e, "Failed to send auth challenge"); @@ -172,7 +128,7 @@ impl AuthStateMachineContext for AuthContext<'_> { })?; Ok(ChallengeContext { - challenge, + challenge_nonce: nonce, key: pubkey, }) } @@ -217,10 +173,10 @@ impl AuthStateMachineContext for AuthContext<'_> { #[allow(clippy::unused_unit)] async fn verify_solution( &mut self, - ChallengeContext { challenge, key }: &ChallengeContext, + ChallengeContext { challenge_nonce, key }: &ChallengeContext, ChallengeSolution { solution }: ChallengeSolution, ) -> Result { - let formatted = arbiter_proto::format_challenge(challenge.nonce, &challenge.pubkey); + let formatted = arbiter_proto::format_challenge(*challenge_nonce, &key.to_stored_bytes()); let valid = match key { AuthPublicKey::Ed25519(vk) => { @@ -252,9 +208,7 @@ impl AuthStateMachineContext for AuthContext<'_> { if valid { self.conn .transport - .send(Ok(UserAgentResponse { - payload: Some(UserAgentResponsePayload::AuthOk(AuthOk {})), - })) + .send(Ok(Response::AuthOk)) .await .map_err(|_| Error::Transport)?; } diff --git a/server/crates/arbiter-server/src/actors/user_agent/mod.rs b/server/crates/arbiter-server/src/actors/user_agent/mod.rs index 98d1eee..866219b 100644 --- a/server/crates/arbiter-server/src/actors/user_agent/mod.rs +++ b/server/crates/arbiter-server/src/actors/user_agent/mod.rs @@ -1,19 +1,15 @@ -use arbiter_proto::{ - proto::user_agent::{UserAgentRequest, UserAgentResponse}, - transport::Bi, -}; +use alloy::primitives::Address; +use arbiter_proto::transport::Bi; use kameo::actor::Spawn as _; use tracing::{error, info}; use crate::{ - actors::{GlobalActors, user_agent::session::UserAgentSession}, - db::{self}, + actors::{GlobalActors, evm, user_agent::session::UserAgentSession}, + db::{self, models::KeyType}, }; #[derive(Debug, thiserror::Error, PartialEq)] pub enum TransportResponseError { - #[error("Expected message with payload")] - MissingRequestPayload, #[error("Unexpected request payload")] UnexpectedRequestPayload, #[error("Invalid state for unseal encrypted key")] @@ -30,8 +26,106 @@ pub enum TransportResponseError { ConnectionRegistrationFailed, } -pub type Transport = - Box> + Send>; +/// Abstraction over Ed25519 / ECDSA-secp256k1 / RSA public keys used during the auth handshake. +#[derive(Clone, Debug)] +pub enum AuthPublicKey { + Ed25519(ed25519_dalek::VerifyingKey), + /// Compressed SEC1 public key; signature bytes are raw 64-byte (r||s). + EcdsaSecp256k1(k256::ecdsa::VerifyingKey), + /// RSA-2048+ public key (Windows Hello / KeyCredentialManager); signature bytes are PSS+SHA-256. + Rsa(rsa::RsaPublicKey), +} + +impl AuthPublicKey { + /// Canonical bytes stored in DB and echoed back in the challenge. + /// Ed25519: raw 32 bytes. ECDSA: SEC1 compressed 33 bytes. RSA: DER-encoded SPKI. + pub fn to_stored_bytes(&self) -> Vec { + match self { + AuthPublicKey::Ed25519(k) => k.to_bytes().to_vec(), + // SEC1 compressed (33 bytes) is the natural compact format for secp256k1 + AuthPublicKey::EcdsaSecp256k1(k) => k.to_encoded_point(true).as_bytes().to_vec(), + AuthPublicKey::Rsa(k) => { + use rsa::pkcs8::EncodePublicKey as _; + k.to_public_key_der() + .expect("rsa SPKI encoding is infallible") + .to_vec() + } + } + } + + pub fn key_type(&self) -> KeyType { + match self { + AuthPublicKey::Ed25519(_) => KeyType::Ed25519, + AuthPublicKey::EcdsaSecp256k1(_) => KeyType::EcdsaSecp256k1, + AuthPublicKey::Rsa(_) => KeyType::Rsa, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UnsealError { + InvalidKey, + Unbootstrapped, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BootstrapError { + AlreadyBootstrapped, + InvalidKey, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VaultState { + Unbootstrapped, + Sealed, + Unsealed, +} + +#[derive(Debug, Clone)] +pub enum Request { + AuthChallengeRequest { + pubkey: AuthPublicKey, + bootstrap_token: Option, + }, + AuthChallengeSolution { + signature: Vec, + }, + UnsealStart { + client_pubkey: x25519_dalek::PublicKey, + }, + UnsealEncryptedKey { + nonce: Vec, + ciphertext: Vec, + associated_data: Vec, + }, + BootstrapEncryptedKey { + nonce: Vec, + ciphertext: Vec, + associated_data: Vec, + }, + QueryVaultState, + EvmWalletCreate, + EvmWalletList, + ClientConnectionResponse { + approved: bool, + }, +} + +#[derive(Debug)] +pub enum Response { + AuthChallenge { nonce: i32 }, + AuthOk, + UnsealStartResponse { server_pubkey: x25519_dalek::PublicKey }, + UnsealResult(Result<(), UnsealError>), + BootstrapResult(Result<(), BootstrapError>), + VaultState(VaultState), + ClientConnectionRequest { pubkey: ed25519_dalek::VerifyingKey }, + ClientConnectionCancel, + EvmWalletCreate(Result<(), evm::Error>), + EvmWalletList(Vec
), +} + +pub type Transport = Box> + Send>; pub struct UserAgentConnection { db: db::DatabasePool, diff --git a/server/crates/arbiter-server/src/actors/user_agent/session.rs b/server/crates/arbiter-server/src/actors/user_agent/session.rs index 376273d..0a9f893 100644 --- a/server/crates/arbiter-server/src/actors/user_agent/session.rs +++ b/server/crates/arbiter-server/src/actors/user_agent/session.rs @@ -1,14 +1,5 @@ use std::{ops::DerefMut, sync::Mutex}; -use arbiter_proto::proto::{ - evm as evm_proto, - user_agent::{ - BootstrapEncryptedKey, BootstrapResult, ClientConnectionCancel, ClientConnectionRequest, - UnsealEncryptedKey, UnsealResult, UnsealStart, UnsealStartResponse, UserAgentRequest, - UserAgentResponse, user_agent_request::Payload as UserAgentRequestPayload, - user_agent_response::Payload as UserAgentResponsePayload, - }, -}; use chacha20poly1305::{AeadInPlace, XChaCha20Poly1305, XNonce, aead::KeyInit}; use ed25519_dalek::VerifyingKey; use kameo::{Actor, error::SendError, messages, prelude::Context}; @@ -21,7 +12,10 @@ use crate::actors::{ evm::{Generate, ListWallets}, keyholder::{self, Bootstrap, TryUnseal}, router::RegisterUserAgent, - user_agent::{TransportResponseError, UserAgentConnection}, + user_agent::{ + BootstrapError, Request, Response, TransportResponseError, UnsealError, + UserAgentConnection, VaultState, + }, }; mod state; @@ -60,21 +54,17 @@ impl UserAgentSession { async fn send_msg( &mut self, - msg: UserAgentResponsePayload, + msg: Response, _ctx: &mut Context, ) -> Result<(), Error> { - self.props - .transport - .send(Ok(response(msg))) - .await - .map_err(|_| { - error!( - actor = "useragent", - reason = "channel closed", - "send.failed" - ); - Error::ConnectionLost - }) + self.props.transport.send(Ok(msg)).await.map_err(|_| { + error!( + actor = "useragent", + reason = "channel closed", + "send.failed" + ); + Error::ConnectionLost + }) } async fn expect_msg( @@ -83,7 +73,7 @@ impl UserAgentSession { ctx: &mut Context, ) -> Result where - Extractor: FnOnce(UserAgentRequestPayload) -> Option, + Extractor: FnOnce(Request) -> Option, Reply: kameo::Reply, { let msg = self.props.transport.recv().await.ok_or_else(|| { @@ -96,7 +86,7 @@ impl UserAgentSession { Error::ConnectionLost })?; - msg.payload.and_then(extractor).ok_or_else(|| { + extractor(msg).ok_or_else(|| { error!( actor = "useragent", reason = "unexpected message", @@ -119,18 +109,16 @@ impl UserAgentSession { ctx: &mut Context>, ) -> Result { self.send_msg( - UserAgentResponsePayload::ClientConnectionRequest(ClientConnectionRequest { - pubkey: client_pubkey.as_bytes().to_vec(), - }), + Response::ClientConnectionRequest { + pubkey: client_pubkey, + }, ctx, ) .await?; let extractor = |msg| { - if let UserAgentRequestPayload::ClientConnectionResponse(client_connection_response) = - msg - { - Some(client_connection_response) + if let Request::ClientConnectionResponse { approved } = msg { + Some(approved) } else { None } @@ -140,53 +128,55 @@ impl UserAgentSession { _ = cancel_flag.changed() => { info!(actor = "useragent", "client connection approval cancelled"); self.send_msg( - UserAgentResponsePayload::ClientConnectionCancel(ClientConnectionCancel {}), + Response::ClientConnectionCancel, ctx, ).await?; Ok(false) } result = self.expect_msg(extractor, ctx) => { let result = result?; - info!(actor = "useragent", "received client connection approval result: approved={}", result.approved); - Ok(result.approved) + info!(actor = "useragent", "received client connection approval result: approved={}", result); + Ok(result) } } } } impl UserAgentSession { - pub async fn process_transport_inbound(&mut self, req: UserAgentRequest) -> Output { - let msg = req.payload.ok_or_else(|| { - error!(actor = "useragent", "Received message with no payload"); - TransportResponseError::MissingRequestPayload - })?; - - match msg { - UserAgentRequestPayload::UnsealStart(unseal_start) => { - self.handle_unseal_request(unseal_start).await + pub async fn process_transport_inbound(&mut self, req: Request) -> Output { + match req { + Request::UnsealStart { client_pubkey } => { + self.handle_unseal_request(client_pubkey).await } - UserAgentRequestPayload::UnsealEncryptedKey(unseal_encrypted_key) => { - self.handle_unseal_encrypted_key(unseal_encrypted_key).await - } - UserAgentRequestPayload::BootstrapEncryptedKey(bootstrap_encrypted_key) => { - self.handle_bootstrap_encrypted_key(bootstrap_encrypted_key) + Request::UnsealEncryptedKey { + nonce, + ciphertext, + associated_data, + } => { + self.handle_unseal_encrypted_key(nonce, ciphertext, associated_data) .await } - UserAgentRequestPayload::QueryVaultState(_) => self.handle_query_vault_state().await, - UserAgentRequestPayload::EvmWalletCreate(_) => self.handle_evm_wallet_create().await, - UserAgentRequestPayload::EvmWalletList(_) => self.handle_evm_wallet_list().await, - _ => Err(TransportResponseError::UnexpectedRequestPayload), + Request::BootstrapEncryptedKey { + nonce, + ciphertext, + associated_data, + } => { + self.handle_bootstrap_encrypted_key(nonce, ciphertext, associated_data) + .await + } + Request::QueryVaultState => self.handle_query_vault_state().await, + Request::EvmWalletCreate => self.handle_evm_wallet_create().await, + Request::EvmWalletList => self.handle_evm_wallet_list().await, + Request::AuthChallengeRequest { .. } + | Request::AuthChallengeSolution { .. } + | Request::ClientConnectionResponse { .. } => { + Err(TransportResponseError::UnexpectedRequestPayload) + } } } } -type Output = Result; - -fn response(payload: UserAgentResponsePayload) -> UserAgentResponse { - UserAgentResponse { - payload: Some(payload), - } -} +type Output = Result; impl UserAgentSession { fn take_unseal_secret( @@ -242,37 +232,31 @@ impl UserAgentSession { } } - async fn handle_unseal_request(&mut self, req: UnsealStart) -> Output { + async fn handle_unseal_request(&mut self, client_pubkey: x25519_dalek::PublicKey) -> Output { let secret = EphemeralSecret::random(); let public_key = PublicKey::from(&secret); - let client_pubkey_bytes: [u8; 32] = req - .client_pubkey - .try_into() - .map_err(|_| TransportResponseError::InvalidClientPubkeyLength)?; - - let client_public_key = PublicKey::from(client_pubkey_bytes); - self.transition(UserAgentEvents::UnsealRequest(UnsealContext { secret: Mutex::new(Some(secret)), - client_public_key, + client_public_key: client_pubkey }))?; - Ok(response(UserAgentResponsePayload::UnsealStartResponse( - UnsealStartResponse { - server_pubkey: public_key.as_bytes().to_vec(), - }, - ))) + Ok(Response::UnsealStartResponse { + server_pubkey: public_key, + }) } - async fn handle_unseal_encrypted_key(&mut self, req: UnsealEncryptedKey) -> Output { + async fn handle_unseal_encrypted_key( + &mut self, + nonce: Vec, + ciphertext: Vec, + associated_data: Vec, + ) -> Output { let (ephemeral_secret, client_public_key) = match self.take_unseal_secret() { Ok(values) => values, Err(TransportResponseError::StateTransitionFailed) => { self.transition(UserAgentEvents::ReceivedInvalidKey)?; - return Ok(response(UserAgentResponsePayload::UnsealResult( - UnsealResult::InvalidKey.into(), - ))); + return Ok(Response::UnsealResult(Err(UnsealError::InvalidKey))); } Err(err) => return Err(err), }; @@ -280,16 +264,14 @@ impl UserAgentSession { let seal_key_buffer = match Self::decrypt_client_key_material( ephemeral_secret, client_public_key, - &req.nonce, - &req.ciphertext, - &req.associated_data, + &nonce, + &ciphertext, + &associated_data, ) { Ok(buffer) => buffer, Err(()) => { self.transition(UserAgentEvents::ReceivedInvalidKey)?; - return Ok(response(UserAgentResponsePayload::UnsealResult( - UnsealResult::InvalidKey.into(), - ))); + return Ok(Response::UnsealResult(Err(UnsealError::InvalidKey))); } }; @@ -305,22 +287,16 @@ impl UserAgentSession { Ok(_) => { info!("Successfully unsealed key with client-provided key"); self.transition(UserAgentEvents::ReceivedValidKey)?; - Ok(response(UserAgentResponsePayload::UnsealResult( - UnsealResult::Success.into(), - ))) + Ok(Response::UnsealResult(Ok(()))) } Err(SendError::HandlerError(keyholder::Error::InvalidKey)) => { self.transition(UserAgentEvents::ReceivedInvalidKey)?; - Ok(response(UserAgentResponsePayload::UnsealResult( - UnsealResult::InvalidKey.into(), - ))) + Ok(Response::UnsealResult(Err(UnsealError::InvalidKey))) } Err(SendError::HandlerError(err)) => { error!(?err, "Keyholder failed to unseal key"); self.transition(UserAgentEvents::ReceivedInvalidKey)?; - Ok(response(UserAgentResponsePayload::UnsealResult( - UnsealResult::InvalidKey.into(), - ))) + Ok(Response::UnsealResult(Err(UnsealError::InvalidKey))) } Err(err) => { error!(?err, "Failed to send unseal request to keyholder"); @@ -330,14 +306,17 @@ impl UserAgentSession { } } - async fn handle_bootstrap_encrypted_key(&mut self, req: BootstrapEncryptedKey) -> Output { + async fn handle_bootstrap_encrypted_key( + &mut self, + nonce: Vec, + ciphertext: Vec, + associated_data: Vec, + ) -> Output { let (ephemeral_secret, client_public_key) = match self.take_unseal_secret() { Ok(values) => values, Err(TransportResponseError::StateTransitionFailed) => { self.transition(UserAgentEvents::ReceivedInvalidKey)?; - return Ok(response(UserAgentResponsePayload::BootstrapResult( - BootstrapResult::InvalidKey.into(), - ))); + return Ok(Response::BootstrapResult(Err(BootstrapError::InvalidKey))); } Err(err) => return Err(err), }; @@ -345,16 +324,14 @@ impl UserAgentSession { let seal_key_buffer = match Self::decrypt_client_key_material( ephemeral_secret, client_public_key, - &req.nonce, - &req.ciphertext, - &req.associated_data, + &nonce, + &ciphertext, + &associated_data, ) { Ok(buffer) => buffer, Err(()) => { self.transition(UserAgentEvents::ReceivedInvalidKey)?; - return Ok(response(UserAgentResponsePayload::BootstrapResult( - BootstrapResult::InvalidKey.into(), - ))); + return Ok(Response::BootstrapResult(Err(BootstrapError::InvalidKey))); } }; @@ -370,22 +347,18 @@ impl UserAgentSession { Ok(_) => { info!("Successfully bootstrapped vault with client-provided key"); self.transition(UserAgentEvents::ReceivedValidKey)?; - Ok(response(UserAgentResponsePayload::BootstrapResult( - BootstrapResult::Success.into(), - ))) + Ok(Response::BootstrapResult(Ok(()))) } Err(SendError::HandlerError(keyholder::Error::AlreadyBootstrapped)) => { self.transition(UserAgentEvents::ReceivedInvalidKey)?; - Ok(response(UserAgentResponsePayload::BootstrapResult( - BootstrapResult::AlreadyBootstrapped.into(), + Ok(Response::BootstrapResult(Err( + BootstrapError::AlreadyBootstrapped, ))) } Err(SendError::HandlerError(err)) => { error!(?err, "Keyholder failed to bootstrap vault"); self.transition(UserAgentEvents::ReceivedInvalidKey)?; - Ok(response(UserAgentResponsePayload::BootstrapResult( - BootstrapResult::InvalidKey.into(), - ))) + Ok(Response::BootstrapResult(Err(BootstrapError::InvalidKey))) } Err(err) => { error!(?err, "Failed to send bootstrap request to keyholder"); @@ -399,7 +372,6 @@ impl UserAgentSession { impl UserAgentSession { async fn handle_query_vault_state(&mut self) -> Output { use crate::actors::keyholder::{GetState, StateDiscriminants}; - use arbiter_proto::proto::user_agent::VaultState; let vault_state = match self.props.actors.key_holder.ask(GetState {}).await { Ok(StateDiscriminants::Unbootstrapped) => VaultState::Unbootstrapped, @@ -407,70 +379,34 @@ impl UserAgentSession { Ok(StateDiscriminants::Unsealed) => VaultState::Unsealed, Err(err) => { error!(?err, actor = "useragent", "keyholder.query.failed"); - VaultState::Error + return Err(TransportResponseError::KeyHolderActorUnreachable); } }; - Ok(response(UserAgentResponsePayload::VaultState( - vault_state.into(), - ))) + Ok(Response::VaultState(vault_state)) } } impl UserAgentSession { async fn handle_evm_wallet_create(&mut self) -> Output { - use evm_proto::wallet_create_response::Result as CreateResult; - let result = match self.props.actors.evm.ask(Generate {}).await { - Ok(address) => CreateResult::Wallet(evm_proto::WalletEntry { - address: address.as_slice().to_vec(), - }), - Err(err) => CreateResult::Error(map_evm_error("wallet create", err).into()), + Ok(_address) => return Ok(Response::EvmWalletCreate(Ok(()))), + Err(SendError::HandlerError(err)) => Err(err), + Err(err) => { + error!(?err, "EVM actor unreachable during wallet create"); + return Err(TransportResponseError::KeyHolderActorUnreachable); + } }; - - Ok(response(UserAgentResponsePayload::EvmWalletCreate( - evm_proto::WalletCreateResponse { - result: Some(result), - }, - ))) + Ok(Response::EvmWalletCreate(result)) } async fn handle_evm_wallet_list(&mut self) -> Output { - use evm_proto::wallet_list_response::Result as ListResult; - - let result = match self.props.actors.evm.ask(ListWallets {}).await { - Ok(wallets) => ListResult::Wallets(evm_proto::WalletList { - wallets: wallets - .into_iter() - .map(|addr| evm_proto::WalletEntry { - address: addr.as_slice().to_vec(), - }) - .collect(), - }), - Err(err) => ListResult::Error(map_evm_error("wallet list", err).into()), - }; - - Ok(response(UserAgentResponsePayload::EvmWalletList( - evm_proto::WalletListResponse { - result: Some(result), - }, - ))) - } -} - -fn map_evm_error(op: &str, err: SendError) -> evm_proto::EvmError { - use crate::actors::{evm::Error as EvmError, keyholder::Error as KhError}; - match err { - SendError::HandlerError(EvmError::Keyholder(KhError::NotBootstrapped)) => { - evm_proto::EvmError::VaultSealed - } - SendError::HandlerError(err) => { - error!(?err, "EVM {op} failed"); - evm_proto::EvmError::Internal - } - _ => { - error!("EVM actor unreachable during {op}"); - evm_proto::EvmError::Internal + match self.props.actors.evm.ask(ListWallets {}).await { + Ok(wallets) => Ok(Response::EvmWalletList(wallets)), + Err(err) => { + error!(?err, "EVM wallet list failed"); + Err(TransportResponseError::KeyHolderActorUnreachable) + } } } } diff --git a/server/crates/arbiter-server/src/context/tls.rs b/server/crates/arbiter-server/src/context/tls.rs index 4a27764..85196ec 100644 --- a/server/crates/arbiter-server/src/context/tls.rs +++ b/server/crates/arbiter-server/src/context/tls.rs @@ -8,7 +8,7 @@ use rcgen::{ BasicConstraints, Certificate, CertificateParams, CertifiedIssuer, DistinguishedName, DnType, IsCa, Issuer, KeyPair, KeyUsagePurpose, }; -use rustls::pki_types::{pem::PemObject}; +use rustls::pki_types::pem::PemObject; use thiserror::Error; use tonic::transport::CertificateDer; @@ -59,10 +59,7 @@ pub enum InitError { pub type PemCert = String; pub fn encode_cert_to_pem(cert: &CertificateDer) -> PemCert { - pem::encode_config( - &Pem::new("CERTIFICATE", cert.to_vec()), - ENCODE_CONFIG, - ) + pem::encode_config(&Pem::new("CERTIFICATE", cert.to_vec()), ENCODE_CONFIG) } #[allow(unused)] diff --git a/server/crates/arbiter-server/src/evm/mod.rs b/server/crates/arbiter-server/src/evm/mod.rs index f295dc8..503735b 100644 --- a/server/crates/arbiter-server/src/evm/mod.rs +++ b/server/crates/arbiter-server/src/evm/mod.rs @@ -117,9 +117,7 @@ async fn check_shared_constraints( let now = Utc::now(); // Validity window - if shared.valid_from.is_some_and(|t| now < t) - || shared.valid_until.is_some_and(|t| now > t) - { + if shared.valid_from.is_some_and(|t| now < t) || shared.valid_until.is_some_and(|t| now > t) { violations.push(EvalViolation::InvalidTime); } @@ -127,9 +125,9 @@ async fn check_shared_constraints( let fee_exceeded = shared .max_gas_fee_per_gas .is_some_and(|cap| U256::from(context.max_fee_per_gas) > cap); - let priority_exceeded = shared.max_priority_fee_per_gas.is_some_and(|cap| { - U256::from(context.max_priority_fee_per_gas) > cap - }); + let priority_exceeded = shared + .max_priority_fee_per_gas + .is_some_and(|cap| U256::from(context.max_priority_fee_per_gas) > cap); if fee_exceeded || priority_exceeded { violations.push(EvalViolation::GasLimitExceeded { max_gas_fee_per_gas: shared.max_gas_fee_per_gas, diff --git a/server/crates/arbiter-server/src/evm/policies.rs b/server/crates/arbiter-server/src/evm/policies.rs index 23c3444..5a968cd 100644 --- a/server/crates/arbiter-server/src/evm/policies.rs +++ b/server/crates/arbiter-server/src/evm/policies.rs @@ -73,7 +73,6 @@ pub struct Grant { pub settings: PolicySettings, } - pub trait Policy: Sized { type Settings: Send + Sync + 'static + Into; type Meaning: Display + std::fmt::Debug + Send + Sync + 'static + Into; diff --git a/server/crates/arbiter-server/src/evm/policies/ether_transfer/tests.rs b/server/crates/arbiter-server/src/evm/policies/ether_transfer/tests.rs index 55a7744..0e52c19 100644 --- a/server/crates/arbiter-server/src/evm/policies/ether_transfer/tests.rs +++ b/server/crates/arbiter-server/src/evm/policies/ether_transfer/tests.rs @@ -9,9 +9,7 @@ use crate::db::{ schema::{evm_basic_grant, evm_transaction_log}, }; use crate::evm::{ - policies::{ - EvalContext, EvalViolation, Grant, Policy, SharedGrantSettings, VolumeRateLimit, - }, + policies::{EvalContext, EvalViolation, Grant, Policy, SharedGrantSettings, VolumeRateLimit}, utils, }; diff --git a/server/crates/arbiter-server/src/evm/policies/token_transfers/tests.rs b/server/crates/arbiter-server/src/evm/policies/token_transfers/tests.rs index 6cd8246..95d852b 100644 --- a/server/crates/arbiter-server/src/evm/policies/token_transfers/tests.rs +++ b/server/crates/arbiter-server/src/evm/policies/token_transfers/tests.rs @@ -140,10 +140,18 @@ async fn evaluate_rejects_nonzero_eth_value() { let mut context = ctx(DAI, calldata); context.value = U256::from(1u64); // ETH attached to an ERC-20 call - let m = TokenTransfer::analyze(&EvalContext { value: U256::ZERO, ..context.clone() }) + let m = TokenTransfer::analyze(&EvalContext { + value: U256::ZERO, + ..context.clone() + }) + .unwrap(); + let v = TokenTransfer::evaluate(&context, &m, &grant, &mut *conn) + .await .unwrap(); - let v = TokenTransfer::evaluate(&context, &m, &grant, &mut *conn).await.unwrap(); - assert!(v.iter().any(|e| matches!(e, EvalViolation::InvalidTransactionType))); + assert!( + v.iter() + .any(|e| matches!(e, EvalViolation::InvalidTransactionType)) + ); } #[tokio::test] @@ -160,7 +168,9 @@ async fn evaluate_passes_any_recipient_when_no_restriction() { let calldata = transfer_calldata(RECIPIENT, U256::from(100u64)); let context = ctx(DAI, calldata); let m = TokenTransfer::analyze(&context).unwrap(); - let v = TokenTransfer::evaluate(&context, &m, &grant, &mut *conn).await.unwrap(); + let v = TokenTransfer::evaluate(&context, &m, &grant, &mut *conn) + .await + .unwrap(); assert!(v.is_empty()); } @@ -178,7 +188,9 @@ async fn evaluate_passes_matching_restricted_recipient() { let calldata = transfer_calldata(RECIPIENT, U256::from(100u64)); let context = ctx(DAI, calldata); let m = TokenTransfer::analyze(&context).unwrap(); - let v = TokenTransfer::evaluate(&context, &m, &grant, &mut *conn).await.unwrap(); + let v = TokenTransfer::evaluate(&context, &m, &grant, &mut *conn) + .await + .unwrap(); assert!(v.is_empty()); } @@ -196,8 +208,13 @@ async fn evaluate_rejects_wrong_restricted_recipient() { let calldata = transfer_calldata(OTHER, U256::from(100u64)); let context = ctx(DAI, calldata); let m = TokenTransfer::analyze(&context).unwrap(); - let v = TokenTransfer::evaluate(&context, &m, &grant, &mut *conn).await.unwrap(); - assert!(v.iter().any(|e| matches!(e, EvalViolation::InvalidTarget { .. }))); + let v = TokenTransfer::evaluate(&context, &m, &grant, &mut *conn) + .await + .unwrap(); + assert!( + v.iter() + .any(|e| matches!(e, EvalViolation::InvalidTarget { .. })) + ); } #[tokio::test] @@ -207,7 +224,9 @@ async fn evaluate_passes_volume_within_limit() { let basic = insert_basic(&mut conn, false).await; let settings = make_settings(None, Some(1_000)); - let grant_id = TokenTransfer::create_grant(&basic, &settings, &mut *conn).await.unwrap(); + let grant_id = TokenTransfer::create_grant(&basic, &settings, &mut *conn) + .await + .unwrap(); // Record a past transfer of 500 (within 1000 limit) use crate::db::{models::NewEvmTokenTransferLog, schema::evm_token_transfer_log}; @@ -224,12 +243,22 @@ async fn evaluate_passes_volume_within_limit() { .await .unwrap(); - let grant = Grant { id: grant_id, shared_grant_id: basic.id, shared: shared(), settings }; + let grant = Grant { + id: grant_id, + shared_grant_id: basic.id, + shared: shared(), + settings, + }; let calldata = transfer_calldata(RECIPIENT, U256::from(100u64)); let context = ctx(DAI, calldata); let m = TokenTransfer::analyze(&context).unwrap(); - let v = TokenTransfer::evaluate(&context, &m, &grant, &mut *conn).await.unwrap(); - assert!(!v.iter().any(|e| matches!(e, EvalViolation::VolumetricLimitExceeded))); + let v = TokenTransfer::evaluate(&context, &m, &grant, &mut *conn) + .await + .unwrap(); + assert!( + !v.iter() + .any(|e| matches!(e, EvalViolation::VolumetricLimitExceeded)) + ); } #[tokio::test] @@ -239,7 +268,9 @@ async fn evaluate_rejects_volume_over_limit() { let basic = insert_basic(&mut conn, false).await; let settings = make_settings(None, Some(1_000)); - let grant_id = TokenTransfer::create_grant(&basic, &settings, &mut *conn).await.unwrap(); + let grant_id = TokenTransfer::create_grant(&basic, &settings, &mut *conn) + .await + .unwrap(); use crate::db::{models::NewEvmTokenTransferLog, schema::evm_token_transfer_log}; insert_into(evm_token_transfer_log::table) @@ -255,12 +286,22 @@ async fn evaluate_rejects_volume_over_limit() { .await .unwrap(); - let grant = Grant { id: grant_id, shared_grant_id: basic.id, shared: shared(), settings }; + let grant = Grant { + id: grant_id, + shared_grant_id: basic.id, + shared: shared(), + settings, + }; let calldata = transfer_calldata(RECIPIENT, U256::from(100u64)); let context = ctx(DAI, calldata); let m = TokenTransfer::analyze(&context).unwrap(); - let v = TokenTransfer::evaluate(&context, &m, &grant, &mut *conn).await.unwrap(); - assert!(v.iter().any(|e| matches!(e, EvalViolation::VolumetricLimitExceeded))); + let v = TokenTransfer::evaluate(&context, &m, &grant, &mut *conn) + .await + .unwrap(); + assert!( + v.iter() + .any(|e| matches!(e, EvalViolation::VolumetricLimitExceeded)) + ); } #[tokio::test] @@ -277,8 +318,13 @@ async fn evaluate_no_volume_limits_always_passes() { let calldata = transfer_calldata(RECIPIENT, U256::from(u64::MAX)); let context = ctx(DAI, calldata); let m = TokenTransfer::analyze(&context).unwrap(); - let v = TokenTransfer::evaluate(&context, &m, &grant, &mut *conn).await.unwrap(); - assert!(!v.iter().any(|e| matches!(e, EvalViolation::VolumetricLimitExceeded))); + let v = TokenTransfer::evaluate(&context, &m, &grant, &mut *conn) + .await + .unwrap(); + assert!( + !v.iter() + .any(|e| matches!(e, EvalViolation::VolumetricLimitExceeded)) + ); } // ── try_find_grant ─────────────────────────────────────────────────────── @@ -290,7 +336,9 @@ async fn try_find_grant_roundtrip() { let basic = insert_basic(&mut conn, false).await; let settings = make_settings(Some(RECIPIENT), Some(5_000)); - TokenTransfer::create_grant(&basic, &settings, &mut *conn).await.unwrap(); + TokenTransfer::create_grant(&basic, &settings, &mut *conn) + .await + .unwrap(); let calldata = transfer_calldata(RECIPIENT, U256::from(100u64)); let found = TokenTransfer::try_find_grant(&ctx(DAI, calldata), &mut *conn) @@ -312,7 +360,9 @@ async fn try_find_grant_revoked_returns_none() { let basic = insert_basic(&mut conn, true).await; let settings = make_settings(None, None); - TokenTransfer::create_grant(&basic, &settings, &mut *conn).await.unwrap(); + TokenTransfer::create_grant(&basic, &settings, &mut *conn) + .await + .unwrap(); let calldata = transfer_calldata(RECIPIENT, U256::from(1u64)); let found = TokenTransfer::try_find_grant(&ctx(DAI, calldata), &mut *conn) @@ -328,7 +378,9 @@ async fn try_find_grant_unknown_token_returns_none() { let basic = insert_basic(&mut conn, false).await; let settings = make_settings(None, None); - TokenTransfer::create_grant(&basic, &settings, &mut *conn).await.unwrap(); + TokenTransfer::create_grant(&basic, &settings, &mut *conn) + .await + .unwrap(); // Query with a different token contract let calldata = transfer_calldata(RECIPIENT, U256::from(1u64)); @@ -355,9 +407,13 @@ async fn find_all_grants_excludes_revoked() { let settings = make_settings(None, Some(1_000)); let active = insert_basic(&mut conn, false).await; - TokenTransfer::create_grant(&active, &settings, &mut *conn).await.unwrap(); + TokenTransfer::create_grant(&active, &settings, &mut *conn) + .await + .unwrap(); let revoked = insert_basic(&mut conn, true).await; - TokenTransfer::create_grant(&revoked, &settings, &mut *conn).await.unwrap(); + TokenTransfer::create_grant(&revoked, &settings, &mut *conn) + .await + .unwrap(); let all = TokenTransfer::find_all_grants(&mut *conn).await.unwrap(); assert_eq!(all.len(), 1); @@ -370,12 +426,17 @@ async fn find_all_grants_loads_volume_limits() { let basic = insert_basic(&mut conn, false).await; let settings = make_settings(None, Some(9_999)); - TokenTransfer::create_grant(&basic, &settings, &mut *conn).await.unwrap(); + TokenTransfer::create_grant(&basic, &settings, &mut *conn) + .await + .unwrap(); let all = TokenTransfer::find_all_grants(&mut *conn).await.unwrap(); assert_eq!(all.len(), 1); assert_eq!(all[0].settings.volume_limits.len(), 1); - assert_eq!(all[0].settings.volume_limits[0].max_volume, U256::from(9_999u64)); + assert_eq!( + all[0].settings.volume_limits[0].max_volume, + U256::from(9_999u64) + ); } #[tokio::test] @@ -388,9 +449,13 @@ async fn find_all_grants_multiple_grants_batch_loaded() { .await .unwrap(); let b2 = insert_basic(&mut conn, false).await; - TokenTransfer::create_grant(&b2, &make_settings(Some(RECIPIENT), Some(2_000)), &mut *conn) - .await - .unwrap(); + TokenTransfer::create_grant( + &b2, + &make_settings(Some(RECIPIENT), Some(2_000)), + &mut *conn, + ) + .await + .unwrap(); let all = TokenTransfer::find_all_grants(&mut *conn).await.unwrap(); assert_eq!(all.len(), 2); diff --git a/server/crates/arbiter-server/src/evm/safe_signer.rs b/server/crates/arbiter-server/src/evm/safe_signer.rs index 1e10031..fd39189 100644 --- a/server/crates/arbiter-server/src/evm/safe_signer.rs +++ b/server/crates/arbiter-server/src/evm/safe_signer.rs @@ -3,11 +3,11 @@ use std::sync::Mutex; use alloy::{ consensus::SignableTransaction, network::{TxSigner, TxSignerSync}, - primitives::{Address, ChainId, Signature, B256}, + primitives::{Address, B256, ChainId, Signature}, signers::{Error, Result, Signer, SignerSync, utils::secret_key_to_address}, }; use async_trait::async_trait; -use k256::ecdsa::{self, signature::hazmat::PrehashSigner, RecoveryId, SigningKey}; +use k256::ecdsa::{self, RecoveryId, SigningKey, signature::hazmat::PrehashSigner}; use memsafe::MemSafe; /// An Ethereum signer that stores its secp256k1 secret key inside a @@ -90,10 +90,7 @@ impl SafeSigner { Ok(sig.into()) } - fn sign_tx_inner( - &self, - tx: &mut dyn SignableTransaction, - ) -> Result { + fn sign_tx_inner(&self, tx: &mut dyn SignableTransaction) -> Result { if let Some(chain_id) = self.chain_id && !tx.set_chain_id_checked(chain_id) { @@ -102,7 +99,8 @@ impl SafeSigner { tx: tx.chain_id().unwrap(), }); } - self.sign_hash_inner(&tx.signature_hash()).map_err(Error::other) + self.sign_hash_inner(&tx.signature_hash()) + .map_err(Error::other) } } diff --git a/server/crates/arbiter-server/src/grpc/client.rs b/server/crates/arbiter-server/src/grpc/client.rs new file mode 100644 index 0000000..1e9e072 --- /dev/null +++ b/server/crates/arbiter-server/src/grpc/client.rs @@ -0,0 +1,137 @@ +use arbiter_proto::{ + proto::client::{ + AuthChallenge as ProtoAuthChallenge, + AuthChallengeRequest as ProtoAuthChallengeRequest, + AuthChallengeSolution as ProtoAuthChallengeSolution, AuthOk as ProtoAuthOk, + ClientConnectError, ClientRequest, ClientResponse, + client_connect_error::Code as ProtoClientConnectErrorCode, + client_request::Payload as ClientRequestPayload, + client_response::Payload as ClientResponsePayload, + }, + transport::{Bi, Error as TransportError}, +}; +use async_trait::async_trait; +use futures::StreamExt as _; +use tokio::sync::mpsc; +use tonic::{Status, Streaming}; + +use crate::actors::client::{ + self, ClientError, ConnectErrorCode, Request as DomainRequest, Response as DomainResponse, +}; + +pub struct GrpcTransport { + sender: mpsc::Sender>, + receiver: Streaming, +} + +impl GrpcTransport { + pub fn new( + sender: mpsc::Sender>, + receiver: Streaming, + ) -> Self { + Self { sender, receiver } + } + + fn request_to_domain(request: ClientRequest) -> Result { + match request.payload { + Some(ClientRequestPayload::AuthChallengeRequest(ProtoAuthChallengeRequest { + pubkey, + })) => Ok(DomainRequest::AuthChallengeRequest { pubkey }), + Some(ClientRequestPayload::AuthChallengeSolution( + ProtoAuthChallengeSolution { signature }, + )) => Ok(DomainRequest::AuthChallengeSolution { signature }), + None => Err(Status::invalid_argument("Missing client request payload")), + } + } + + fn response_to_proto(response: DomainResponse) -> ClientResponse { + let payload = match response { + DomainResponse::AuthChallenge { pubkey, nonce } => { + ClientResponsePayload::AuthChallenge(ProtoAuthChallenge { pubkey, nonce }) + } + DomainResponse::AuthOk => ClientResponsePayload::AuthOk(ProtoAuthOk {}), + DomainResponse::ClientConnectError { code } => { + ClientResponsePayload::ClientConnectError(ClientConnectError { + code: match code { + ConnectErrorCode::Unknown => ProtoClientConnectErrorCode::Unknown, + ConnectErrorCode::ApprovalDenied => { + ProtoClientConnectErrorCode::ApprovalDenied + } + ConnectErrorCode::NoUserAgentsOnline => { + ProtoClientConnectErrorCode::NoUserAgentsOnline + } + } + .into(), + }) + } + }; + + ClientResponse { + payload: Some(payload), + } + } + + fn error_to_status(value: ClientError) -> Status { + match value { + ClientError::MissingRequestPayload | ClientError::UnexpectedRequestPayload => { + Status::invalid_argument("Expected message with payload") + } + ClientError::StateTransitionFailed => Status::internal("State machine error"), + ClientError::Auth(ref err) => auth_error_status(err), + ClientError::ConnectionRegistrationFailed => { + Status::internal("Connection registration failed") + } + } + } +} + +#[async_trait] +impl Bi> for GrpcTransport { + async fn send(&mut self, item: Result) -> Result<(), TransportError> { + let outbound = match item { + Ok(message) => Ok(Self::response_to_proto(message)), + Err(err) => Err(Self::error_to_status(err)), + }; + + self.sender + .send(outbound) + .await + .map_err(|_| TransportError::ChannelClosed) + } + + async fn recv(&mut self) -> Option { + match self.receiver.next().await { + Some(Ok(item)) => match Self::request_to_domain(item) { + Ok(request) => Some(request), + Err(status) => { + let _ = self.sender.send(Err(status)).await; + None + } + }, + Some(Err(error)) => { + tracing::error!(error = ?error, "grpc client recv failed; closing stream"); + None + } + None => None, + } + } +} + +fn auth_error_status(value: &client::auth::Error) -> Status { + use client::auth::Error; + + match value { + Error::UnexpectedMessagePayload | Error::InvalidClientPubkeyLength => { + Status::invalid_argument(value.to_string()) + } + Error::InvalidAuthPubkeyEncoding => { + Status::invalid_argument("Failed to convert pubkey to VerifyingKey") + } + Error::InvalidChallengeSolution => Status::unauthenticated(value.to_string()), + Error::ApproveError(_) => Status::permission_denied(value.to_string()), + Error::Transport => Status::internal("Transport error"), + Error::DatabasePoolUnavailable => Status::internal("Database pool error"), + Error::DatabaseOperationFailed => Status::internal("Database error"), + Error::InternalError => Status::internal("Internal error"), + } +} diff --git a/server/crates/arbiter-server/src/grpc/mod.rs b/server/crates/arbiter-server/src/grpc/mod.rs new file mode 100644 index 0000000..18b9f70 --- /dev/null +++ b/server/crates/arbiter-server/src/grpc/mod.rs @@ -0,0 +1,65 @@ + +use arbiter_proto::proto::{ + client::{ClientRequest, ClientResponse}, + user_agent::{UserAgentRequest, UserAgentResponse}, +}; +use tokio::sync::mpsc; +use tokio_stream::wrappers::ReceiverStream; +use tonic::{Request, Response, Status, async_trait}; +use tracing::info; + +use crate::{ + DEFAULT_CHANNEL_SIZE, + actors::{client::{ClientConnection, connect_client}, user_agent::{UserAgentConnection, connect_user_agent}}, +}; + +pub mod client; +pub mod user_agent; + +#[async_trait] +impl arbiter_proto::proto::arbiter_service_server::ArbiterService for super::Server { + type UserAgentStream = ReceiverStream>; + type ClientStream = ReceiverStream>; + + #[tracing::instrument(level = "debug", skip(self))] + async fn client( + &self, + request: Request>, + ) -> Result, Status> { + let req_stream = request.into_inner(); + let (tx, rx) = mpsc::channel(DEFAULT_CHANNEL_SIZE); + + let transport = client::GrpcTransport::new(tx, req_stream); + let props = ClientConnection::new( + self.context.db.clone(), + Box::new(transport), + self.context.actors.clone(), + ); + tokio::spawn(connect_client(props)); + + info!(event = "connection established", "grpc.client"); + + Ok(Response::new(ReceiverStream::new(rx))) + } + + #[tracing::instrument(level = "debug", skip(self))] + async fn user_agent( + &self, + request: Request>, + ) -> Result, Status> { + let req_stream = request.into_inner(); + let (tx, rx) = mpsc::channel(DEFAULT_CHANNEL_SIZE); + + let transport = user_agent::GrpcTransport::new(tx, req_stream); + let props = UserAgentConnection::new( + self.context.db.clone(), + self.context.actors.clone(), + Box::new(transport), + ); + tokio::spawn(connect_user_agent(props)); + + info!(event = "connection established", "grpc.user_agent"); + + Ok(Response::new(ReceiverStream::new(rx))) + } +} diff --git a/server/crates/arbiter-server/src/grpc/user_agent.rs b/server/crates/arbiter-server/src/grpc/user_agent.rs new file mode 100644 index 0000000..ade2444 --- /dev/null +++ b/server/crates/arbiter-server/src/grpc/user_agent.rs @@ -0,0 +1,288 @@ +use arbiter_proto::{ + proto::{ + evm::{ + EvmError as ProtoEvmError, WalletCreateResponse, WalletEntry, WalletList, + WalletListResponse, wallet_create_response::Result as WalletCreateResult, + wallet_list_response::Result as WalletListResult, + }, + user_agent::{ + AuthChallenge as ProtoAuthChallenge, + AuthChallengeRequest as ProtoAuthChallengeRequest, + AuthChallengeSolution as ProtoAuthChallengeSolution, AuthOk as ProtoAuthOk, + BootstrapEncryptedKey as ProtoBootstrapEncryptedKey, + BootstrapResult as ProtoBootstrapResult, ClientConnectionCancel, + ClientConnectionRequest, ClientConnectionResponse, KeyType as ProtoKeyType, + UnsealEncryptedKey as ProtoUnsealEncryptedKey, UnsealResult as ProtoUnsealResult, + UnsealStart, UnsealStartResponse, UserAgentRequest, UserAgentResponse, + VaultState as ProtoVaultState, + user_agent_request::Payload as UserAgentRequestPayload, + user_agent_response::Payload as UserAgentResponsePayload, + }, + }, + transport::{Bi, Error as TransportError}, +}; +use async_trait::async_trait; +use futures::StreamExt as _; +use tokio::sync::mpsc; +use tonic::{Status, Streaming}; + +use crate::actors::user_agent::{ + self, AuthPublicKey, BootstrapError, Request as DomainRequest, Response as DomainResponse, + TransportResponseError, UnsealError, VaultState, +}; + +pub struct GrpcTransport { + sender: mpsc::Sender>, + receiver: Streaming, +} + +impl GrpcTransport { + pub fn new( + sender: mpsc::Sender>, + receiver: Streaming, + ) -> Self { + Self { sender, receiver } + } + + fn request_to_domain(request: UserAgentRequest) -> Result { + match request.payload { + Some(UserAgentRequestPayload::AuthChallengeRequest( + ProtoAuthChallengeRequest { + pubkey, + bootstrap_token, + key_type, + }, + )) => Ok(DomainRequest::AuthChallengeRequest { + pubkey: parse_auth_pubkey(key_type, pubkey)?, + bootstrap_token, + }), + Some(UserAgentRequestPayload::AuthChallengeSolution( + ProtoAuthChallengeSolution { signature }, + )) => Ok(DomainRequest::AuthChallengeSolution { signature }), + Some(UserAgentRequestPayload::UnsealStart(UnsealStart { client_pubkey })) => { + let client_pubkey: [u8; 32] = client_pubkey + .as_slice() + .try_into() + .map_err(|_| Status::invalid_argument("client_pubkey must be 32 bytes"))?; + Ok(DomainRequest::UnsealStart { + client_pubkey: x25519_dalek::PublicKey::from(client_pubkey), + }) + } + Some(UserAgentRequestPayload::UnsealEncryptedKey(ProtoUnsealEncryptedKey { + nonce, + ciphertext, + associated_data, + })) => Ok(DomainRequest::UnsealEncryptedKey { + nonce, + ciphertext, + associated_data, + }), + Some(UserAgentRequestPayload::BootstrapEncryptedKey( + ProtoBootstrapEncryptedKey { + nonce, + ciphertext, + associated_data, + }, + )) => Ok(DomainRequest::BootstrapEncryptedKey { + nonce, + ciphertext, + associated_data, + }), + Some(UserAgentRequestPayload::QueryVaultState(_)) => { + Ok(DomainRequest::QueryVaultState) + } + Some(UserAgentRequestPayload::EvmWalletCreate(_)) => Ok(DomainRequest::EvmWalletCreate), + Some(UserAgentRequestPayload::EvmWalletList(_)) => Ok(DomainRequest::EvmWalletList), + Some(UserAgentRequestPayload::ClientConnectionResponse( + ClientConnectionResponse { approved }, + )) => Ok(DomainRequest::ClientConnectionResponse { approved }), + Some(_) => Err(Status::invalid_argument( + "Unexpected user-agent request payload", + )), + None => Err(Status::invalid_argument("Missing user-agent request payload")), + } + } + + fn response_to_proto(response: DomainResponse) -> UserAgentResponse { + let payload = match response { + DomainResponse::AuthChallenge { nonce } => { + UserAgentResponsePayload::AuthChallenge(ProtoAuthChallenge { + pubkey: Vec::new(), + nonce, + }) + } + DomainResponse::AuthOk => UserAgentResponsePayload::AuthOk(ProtoAuthOk {}), + DomainResponse::UnsealStartResponse { server_pubkey } => { + UserAgentResponsePayload::UnsealStartResponse(UnsealStartResponse { + server_pubkey: server_pubkey.as_bytes().to_vec(), + }) + } + DomainResponse::UnsealResult(result) => UserAgentResponsePayload::UnsealResult( + match result { + Ok(()) => ProtoUnsealResult::Success, + Err(UnsealError::InvalidKey) => ProtoUnsealResult::InvalidKey, + Err(UnsealError::Unbootstrapped) => ProtoUnsealResult::Unbootstrapped, + } + .into(), + ), + DomainResponse::BootstrapResult(result) => UserAgentResponsePayload::BootstrapResult( + match result { + Ok(()) => ProtoBootstrapResult::Success, + Err(BootstrapError::AlreadyBootstrapped) => { + ProtoBootstrapResult::AlreadyBootstrapped + } + Err(BootstrapError::InvalidKey) => ProtoBootstrapResult::InvalidKey, + } + .into(), + ), + DomainResponse::VaultState(state) => UserAgentResponsePayload::VaultState( + match state { + VaultState::Unbootstrapped => ProtoVaultState::Unbootstrapped, + VaultState::Sealed => ProtoVaultState::Sealed, + VaultState::Unsealed => ProtoVaultState::Unsealed, + } + .into(), + ), + DomainResponse::ClientConnectionRequest { pubkey } => { + UserAgentResponsePayload::ClientConnectionRequest(ClientConnectionRequest { + pubkey: pubkey.to_bytes().to_vec(), + }) + } + DomainResponse::ClientConnectionCancel => { + UserAgentResponsePayload::ClientConnectionCancel(ClientConnectionCancel {}) + } + DomainResponse::EvmWalletCreate(result) => { + UserAgentResponsePayload::EvmWalletCreate(WalletCreateResponse { + result: Some(match result { + Ok(()) => WalletCreateResult::Wallet(WalletEntry { + address: Vec::new(), + }), + Err(_) => WalletCreateResult::Error(ProtoEvmError::Internal.into()), + }), + }) + } + DomainResponse::EvmWalletList(wallets) => { + UserAgentResponsePayload::EvmWalletList(WalletListResponse { + result: Some(WalletListResult::Wallets(WalletList { + wallets: wallets + .into_iter() + .map(|addr| WalletEntry { + address: addr.as_slice().to_vec(), + }) + .collect(), + })), + }) + } + }; + + UserAgentResponse { + payload: Some(payload), + } + } + + fn error_to_status(value: TransportResponseError) -> Status { + match value { + TransportResponseError::UnexpectedRequestPayload => { + Status::invalid_argument("Expected message with payload") + } + TransportResponseError::InvalidStateForUnsealEncryptedKey => { + Status::failed_precondition("Invalid state for unseal encrypted key") + } + TransportResponseError::InvalidClientPubkeyLength => { + Status::invalid_argument("client_pubkey must be 32 bytes") + } + TransportResponseError::StateTransitionFailed => Status::internal("State machine error"), + TransportResponseError::KeyHolderActorUnreachable => { + Status::internal("Vault is not available") + } + TransportResponseError::Auth(ref err) => auth_error_status(err), + TransportResponseError::ConnectionRegistrationFailed => { + Status::internal("Failed registering connection") + } + } + } +} + +#[async_trait] +impl Bi> for GrpcTransport { + async fn send( + &mut self, + item: Result, + ) -> Result<(), TransportError> { + let outbound = match item { + Ok(message) => Ok(Self::response_to_proto(message)), + Err(err) => Err(Self::error_to_status(err)), + }; + + self.sender + .send(outbound) + .await + .map_err(|_| TransportError::ChannelClosed) + } + + async fn recv(&mut self) -> Option { + match self.receiver.next().await { + Some(Ok(item)) => match Self::request_to_domain(item) { + Ok(request) => Some(request), + Err(status) => { + let _ = self.sender.send(Err(status)).await; + None + } + }, + Some(Err(error)) => { + tracing::error!(error = ?error, "grpc user-agent recv failed; closing stream"); + None + } + None => None, + } + } +} + +fn parse_auth_pubkey(key_type: i32, pubkey: Vec) -> Result { + match ProtoKeyType::try_from(key_type).unwrap_or(ProtoKeyType::Unspecified) { + ProtoKeyType::Unspecified | ProtoKeyType::Ed25519 => { + let bytes: [u8; 32] = pubkey + .as_slice() + .try_into() + .map_err(|_| Status::invalid_argument("invalid Ed25519 public key length"))?; + let key = ed25519_dalek::VerifyingKey::from_bytes(&bytes) + .map_err(|_| Status::invalid_argument("invalid Ed25519 public key encoding"))?; + Ok(AuthPublicKey::Ed25519(key)) + } + ProtoKeyType::EcdsaSecp256k1 => { + let key = k256::ecdsa::VerifyingKey::from_sec1_bytes(&pubkey) + .map_err(|_| Status::invalid_argument("invalid secp256k1 public key encoding"))?; + Ok(AuthPublicKey::EcdsaSecp256k1(key)) + } + ProtoKeyType::Rsa => { + use rsa::pkcs8::DecodePublicKey as _; + + let key = rsa::RsaPublicKey::from_public_key_der(&pubkey) + .map_err(|_| Status::invalid_argument("invalid RSA public key encoding"))?; + Ok(AuthPublicKey::Rsa(key)) + } + } +} + +fn auth_error_status(value: &user_agent::auth::Error) -> Status { + use user_agent::auth::Error; + + match value { + Error::UnexpectedMessagePayload | Error::InvalidClientPubkeyLength => { + Status::invalid_argument(value.to_string()) + } + Error::InvalidAuthPubkeyEncoding => { + Status::invalid_argument("Failed to convert pubkey to VerifyingKey") + } + Error::PublicKeyNotRegistered | Error::InvalidChallengeSolution => { + Status::unauthenticated(value.to_string()) + } + Error::InvalidBootstrapToken => Status::invalid_argument("Invalid bootstrap token"), + Error::Transport => Status::internal("Transport error"), + Error::BootstrapperActorUnreachable => { + Status::internal("Bootstrap token consumption failed") + } + Error::DatabasePoolUnavailable => Status::internal("Database pool error"), + Error::DatabaseOperationFailed => Status::internal("Database error"), + } +} diff --git a/server/crates/arbiter-server/src/lib.rs b/server/crates/arbiter-server/src/lib.rs index d712992..2c06328 100644 --- a/server/crates/arbiter-server/src/lib.rs +++ b/server/crates/arbiter-server/src/lib.rs @@ -1,137 +1,16 @@ #![forbid(unsafe_code)] -use arbiter_proto::{ - proto::{ - client::{ClientRequest, ClientResponse}, - user_agent::{UserAgentRequest, UserAgentResponse}, - }, - transport::{IdentityRecvConverter, SendConverter, grpc}, -}; -use async_trait::async_trait; -use tokio_stream::wrappers::ReceiverStream; -use tokio::sync::mpsc; -use tonic::{Request, Response, Status}; -use tracing::info; -use crate::{ - actors::{ - client::{self, ClientConnection as ClientConnectionProps, ClientError, connect_client}, - user_agent::{self, TransportResponseError, UserAgentConnection, connect_user_agent}, - }, - context::ServerContext, -}; +use crate::context::ServerContext; pub mod actors; pub mod context; pub mod db; pub mod evm; +pub mod grpc; const DEFAULT_CHANNEL_SIZE: usize = 1000; -struct UserAgentGrpcSender; - -impl SendConverter for UserAgentGrpcSender { - type Input = Result; - type Output = Result; - - fn convert(&self, item: Self::Input) -> Self::Output { - match item { - Ok(message) => Ok(message), - Err(err) => Err(user_agent_error_status(err)), - } - } -} - -struct ClientGrpcSender; - -impl SendConverter for ClientGrpcSender { - type Input = Result; - type Output = Result; - - fn convert(&self, item: Self::Input) -> Self::Output { - match item { - Ok(message) => Ok(message), - Err(err) => Err(client_error_status(err)), - } - } -} - -fn client_error_status(value: ClientError) -> Status { - match value { - ClientError::MissingRequestPayload | ClientError::UnexpectedRequestPayload => { - Status::invalid_argument("Expected message with payload") - } - ClientError::StateTransitionFailed => Status::internal("State machine error"), - ClientError::Auth(ref err) => client_auth_error_status(err), - ClientError::ConnectionRegistrationFailed => { - Status::internal("Connection registration failed") - } - } -} - -fn client_auth_error_status(value: &client::auth::Error) -> Status { - use client::auth::Error; - match value { - Error::UnexpectedMessagePayload | Error::InvalidClientPubkeyLength => { - Status::invalid_argument(value.to_string()) - } - Error::InvalidAuthPubkeyEncoding => { - Status::invalid_argument("Failed to convert pubkey to VerifyingKey") - } - Error::InvalidChallengeSolution => Status::unauthenticated(value.to_string()), - Error::ApproveError(_) => Status::permission_denied(value.to_string()), - Error::Transport => Status::internal("Transport error"), - Error::DatabasePoolUnavailable => Status::internal("Database pool error"), - Error::DatabaseOperationFailed => Status::internal("Database error"), - Error::InternalError => Status::internal("Internal error"), - } -} - -fn user_agent_error_status(value: TransportResponseError) -> Status { - match value { - TransportResponseError::MissingRequestPayload - | TransportResponseError::UnexpectedRequestPayload => { - Status::invalid_argument("Expected message with payload") - } - TransportResponseError::InvalidStateForUnsealEncryptedKey => { - Status::failed_precondition("Invalid state for unseal encrypted key") - } - TransportResponseError::InvalidClientPubkeyLength => { - Status::invalid_argument("client_pubkey must be 32 bytes") - } - TransportResponseError::StateTransitionFailed => Status::internal("State machine error"), - TransportResponseError::KeyHolderActorUnreachable => { - Status::internal("Vault is not available") - } - TransportResponseError::Auth(ref err) => auth_error_status(err), - TransportResponseError::ConnectionRegistrationFailed => { - Status::internal("Failed registering connection") - } - } -} - -fn auth_error_status(value: &user_agent::auth::Error) -> Status { - use user_agent::auth::Error; - match value { - Error::UnexpectedMessagePayload | Error::InvalidClientPubkeyLength => { - Status::invalid_argument(value.to_string()) - } - Error::InvalidAuthPubkeyEncoding => { - Status::invalid_argument("Failed to convert pubkey to VerifyingKey") - } - Error::PublicKeyNotRegistered | Error::InvalidChallengeSolution => { - Status::unauthenticated(value.to_string()) - } - Error::InvalidBootstrapToken => Status::invalid_argument("Invalid bootstrap token"), - Error::Transport => Status::internal("Transport error"), - Error::BootstrapperActorUnreachable => { - Status::internal("Bootstrap token consumption failed") - } - Error::DatabasePoolUnavailable => Status::internal("Database pool error"), - Error::DatabaseOperationFailed => Status::internal("Database error"), - } -} - pub struct Server { context: ServerContext, } @@ -142,60 +21,4 @@ impl Server { } } -#[async_trait] -impl arbiter_proto::proto::arbiter_service_server::ArbiterService for Server { - type UserAgentStream = ReceiverStream>; - type ClientStream = ReceiverStream>; - #[tracing::instrument(level = "debug", skip(self))] - async fn client( - &self, - request: Request>, - ) -> Result, Status> { - let req_stream = request.into_inner(); - let (tx, rx) = mpsc::channel(DEFAULT_CHANNEL_SIZE); - - let transport = grpc::GrpcAdapter::new( - tx, - req_stream, - IdentityRecvConverter::::new(), - ClientGrpcSender, - ); - let props = ClientConnectionProps::new( - self.context.db.clone(), - Box::new(transport), - self.context.actors.clone(), - ); - tokio::spawn(connect_client(props)); - - info!(event = "connection established", "grpc.client"); - - Ok(Response::new(ReceiverStream::new(rx))) - } - - #[tracing::instrument(level = "debug", skip(self))] - async fn user_agent( - &self, - request: Request>, - ) -> Result, Status> { - let req_stream = request.into_inner(); - let (tx, rx) = mpsc::channel(DEFAULT_CHANNEL_SIZE); - - let transport = grpc::GrpcAdapter::new( - tx, - req_stream, - IdentityRecvConverter::::new(), - UserAgentGrpcSender, - ); - let props = UserAgentConnection::new( - self.context.db.clone(), - self.context.actors.clone(), - Box::new(transport), - ); - tokio::spawn(connect_user_agent(props)); - - info!(event = "connection established", "grpc.user_agent"); - - Ok(Response::new(ReceiverStream::new(rx))) - } -} diff --git a/server/crates/arbiter-server/tests/client/auth.rs b/server/crates/arbiter-server/tests/client/auth.rs index 6228a58..ca4f38b 100644 --- a/server/crates/arbiter-server/tests/client/auth.rs +++ b/server/crates/arbiter-server/tests/client/auth.rs @@ -1,12 +1,7 @@ -use arbiter_proto::proto::client::{ - AuthChallengeRequest, AuthChallengeSolution, ClientRequest, - client_request::Payload as ClientRequestPayload, - client_response::Payload as ClientResponsePayload, -}; use arbiter_proto::transport::Bi; use arbiter_server::actors::GlobalActors; use arbiter_server::{ - actors::client::{ClientConnection, connect_client}, + actors::client::{ClientConnection, Request, Response, connect_client}, db::{self, schema}, }; use diesel::{ExpressionMethods as _, insert_into}; @@ -29,12 +24,8 @@ pub async fn test_unregistered_pubkey_rejected() { let pubkey_bytes = new_key.verifying_key().to_bytes().to_vec(); test_transport - .send(ClientRequest { - payload: Some(ClientRequestPayload::AuthChallengeRequest( - AuthChallengeRequest { - pubkey: pubkey_bytes, - }, - )), + .send(Request::AuthChallengeRequest { + pubkey: pubkey_bytes, }) .await .unwrap(); @@ -68,12 +59,8 @@ pub async fn test_challenge_auth() { // Send challenge request test_transport - .send(ClientRequest { - payload: Some(ClientRequestPayload::AuthChallengeRequest( - AuthChallengeRequest { - pubkey: pubkey_bytes, - }, - )), + .send(Request::AuthChallengeRequest { + pubkey: pubkey_bytes, }) .await .unwrap(); @@ -84,24 +71,20 @@ pub async fn test_challenge_auth() { .await .expect("should receive challenge"); let challenge = match response { - Ok(resp) => match resp.payload { - Some(ClientResponsePayload::AuthChallenge(c)) => c, + Ok(resp) => match resp { + Response::AuthChallenge { pubkey, nonce } => (pubkey, nonce), other => panic!("Expected AuthChallenge, got {other:?}"), }, Err(err) => panic!("Expected Ok response, got Err({err:?})"), }; // Sign the challenge and send solution - let formatted_challenge = arbiter_proto::format_challenge(challenge.nonce, &challenge.pubkey); + let formatted_challenge = arbiter_proto::format_challenge(challenge.1, &challenge.0); let signature = new_key.sign(&formatted_challenge); test_transport - .send(ClientRequest { - payload: Some(ClientRequestPayload::AuthChallengeSolution( - AuthChallengeSolution { - signature: signature.to_bytes().to_vec(), - }, - )), + .send(Request::AuthChallengeSolution { + signature: signature.to_bytes().to_vec(), }) .await .unwrap(); diff --git a/server/crates/arbiter-server/tests/user_agent/auth.rs b/server/crates/arbiter-server/tests/user_agent/auth.rs index edcc1d2..4f23e9d 100644 --- a/server/crates/arbiter-server/tests/user_agent/auth.rs +++ b/server/crates/arbiter-server/tests/user_agent/auth.rs @@ -1,14 +1,9 @@ -use arbiter_proto::proto::user_agent::{ - AuthChallengeRequest, AuthChallengeSolution, KeyType as ProtoKeyType, UserAgentRequest, - user_agent_request::Payload as UserAgentRequestPayload, - user_agent_response::Payload as UserAgentResponsePayload, -}; use arbiter_proto::transport::Bi; use arbiter_server::{ actors::{ GlobalActors, bootstrap::GetToken, - user_agent::{UserAgentConnection, connect_user_agent}, + user_agent::{AuthPublicKey, Request, Response, UserAgentConnection, connect_user_agent}, }, db::{self, schema}, }; @@ -30,17 +25,10 @@ pub async fn test_bootstrap_token_auth() { let task = tokio::spawn(connect_user_agent(props)); let new_key = ed25519_dalek::SigningKey::generate(&mut rand::rng()); - let pubkey_bytes = new_key.verifying_key().to_bytes().to_vec(); - test_transport - .send(UserAgentRequest { - payload: Some(UserAgentRequestPayload::AuthChallengeRequest( - AuthChallengeRequest { - pubkey: pubkey_bytes, - bootstrap_token: Some(token), - key_type: ProtoKeyType::Ed25519.into(), - }, - )), + .send(Request::AuthChallengeRequest { + pubkey: AuthPublicKey::Ed25519(new_key.verifying_key()), + bootstrap_token: Some(token), }) .await .unwrap(); @@ -67,17 +55,10 @@ pub async fn test_bootstrap_invalid_token_auth() { let task = tokio::spawn(connect_user_agent(props)); let new_key = ed25519_dalek::SigningKey::generate(&mut rand::rng()); - let pubkey_bytes = new_key.verifying_key().to_bytes().to_vec(); - test_transport - .send(UserAgentRequest { - payload: Some(UserAgentRequestPayload::AuthChallengeRequest( - AuthChallengeRequest { - pubkey: pubkey_bytes, - bootstrap_token: Some("invalid_token".to_string()), - key_type: ProtoKeyType::Ed25519.into(), - }, - )), + .send(Request::AuthChallengeRequest { + pubkey: AuthPublicKey::Ed25519(new_key.verifying_key()), + bootstrap_token: Some("invalid_token".to_string()), }) .await .unwrap(); @@ -123,14 +104,9 @@ pub async fn test_challenge_auth() { // Send challenge request test_transport - .send(UserAgentRequest { - payload: Some(UserAgentRequestPayload::AuthChallengeRequest( - AuthChallengeRequest { - pubkey: pubkey_bytes, - bootstrap_token: None, - key_type: ProtoKeyType::Ed25519.into(), - }, - )), + .send(Request::AuthChallengeRequest { + pubkey: AuthPublicKey::Ed25519(new_key.verifying_key()), + bootstrap_token: None, }) .await .unwrap(); @@ -141,24 +117,19 @@ pub async fn test_challenge_auth() { .await .expect("should receive challenge"); let challenge = match response { - Ok(resp) => match resp.payload { - Some(UserAgentResponsePayload::AuthChallenge(c)) => c, + Ok(resp) => match resp { + Response::AuthChallenge { nonce } => nonce, other => panic!("Expected AuthChallenge, got {other:?}"), }, Err(err) => panic!("Expected Ok response, got Err({err:?})"), }; - // Sign the challenge and send solution - let formatted_challenge = arbiter_proto::format_challenge(challenge.nonce, &challenge.pubkey); + let formatted_challenge = arbiter_proto::format_challenge(challenge, &pubkey_bytes); let signature = new_key.sign(&formatted_challenge); test_transport - .send(UserAgentRequest { - payload: Some(UserAgentRequestPayload::AuthChallengeSolution( - AuthChallengeSolution { - signature: signature.to_bytes().to_vec(), - }, - )), + .send(Request::AuthChallengeSolution { + signature: signature.to_bytes().to_vec(), }) .await .unwrap(); diff --git a/server/crates/arbiter-server/tests/user_agent/unseal.rs b/server/crates/arbiter-server/tests/user_agent/unseal.rs index 4e30ff4..486cb5d 100644 --- a/server/crates/arbiter-server/tests/user_agent/unseal.rs +++ b/server/crates/arbiter-server/tests/user_agent/unseal.rs @@ -1,13 +1,8 @@ -use arbiter_proto::proto::user_agent::{ - UnsealEncryptedKey, UnsealResult, UnsealStart, UserAgentRequest, - user_agent_request::Payload as UserAgentRequestPayload, - user_agent_response::Payload as UserAgentResponsePayload, -}; use arbiter_server::{ actors::{ GlobalActors, keyholder::{Bootstrap, Seal}, - user_agent::session::UserAgentSession, + user_agent::{Request, Response, UnsealError, session::UserAgentSession}, }, db, }; @@ -15,9 +10,7 @@ use chacha20poly1305::{AeadInPlace, XChaCha20Poly1305, XNonce, aead::KeyInit}; use memsafe::MemSafe; use x25519_dalek::{EphemeralSecret, PublicKey}; -async fn setup_sealed_user_agent( - seal_key: &[u8], -) -> (db::DatabasePool, UserAgentSession) { +async fn setup_sealed_user_agent(seal_key: &[u8]) -> (db::DatabasePool, UserAgentSession) { let db = db::create_test_pool().await; let actors = GlobalActors::spawn(db.clone()).await.unwrap(); @@ -35,29 +28,23 @@ async fn setup_sealed_user_agent( (db, session) } -async fn client_dh_encrypt( - user_agent: &mut UserAgentSession, - key_to_send: &[u8], -) -> UnsealEncryptedKey { +async fn client_dh_encrypt(user_agent: &mut UserAgentSession, key_to_send: &[u8]) -> Request { let client_secret = EphemeralSecret::random(); let client_public = PublicKey::from(&client_secret); let response = user_agent - .process_transport_inbound(UserAgentRequest { - payload: Some(UserAgentRequestPayload::UnsealStart(UnsealStart { - client_pubkey: client_public.as_bytes().to_vec(), - })), + .process_transport_inbound(Request::UnsealStart { + client_pubkey: client_public, }) .await .unwrap(); - let server_pubkey = match response.payload.unwrap() { - UserAgentResponsePayload::UnsealStartResponse(resp) => resp.server_pubkey, + let server_pubkey = match response { + Response::UnsealStartResponse { server_pubkey } => server_pubkey, other => panic!("Expected UnsealStartResponse, got {other:?}"), }; - let server_public = PublicKey::from(<[u8; 32]>::try_from(server_pubkey.as_slice()).unwrap()); - let shared_secret = client_secret.diffie_hellman(&server_public); + let shared_secret = client_secret.diffie_hellman(&server_pubkey); let cipher = XChaCha20Poly1305::new(shared_secret.as_bytes().into()); let nonce = XNonce::from([0u8; 24]); let associated_data = b"unseal"; @@ -66,19 +53,13 @@ async fn client_dh_encrypt( .encrypt_in_place(&nonce, associated_data, &mut ciphertext) .unwrap(); - UnsealEncryptedKey { + Request::UnsealEncryptedKey { nonce: nonce.to_vec(), ciphertext, associated_data: associated_data.to_vec(), } } -fn unseal_key_request(req: UnsealEncryptedKey) -> UserAgentRequest { - UserAgentRequest { - payload: Some(UserAgentRequestPayload::UnsealEncryptedKey(req)), - } -} - #[tokio::test] #[test_log::test] pub async fn test_unseal_success() { @@ -88,14 +69,11 @@ pub async fn test_unseal_success() { let encrypted_key = client_dh_encrypt(&mut user_agent, seal_key).await; let response = user_agent - .process_transport_inbound(unseal_key_request(encrypted_key)) + .process_transport_inbound(encrypted_key) .await .unwrap(); - assert_eq!( - response.payload.unwrap(), - UserAgentResponsePayload::UnsealResult(UnsealResult::Success.into()), - ); + assert!(matches!(response, Response::UnsealResult(Ok(())))); } #[tokio::test] @@ -106,14 +84,14 @@ pub async fn test_unseal_wrong_seal_key() { let encrypted_key = client_dh_encrypt(&mut user_agent, b"wrong-key").await; let response = user_agent - .process_transport_inbound(unseal_key_request(encrypted_key)) + .process_transport_inbound(encrypted_key) .await .unwrap(); - assert_eq!( - response.payload.unwrap(), - UserAgentResponsePayload::UnsealResult(UnsealResult::InvalidKey.into()), - ); + assert!(matches!( + response, + Response::UnsealResult(Err(UnsealError::InvalidKey)) + )); } #[tokio::test] @@ -125,27 +103,25 @@ pub async fn test_unseal_corrupted_ciphertext() { let client_public = PublicKey::from(&client_secret); user_agent - .process_transport_inbound(UserAgentRequest { - payload: Some(UserAgentRequestPayload::UnsealStart(UnsealStart { - client_pubkey: client_public.as_bytes().to_vec(), - })), + .process_transport_inbound(Request::UnsealStart { + client_pubkey: client_public, }) .await .unwrap(); let response = user_agent - .process_transport_inbound(unseal_key_request(UnsealEncryptedKey { + .process_transport_inbound(Request::UnsealEncryptedKey { nonce: vec![0u8; 24], ciphertext: vec![0u8; 32], associated_data: vec![], - })) + }) .await .unwrap(); - assert_eq!( - response.payload.unwrap(), - UserAgentResponsePayload::UnsealResult(UnsealResult::InvalidKey.into()), - ); + assert!(matches!( + response, + Response::UnsealResult(Err(UnsealError::InvalidKey)) + )); } #[tokio::test] @@ -158,27 +134,24 @@ pub async fn test_unseal_retry_after_invalid_key() { let encrypted_key = client_dh_encrypt(&mut user_agent, b"wrong-key").await; let response = user_agent - .process_transport_inbound(unseal_key_request(encrypted_key)) + .process_transport_inbound(encrypted_key) .await .unwrap(); - assert_eq!( - response.payload.unwrap(), - UserAgentResponsePayload::UnsealResult(UnsealResult::InvalidKey.into()), - ); + assert!(matches!( + response, + Response::UnsealResult(Err(UnsealError::InvalidKey)) + )); } { let encrypted_key = client_dh_encrypt(&mut user_agent, seal_key).await; let response = user_agent - .process_transport_inbound(unseal_key_request(encrypted_key)) + .process_transport_inbound(encrypted_key) .await .unwrap(); - assert_eq!( - response.payload.unwrap(), - UserAgentResponsePayload::UnsealResult(UnsealResult::Success.into()), - ); + assert!(matches!(response, Response::UnsealResult(Ok(())))); } } diff --git a/server/crates/arbiter-useragent/Cargo.toml b/server/crates/arbiter-useragent/Cargo.toml deleted file mode 100644 index 4b7337a..0000000 --- a/server/crates/arbiter-useragent/Cargo.toml +++ /dev/null @@ -1,29 +0,0 @@ -[package] -name = "arbiter-useragent" -version = "0.1.0" -edition = "2024" -license = "Apache-2.0" - -[lints] -workspace = true - -[dependencies] -arbiter-proto.path = "../arbiter-proto" -kameo.workspace = true -tokio = {workspace = true, features = ["net"]} -tonic.workspace = true -tonic.features = ["tls-aws-lc"] -tracing.workspace = true -ed25519-dalek.workspace = true -smlang.workspace = true -x25519-dalek.workspace = true -k256.workspace = true -rsa.workspace = true -sha2.workspace = true -spki.workspace = true -rand.workspace = true -thiserror.workspace = true -tokio-stream.workspace = true -http = "1.4.0" -rustls-webpki = { version = "0.103.9", features = ["aws-lc-rs"] } -async-trait.workspace = true diff --git a/server/crates/arbiter-useragent/src/grpc.rs b/server/crates/arbiter-useragent/src/grpc.rs deleted file mode 100644 index 1c15995..0000000 --- a/server/crates/arbiter-useragent/src/grpc.rs +++ /dev/null @@ -1,70 +0,0 @@ -use arbiter_proto::{ - proto::{ - arbiter_service_client::ArbiterServiceClient, - user_agent::{UserAgentRequest, UserAgentResponse}, - }, - transport::{IdentityRecvConverter, IdentitySendConverter, grpc}, - url::ArbiterUrl, -}; -use kameo::actor::{ActorRef, Spawn}; - -use tokio::sync::mpsc; -use tokio_stream::wrappers::ReceiverStream; - -use tonic::transport::ClientTlsConfig; - -use super::{SigningKeyEnum, UserAgentActor}; - -#[derive(Debug, thiserror::Error)] -pub enum ConnectError { - #[error("Could establish connection")] - Connection(#[from] tonic::transport::Error), - - #[error("Invalid server URI")] - InvalidUri(#[from] http::uri::InvalidUri), - - #[error("Invalid CA certificate")] - InvalidCaCert(#[from] webpki::Error), - - #[error("gRPC error")] - Grpc(#[from] tonic::Status), -} - -pub type UserAgentGrpc = ActorRef< - UserAgentActor< - grpc::GrpcAdapter< - IdentityRecvConverter, - IdentitySendConverter, - >, - >, ->; -pub async fn connect_grpc( - url: ArbiterUrl, - key: SigningKeyEnum, -) -> Result { - let bootstrap_token = url.bootstrap_token.clone(); - let anchor = webpki::anchor_from_trusted_cert(&url.ca_cert)?.to_owned(); - let tls = ClientTlsConfig::new().trust_anchor(anchor); - - // TODO: if `host` is localhost, we need to verify server's process authenticity - let channel = tonic::transport::Channel::from_shared(format!("{}:{}", url.host, url.port))? - .tls_config(tls)? - .connect() - .await?; - - let mut client = ArbiterServiceClient::new(channel); - let (tx, rx) = mpsc::channel(16); - let bistream = client.user_agent(ReceiverStream::new(rx)).await?; - let bistream = bistream.into_inner(); - - let adapter = grpc::GrpcAdapter::new( - tx, - bistream, - IdentityRecvConverter::new(), - IdentitySendConverter::new(), - ); - - let actor = UserAgentActor::spawn(UserAgentActor::new(key, bootstrap_token, adapter)); - - Ok(actor) -} diff --git a/server/crates/arbiter-useragent/src/lib.rs b/server/crates/arbiter-useragent/src/lib.rs deleted file mode 100644 index a9e86bb..0000000 --- a/server/crates/arbiter-useragent/src/lib.rs +++ /dev/null @@ -1,419 +0,0 @@ -use arbiter_proto::{ - format_challenge, - proto::user_agent::{ - AuthChallengeRequest, AuthChallengeSolution, AuthOk, KeyType as ProtoKeyType, - UserAgentRequest, UserAgentResponse, - user_agent_request::Payload as UserAgentRequestPayload, - user_agent_response::Payload as UserAgentResponsePayload, - }, - transport::Bi, -}; -use kameo::{Actor, actor::ActorRef}; -use smlang::statemachine; -use tokio::select; -use tracing::{error, info}; - -/// Signing key variants supported by the user-agent auth protocol. -pub enum SigningKeyEnum { - Ed25519(ed25519_dalek::SigningKey), - /// secp256k1 ECDSA; public key is sent as SEC1 compressed 33 bytes; signature is raw 64-byte (r||s). - EcdsaSecp256k1(k256::ecdsa::SigningKey), - /// RSA for Windows Hello (KeyCredentialManager); public key is DER SPKI; signature is PSS+SHA-256. - Rsa(rsa::RsaPrivateKey), -} - -impl SigningKeyEnum { - /// Returns the canonical public key bytes to include in `AuthChallengeRequest.pubkey`. - pub fn pubkey_bytes(&self) -> Vec { - match self { - SigningKeyEnum::Ed25519(k) => k.verifying_key().to_bytes().to_vec(), - // 33-byte SEC1 compressed point — compact and natively supported by secp256k1 tooling - SigningKeyEnum::EcdsaSecp256k1(k) => { - k.verifying_key().to_encoded_point(true).as_bytes().to_vec() - } - SigningKeyEnum::Rsa(k) => { - use rsa::pkcs8::EncodePublicKey as _; - k.to_public_key() - .to_public_key_der() - .expect("rsa SPKI encoding is infallible") - .to_vec() - } - } - } - - /// Returns the proto `KeyType` discriminant to send in `AuthChallengeRequest.key_type`. - pub fn proto_key_type(&self) -> ProtoKeyType { - match self { - SigningKeyEnum::Ed25519(_) => ProtoKeyType::Ed25519, - SigningKeyEnum::EcdsaSecp256k1(_) => ProtoKeyType::EcdsaSecp256k1, - SigningKeyEnum::Rsa(_) => ProtoKeyType::Rsa, - } - } - - /// Signs `msg` and returns raw signature bytes matching the server-side verification. - pub fn sign(&self, msg: &[u8]) -> Vec { - match self { - SigningKeyEnum::Ed25519(k) => { - use ed25519_dalek::Signer as _; - k.sign(msg).to_bytes().to_vec() - } - SigningKeyEnum::EcdsaSecp256k1(k) => { - use k256::ecdsa::signature::Signer as _; - let sig: k256::ecdsa::Signature = k.sign(msg); - sig.to_bytes().to_vec() - } - SigningKeyEnum::Rsa(k) => { - use rsa::signature::RandomizedSigner as _; - let signing_key = rsa::pss::BlindedSigningKey::::new(k.clone()); - // Use rand_core OsRng from the rsa crate's re-exported rand_core (0.6.x), - // which is the version rsa's signature API expects. - let sig = signing_key.sign_with_rng(&mut rsa::rand_core::OsRng, msg); - use rsa::signature::SignatureEncoding as _; - sig.to_vec() - } - } - } -} - -statemachine! { - name: UserAgent, - custom_error: false, - transitions: { - *Init + SentAuthChallengeRequest = WaitingForServerAuth, - WaitingForServerAuth + ReceivedAuthChallenge = WaitingForAuthOk, - WaitingForServerAuth + ReceivedAuthOk = Authenticated, - WaitingForAuthOk + ReceivedAuthOk = Authenticated, - } -} - -pub struct DummyContext; -impl UserAgentStateMachineContext for DummyContext {} - -#[derive(Debug, thiserror::Error)] -pub enum InboundError { - #[error("Invalid user agent response")] - InvalidResponse, - #[error("Expected response payload")] - MissingResponsePayload, - #[error("Unexpected response payload")] - UnexpectedResponsePayload, - #[error("Invalid state for auth challenge")] - InvalidStateForAuthChallenge, - #[error("Invalid state for auth ok")] - InvalidStateForAuthOk, - #[error("State machine error")] - StateTransitionFailed, - #[error("Transport send failed")] - TransportSendFailed, -} - -pub struct UserAgentActor -where - Transport: Bi, -{ - key: SigningKeyEnum, - bootstrap_token: Option, - state: UserAgentStateMachine, - transport: Transport, -} - -impl UserAgentActor -where - Transport: Bi, -{ - pub fn new(key: SigningKeyEnum, bootstrap_token: Option, transport: Transport) -> Self { - Self { - key, - bootstrap_token, - state: UserAgentStateMachine::new(DummyContext), - transport, - } - } - - fn transition(&mut self, event: UserAgentEvents) -> Result<(), InboundError> { - self.state.process_event(event).map_err(|e| { - error!(?e, "useragent state transition failed"); - InboundError::StateTransitionFailed - })?; - Ok(()) - } - - async fn send_auth_challenge_request(&mut self) -> Result<(), InboundError> { - let req = AuthChallengeRequest { - pubkey: self.key.pubkey_bytes(), - bootstrap_token: self.bootstrap_token.take(), - key_type: self.key.proto_key_type().into(), - }; - - self.transition(UserAgentEvents::SentAuthChallengeRequest)?; - - self.transport - .send(UserAgentRequest { - payload: Some(UserAgentRequestPayload::AuthChallengeRequest(req)), - }) - .await - .map_err(|_| InboundError::TransportSendFailed)?; - - info!(actor = "useragent", "auth.request.sent"); - Ok(()) - } - - async fn handle_auth_challenge( - &mut self, - challenge: arbiter_proto::proto::user_agent::AuthChallenge, - ) -> Result<(), InboundError> { - self.transition(UserAgentEvents::ReceivedAuthChallenge)?; - - let formatted = format_challenge(challenge.nonce, &challenge.pubkey); - let signature_bytes = self.key.sign(&formatted); - let solution = AuthChallengeSolution { - signature: signature_bytes, - }; - - self.transport - .send(UserAgentRequest { - payload: Some(UserAgentRequestPayload::AuthChallengeSolution(solution)), - }) - .await - .map_err(|_| InboundError::TransportSendFailed)?; - - info!(actor = "useragent", "auth.solution.sent"); - Ok(()) - } - - fn handle_auth_ok(&mut self, _ok: AuthOk) -> Result<(), InboundError> { - self.transition(UserAgentEvents::ReceivedAuthOk)?; - info!(actor = "useragent", "auth.ok"); - Ok(()) - } - - pub async fn process_inbound_transport( - &mut self, - inbound: UserAgentResponse, - ) -> Result<(), InboundError> { - let payload = inbound - .payload - .ok_or(InboundError::MissingResponsePayload)?; - - match payload { - UserAgentResponsePayload::AuthChallenge(challenge) => { - self.handle_auth_challenge(challenge).await - } - UserAgentResponsePayload::AuthOk(ok) => self.handle_auth_ok(ok), - _ => Err(InboundError::UnexpectedResponsePayload), - } - } -} - -impl Actor for UserAgentActor -where - Transport: Bi, -{ - type Args = Self; - - type Error = (); - - async fn on_start( - mut args: Self::Args, - _actor_ref: ActorRef, - ) -> Result { - if let Err(err) = args.send_auth_challenge_request().await { - error!(?err, actor = "useragent", "auth.start.failed"); - return Err(()); - } - Ok(args) - } - - async fn next( - &mut self, - _actor_ref: kameo::prelude::WeakActorRef, - mailbox_rx: &mut kameo::prelude::MailboxReceiver, - ) -> Option> { - loop { - select! { - signal = mailbox_rx.recv() => { - return signal; - } - inbound = self.transport.recv() => { - match inbound { - Some(inbound) => { - if let Err(err) = self.process_inbound_transport(inbound).await { - error!(?err, actor = "useragent", "transport.inbound.failed"); - return Some(kameo::mailbox::Signal::Stop); - } - } - None => { - info!(actor = "useragent", "transport.closed"); - return Some(kameo::mailbox::Signal::Stop); - } - } - } - } - } - } -} - -mod grpc; -pub use grpc::{ConnectError, UserAgentGrpc, connect_grpc}; - -use arbiter_proto::proto::user_agent::{ - BootstrapEncryptedKey, UnsealEncryptedKey, UnsealStart, - user_agent_request::Payload as RequestPayload, user_agent_response::Payload as ResponsePayload, -}; - -/// Send an `UnsealStart` request and await the server's `UnsealStartResponse`. -pub struct SendUnsealStart { - pub client_pubkey: Vec, -} - -/// Send an `UnsealEncryptedKey` request and await the server's `UnsealResult`. -pub struct SendUnsealEncryptedKey { - pub nonce: Vec, - pub ciphertext: Vec, - pub associated_data: Vec, -} - -/// Send a `BootstrapEncryptedKey` request and await the server's `BootstrapResult`. -pub struct SendBootstrapEncryptedKey { - pub nonce: Vec, - pub ciphertext: Vec, - pub associated_data: Vec, -} - -/// Query the server for the current `VaultState`. -pub struct QueryVaultState; - -/// Errors that can occur during post-authentication session operations. -#[derive(Debug, thiserror::Error)] -pub enum SessionError { - #[error("Transport send failed")] - TransportSendFailed, - #[error("Transport closed unexpectedly")] - TransportClosed, - #[error("Server sent an unexpected response payload")] - UnexpectedResponse, -} - -impl kameo::message::Message for UserAgentActor -where - Transport: Bi, -{ - type Reply = Result; - - async fn handle( - &mut self, - msg: SendUnsealStart, - _ctx: &mut kameo::message::Context, - ) -> Self::Reply { - self.transport - .send(UserAgentRequest { - payload: Some(RequestPayload::UnsealStart(UnsealStart { - client_pubkey: msg.client_pubkey, - })), - }) - .await - .map_err(|_| SessionError::TransportSendFailed)?; - - match self.transport.recv().await { - Some(resp) => match resp.payload { - Some(ResponsePayload::UnsealStartResponse(r)) => Ok(r), - _ => Err(SessionError::UnexpectedResponse), - }, - None => Err(SessionError::TransportClosed), - } - } -} - -impl kameo::message::Message for UserAgentActor -where - Transport: Bi, -{ - type Reply = Result; - - async fn handle( - &mut self, - msg: SendUnsealEncryptedKey, - _ctx: &mut kameo::message::Context, - ) -> Self::Reply { - self.transport - .send(UserAgentRequest { - payload: Some(RequestPayload::UnsealEncryptedKey(UnsealEncryptedKey { - nonce: msg.nonce, - ciphertext: msg.ciphertext, - associated_data: msg.associated_data, - })), - }) - .await - .map_err(|_| SessionError::TransportSendFailed)?; - - match self.transport.recv().await { - Some(resp) => match resp.payload { - Some(ResponsePayload::UnsealResult(r)) => Ok(r), - _ => Err(SessionError::UnexpectedResponse), - }, - None => Err(SessionError::TransportClosed), - } - } -} - -impl kameo::message::Message for UserAgentActor -where - Transport: Bi, -{ - type Reply = Result; - - async fn handle( - &mut self, - msg: SendBootstrapEncryptedKey, - _ctx: &mut kameo::message::Context, - ) -> Self::Reply { - self.transport - .send(UserAgentRequest { - payload: Some(RequestPayload::BootstrapEncryptedKey( - BootstrapEncryptedKey { - nonce: msg.nonce, - ciphertext: msg.ciphertext, - associated_data: msg.associated_data, - }, - )), - }) - .await - .map_err(|_| SessionError::TransportSendFailed)?; - - match self.transport.recv().await { - Some(resp) => match resp.payload { - Some(ResponsePayload::BootstrapResult(r)) => Ok(r), - _ => Err(SessionError::UnexpectedResponse), - }, - None => Err(SessionError::TransportClosed), - } - } -} - -impl kameo::message::Message for UserAgentActor -where - Transport: Bi, -{ - type Reply = Result; - - async fn handle( - &mut self, - _msg: QueryVaultState, - _ctx: &mut kameo::message::Context, - ) -> Self::Reply { - self.transport - .send(UserAgentRequest { - payload: Some(RequestPayload::QueryVaultState(())), - }) - .await - .map_err(|_| SessionError::TransportSendFailed)?; - - match self.transport.recv().await { - Some(resp) => match resp.payload { - Some(ResponsePayload::VaultState(v)) => Ok(v), - _ => Err(SessionError::UnexpectedResponse), - }, - None => Err(SessionError::TransportClosed), - } - } -} diff --git a/server/crates/arbiter-useragent/tests/auth.rs b/server/crates/arbiter-useragent/tests/auth.rs deleted file mode 100644 index 3b6b35a..0000000 --- a/server/crates/arbiter-useragent/tests/auth.rs +++ /dev/null @@ -1,146 +0,0 @@ -use arbiter_proto::{ - format_challenge, - proto::user_agent::{ - AuthChallenge, AuthOk, UserAgentRequest, UserAgentResponse, - user_agent_request::Payload as UserAgentRequestPayload, - user_agent_response::Payload as UserAgentResponsePayload, - }, - transport::Bi, -}; -use arbiter_useragent::{SigningKeyEnum, UserAgentActor}; -use async_trait::async_trait; -use ed25519_dalek::SigningKey; -use kameo::actor::Spawn; -use tokio::sync::mpsc; -use tokio::time::{Duration, timeout}; - -struct TestTransport { - inbound_rx: mpsc::Receiver, - outbound_tx: mpsc::Sender, -} - -#[async_trait] -impl Bi for TestTransport { - async fn send( - &mut self, - item: UserAgentRequest, - ) -> Result<(), arbiter_proto::transport::Error> { - self.outbound_tx - .send(item) - .await - .map_err(|_| arbiter_proto::transport::Error::ChannelClosed) - } - - async fn recv(&mut self) -> Option { - self.inbound_rx.recv().await - } -} - -fn make_transport() -> ( - TestTransport, - mpsc::Sender, - mpsc::Receiver, -) { - let (inbound_tx, inbound_rx) = mpsc::channel(8); - let (outbound_tx, outbound_rx) = mpsc::channel(8); - ( - TestTransport { - inbound_rx, - outbound_tx, - }, - inbound_tx, - outbound_rx, - ) -} - -fn test_key() -> SigningKeyEnum { - SigningKeyEnum::Ed25519(SigningKey::from_bytes(&[7u8; 32])) -} - -#[tokio::test] -async fn sends_auth_request_on_start_with_bootstrap_token() { - let key = test_key(); - let pubkey = key.pubkey_bytes(); - let bootstrap_token = Some("bootstrap-123".to_string()); - let (transport, inbound_tx, mut outbound_rx) = make_transport(); - - let actor = UserAgentActor::spawn(UserAgentActor::new(key, bootstrap_token.clone(), transport)); - - let outbound = timeout(Duration::from_secs(1), outbound_rx.recv()) - .await - .expect("timed out waiting for auth request") - .expect("channel closed before auth request"); - - let UserAgentRequest { - payload: Some(UserAgentRequestPayload::AuthChallengeRequest(req)), - } = outbound - else { - panic!("expected auth challenge request"); - }; - - assert_eq!(req.pubkey, pubkey); - assert_eq!(req.bootstrap_token, bootstrap_token); - - drop(inbound_tx); - drop(actor); -} - -#[tokio::test] -async fn challenge_flow_sends_solution_from_transport_inbound() { - let key = test_key(); - let pubkey_bytes = key.pubkey_bytes(); - let (transport, inbound_tx, mut outbound_rx) = make_transport(); - - let actor = UserAgentActor::spawn(UserAgentActor::new(key, None, transport)); - - let _initial_auth_request = timeout(Duration::from_secs(1), outbound_rx.recv()) - .await - .expect("timed out waiting for initial auth request") - .expect("missing initial auth request"); - - let challenge = AuthChallenge { - pubkey: pubkey_bytes.clone(), - nonce: 42, - }; - inbound_tx - .send(UserAgentResponse { - payload: Some(UserAgentResponsePayload::AuthChallenge(challenge.clone())), - }) - .await - .unwrap(); - - let outbound = timeout(Duration::from_secs(1), outbound_rx.recv()) - .await - .expect("timed out waiting for challenge solution") - .expect("missing challenge solution"); - - let UserAgentRequest { - payload: Some(UserAgentRequestPayload::AuthChallengeSolution(solution)), - } = outbound - else { - panic!("expected auth challenge solution"); - }; - - // Verify the signature using the Ed25519 verifying key - let formatted = format_challenge(challenge.nonce, &challenge.pubkey); - let raw_key = SigningKey::from_bytes(&[7u8; 32]); - let sig: ed25519_dalek::Signature = solution - .signature - .as_slice() - .try_into() - .expect("signature bytes length"); - raw_key - .verifying_key() - .verify_strict(&formatted, &sig) - .expect("solution signature should verify"); - - inbound_tx - .send(UserAgentResponse { - payload: Some(UserAgentResponsePayload::AuthOk(AuthOk {})), - }) - .await - .unwrap(); - - drop(inbound_tx); - drop(actor); -}