refactor(server): removed grpc adapter and replaced with concrete implementations

This commit is contained in:
hdbg
2026-03-15 23:11:07 +01:00
parent 4db102b3d1
commit 549a0f5f52
29 changed files with 1002 additions and 1620 deletions

24
server/Cargo.lock generated
View File

@@ -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"

View File

@@ -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<Inbound, Outbound>`:
//! - `recv() -> Option<Inbound>`
//! - `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<RecvTransport>
//! -> 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<SendTransport>
//! -> 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<Inbound, Outbound>: Send + Sync + 'static {
async fn recv(&mut self) -> Option<Inbound>;
}
/// 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<T> {
_marker: PhantomData<T>,
}
impl<T> IdentityRecvConverter<T> {
pub fn new() -> Self {
Self {
_marker: PhantomData,
}
}
}
impl<T> Default for IdentityRecvConverter<T> {
fn default() -> Self {
Self::new()
}
}
impl<T> RecvConverter for IdentityRecvConverter<T>
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<T> {
_marker: PhantomData<T>,
}
impl<T> IdentitySendConverter<T> {
pub fn new() -> Self {
Self {
_marker: PhantomData,
}
}
}
impl<T> Default for IdentitySendConverter<T> {
fn default() -> Self {
Self::new()
}
}
impl<T> SendConverter for IdentitySendConverter<T>
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<InboundConverter, OutboundConverter>
where
InboundConverter: RecvConverter,
OutboundConverter: SendConverter,
{
sender: mpsc::Sender<OutboundConverter::Output>,
receiver: Streaming<InboundConverter::Input>,
inbound_converter: InboundConverter,
outbound_converter: OutboundConverter,
}
impl<InboundTransport, Inbound, InboundConverter, OutboundConverter>
GrpcAdapter<InboundConverter, OutboundConverter>
where
InboundConverter: RecvConverter<Input = InboundTransport, Output = Inbound>,
OutboundConverter: SendConverter,
{
pub fn new(
sender: mpsc::Sender<OutboundConverter::Output>,
receiver: Streaming<InboundTransport>,
inbound_converter: InboundConverter,
outbound_converter: OutboundConverter,
) -> Self {
Self {
sender,
receiver,
inbound_converter,
outbound_converter,
}
}
}
#[async_trait]
impl<InboundConverter, OutboundConverter> Bi<InboundConverter::Output, OutboundConverter::Input>
for GrpcAdapter<InboundConverter, OutboundConverter>
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<InboundConverter::Output> {
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

View File

@@ -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)

View File

@@ -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<VerifyingKey, Error> {
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<Clie
let code = connect_error_code(&err);
let _ = props
.transport
.send(Ok(ClientResponse {
payload: Some(ClientResponsePayload::ClientConnectError(
ClientConnectError { code: code.into() },
)),
}))
.send(Ok(Response::ClientConnectError { code }))
.await;
Err(err)
}

View File

@@ -1,7 +1,4 @@
use arbiter_proto::{
proto::client::{ClientRequest, ClientResponse},
transport::Bi,
};
use arbiter_proto::transport::Bi;
use kameo::actor::Spawn;
use tracing::{error, info};
@@ -24,7 +21,27 @@ pub enum ClientError {
Auth(#[from] auth::Error),
}
pub type Transport = Box<dyn Bi<ClientRequest, Result<ClientResponse, ClientError>> + Send>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConnectErrorCode {
Unknown,
ApprovalDenied,
NoUserAgentsOnline,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Request {
AuthChallengeRequest { pubkey: Vec<u8> },
AuthChallengeSolution { signature: Vec<u8> },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Response {
AuthChallenge { pubkey: Vec<u8>, nonce: i32 },
AuthOk,
ClientConnectError { code: ConnectErrorCode },
}
pub type Transport = Box<dyn Bi<Request, Result<Response, ClientError>> + Send>;
pub struct ClientConnection {
pub(crate) db: db::DatabasePool,

View File

@@ -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<ClientResponse, ClientError>;
type Output = Result<Response, ClientError>;
impl Actor for ClientSession {
type Args = Self;

View File

@@ -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::<EtherTransfer>(client_id, FullGrant { basic, specific: settings })
.create_grant::<EtherTransfer>(
client_id,
FullGrant {
basic,
specific: settings,
},
)
.await
}
SpecificGrant::TokenTransfer(settings) => {
self.engine
.create_grant::<TokenTransfer>(client_id, FullGrant { basic, specific: settings })
.create_grant::<TokenTransfer>(
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<Vec<u8>> = 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 _;

View File

@@ -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)

View File

@@ -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<u8>) -> Result<AuthPublicKey, Error> {
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<AuthEvents, Error> {
fn parse_auth_event(payload: Request) -> Result<AuthEvents, Error> {
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<AuthPublicK
loop {
// `state` holds a mutable reference to `props` so we can't access it directly here
let transport = state.context_mut().conn.transport.as_mut();
let Some(UserAgentRequest {
payload: Some(payload),
}) = transport.recv().await
else {
let Some(payload) = transport.recv().await else {
return Err(Error::Transport);
};

View File

@@ -1,53 +1,16 @@
use arbiter_proto::proto::user_agent::{
AuthChallenge, AuthOk, UserAgentResponse,
user_agent_response::Payload as UserAgentResponsePayload,
};
use diesel::{ExpressionMethods as _, OptionalExtension as _, QueryDsl, update};
use diesel_async::RunQueryDsl;
use tracing::error;
use super::Error;
use crate::{
actors::{bootstrap::ConsumeToken, user_agent::UserAgentConnection},
db::{models::KeyType, schema},
actors::{
bootstrap::ConsumeToken,
user_agent::{AuthPublicKey, Response, UserAgentConnection},
},
db::schema,
};
/// Abstraction over Ed25519 / ECDSA-secp256k1 / RSA public keys used during the auth handshake.
#[derive(Clone)]
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<u8> {
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<AuthPublicKey, Self::Error> {
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)?;
}

View File

@@ -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<dyn Bi<UserAgentRequest, Result<UserAgentResponse, TransportResponseError>> + 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<u8> {
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<String>,
},
AuthChallengeSolution {
signature: Vec<u8>,
},
UnsealStart {
client_pubkey: x25519_dalek::PublicKey,
},
UnsealEncryptedKey {
nonce: Vec<u8>,
ciphertext: Vec<u8>,
associated_data: Vec<u8>,
},
BootstrapEncryptedKey {
nonce: Vec<u8>,
ciphertext: Vec<u8>,
associated_data: Vec<u8>,
},
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<Address>),
}
pub type Transport = Box<dyn Bi<Request, Result<Response, TransportResponseError>> + Send>;
pub struct UserAgentConnection {
db: db::DatabasePool,

View File

@@ -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<Reply: kameo::Reply>(
&mut self,
msg: UserAgentResponsePayload,
msg: Response,
_ctx: &mut Context<Self, Reply>,
) -> 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<Extractor, Msg, Reply>(
@@ -83,7 +73,7 @@ impl UserAgentSession {
ctx: &mut Context<Self, Reply>,
) -> Result<Msg, Error>
where
Extractor: FnOnce(UserAgentRequestPayload) -> Option<Msg>,
Extractor: FnOnce(Request) -> Option<Msg>,
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<Self, Result<bool, Error>>,
) -> Result<bool, Error> {
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<UserAgentResponse, TransportResponseError>;
fn response(payload: UserAgentResponsePayload) -> UserAgentResponse {
UserAgentResponse {
payload: Some(payload),
}
}
type Output = Result<Response, TransportResponseError>;
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<u8>,
ciphertext: Vec<u8>,
associated_data: Vec<u8>,
) -> 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<u8>,
ciphertext: Vec<u8>,
associated_data: Vec<u8>,
) -> 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<M>(op: &str, err: SendError<M, crate::actors::evm::Error>) -> 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)
}
}
}
}

View File

@@ -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)]

View File

@@ -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,

View File

@@ -73,7 +73,6 @@ pub struct Grant<PolicySettings> {
pub settings: PolicySettings,
}
pub trait Policy: Sized {
type Settings: Send + Sync + 'static + Into<SpecificGrant>;
type Meaning: Display + std::fmt::Debug + Send + Sync + 'static + Into<SpecificMeaning>;

View File

@@ -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,
};

View File

@@ -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);

View File

@@ -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<Signature>,
) -> Result<Signature> {
fn sign_tx_inner(&self, tx: &mut dyn SignableTransaction<Signature>) -> Result<Signature> {
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)
}
}

View File

@@ -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<Result<ClientResponse, Status>>,
receiver: Streaming<ClientRequest>,
}
impl GrpcTransport {
pub fn new(
sender: mpsc::Sender<Result<ClientResponse, Status>>,
receiver: Streaming<ClientRequest>,
) -> Self {
Self { sender, receiver }
}
fn request_to_domain(request: ClientRequest) -> Result<DomainRequest, Status> {
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<DomainRequest, Result<DomainResponse, ClientError>> for GrpcTransport {
async fn send(&mut self, item: Result<DomainResponse, ClientError>) -> 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<DomainRequest> {
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"),
}
}

View File

@@ -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<Result<UserAgentResponse, Status>>;
type ClientStream = ReceiverStream<Result<ClientResponse, Status>>;
#[tracing::instrument(level = "debug", skip(self))]
async fn client(
&self,
request: Request<tonic::Streaming<ClientRequest>>,
) -> Result<Response<Self::ClientStream>, 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<tonic::Streaming<UserAgentRequest>>,
) -> Result<Response<Self::UserAgentStream>, 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)))
}
}

View File

@@ -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<Result<UserAgentResponse, Status>>,
receiver: Streaming<UserAgentRequest>,
}
impl GrpcTransport {
pub fn new(
sender: mpsc::Sender<Result<UserAgentResponse, Status>>,
receiver: Streaming<UserAgentRequest>,
) -> Self {
Self { sender, receiver }
}
fn request_to_domain(request: UserAgentRequest) -> Result<DomainRequest, Status> {
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<DomainRequest, Result<DomainResponse, TransportResponseError>> for GrpcTransport {
async fn send(
&mut self,
item: Result<DomainResponse, TransportResponseError>,
) -> 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<DomainRequest> {
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<u8>) -> Result<AuthPublicKey, Status> {
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"),
}
}

View File

@@ -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<UserAgentResponse, TransportResponseError>;
type Output = Result<UserAgentResponse, Status>;
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<ClientResponse, ClientError>;
type Output = Result<ClientResponse, Status>;
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<Result<UserAgentResponse, Status>>;
type ClientStream = ReceiverStream<Result<ClientResponse, Status>>;
#[tracing::instrument(level = "debug", skip(self))]
async fn client(
&self,
request: Request<tonic::Streaming<ClientRequest>>,
) -> Result<Response<Self::ClientStream>, Status> {
let req_stream = request.into_inner();
let (tx, rx) = mpsc::channel(DEFAULT_CHANNEL_SIZE);
let transport = grpc::GrpcAdapter::new(
tx,
req_stream,
IdentityRecvConverter::<ClientRequest>::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<tonic::Streaming<UserAgentRequest>>,
) -> Result<Response<Self::UserAgentStream>, Status> {
let req_stream = request.into_inner();
let (tx, rx) = mpsc::channel(DEFAULT_CHANNEL_SIZE);
let transport = grpc::GrpcAdapter::new(
tx,
req_stream,
IdentityRecvConverter::<UserAgentRequest>::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)))
}
}

View File

@@ -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();

View File

@@ -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();

View File

@@ -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(()))));
}
}

View File

@@ -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

View File

@@ -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<UserAgentResponse>,
IdentitySendConverter<UserAgentRequest>,
>,
>,
>;
pub async fn connect_grpc(
url: ArbiterUrl,
key: SigningKeyEnum,
) -> Result<UserAgentGrpc, ConnectError> {
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)
}

View File

@@ -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<u8> {
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<u8> {
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::<sha2::Sha256>::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<Transport>
where
Transport: Bi<UserAgentResponse, UserAgentRequest>,
{
key: SigningKeyEnum,
bootstrap_token: Option<String>,
state: UserAgentStateMachine<DummyContext>,
transport: Transport,
}
impl<Transport> UserAgentActor<Transport>
where
Transport: Bi<UserAgentResponse, UserAgentRequest>,
{
pub fn new(key: SigningKeyEnum, bootstrap_token: Option<String>, 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<Transport> Actor for UserAgentActor<Transport>
where
Transport: Bi<UserAgentResponse, UserAgentRequest>,
{
type Args = Self;
type Error = ();
async fn on_start(
mut args: Self::Args,
_actor_ref: ActorRef<Self>,
) -> Result<Self, Self::Error> {
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<Self>,
mailbox_rx: &mut kameo::prelude::MailboxReceiver<Self>,
) -> Option<kameo::mailbox::Signal<Self>> {
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<u8>,
}
/// Send an `UnsealEncryptedKey` request and await the server's `UnsealResult`.
pub struct SendUnsealEncryptedKey {
pub nonce: Vec<u8>,
pub ciphertext: Vec<u8>,
pub associated_data: Vec<u8>,
}
/// Send a `BootstrapEncryptedKey` request and await the server's `BootstrapResult`.
pub struct SendBootstrapEncryptedKey {
pub nonce: Vec<u8>,
pub ciphertext: Vec<u8>,
pub associated_data: Vec<u8>,
}
/// 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<Transport> kameo::message::Message<SendUnsealStart> for UserAgentActor<Transport>
where
Transport: Bi<UserAgentResponse, UserAgentRequest>,
{
type Reply = Result<arbiter_proto::proto::user_agent::UnsealStartResponse, SessionError>;
async fn handle(
&mut self,
msg: SendUnsealStart,
_ctx: &mut kameo::message::Context<Self, Self::Reply>,
) -> 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<Transport> kameo::message::Message<SendUnsealEncryptedKey> for UserAgentActor<Transport>
where
Transport: Bi<UserAgentResponse, UserAgentRequest>,
{
type Reply = Result<i32, SessionError>;
async fn handle(
&mut self,
msg: SendUnsealEncryptedKey,
_ctx: &mut kameo::message::Context<Self, Self::Reply>,
) -> 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<Transport> kameo::message::Message<SendBootstrapEncryptedKey> for UserAgentActor<Transport>
where
Transport: Bi<UserAgentResponse, UserAgentRequest>,
{
type Reply = Result<i32, SessionError>;
async fn handle(
&mut self,
msg: SendBootstrapEncryptedKey,
_ctx: &mut kameo::message::Context<Self, Self::Reply>,
) -> 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<Transport> kameo::message::Message<QueryVaultState> for UserAgentActor<Transport>
where
Transport: Bi<UserAgentResponse, UserAgentRequest>,
{
type Reply = Result<i32, SessionError>;
async fn handle(
&mut self,
_msg: QueryVaultState,
_ctx: &mut kameo::message::Context<Self, Self::Reply>,
) -> 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),
}
}
}

View File

@@ -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<UserAgentResponse>,
outbound_tx: mpsc::Sender<UserAgentRequest>,
}
#[async_trait]
impl Bi<UserAgentResponse, UserAgentRequest> 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<UserAgentResponse> {
self.inbound_rx.recv().await
}
}
fn make_transport() -> (
TestTransport,
mpsc::Sender<UserAgentResponse>,
mpsc::Receiver<UserAgentRequest>,
) {
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);
}