2 Commits

Author SHA1 Message Date
hdbg
ccd657c9ec fix(server): enabled crypto provider for rustls
Some checks failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-lint Pipeline was successful
ci/woodpecker/push/server-test Pipeline was successful
2026-03-15 16:46:39 +01:00
hdbg
013af7e65f fix(server): remove useless vendored protoc 2026-03-15 16:43:30 +01:00
24 changed files with 289 additions and 1495 deletions

View File

@@ -23,7 +23,6 @@ message ClientRequest {
oneof payload { oneof payload {
AuthChallengeRequest auth_challenge_request = 1; AuthChallengeRequest auth_challenge_request = 1;
AuthChallengeSolution auth_challenge_solution = 2; AuthChallengeSolution auth_challenge_solution = 2;
arbiter.evm.EvmSignTransactionRequest evm_sign_transaction = 3;
} }
} }

View File

@@ -12,55 +12,6 @@ enum KeyType {
KEY_TYPE_RSA = 3; KEY_TYPE_RSA = 3;
} }
// --- SDK client management ---
enum SdkClientError {
SDK_CLIENT_ERROR_UNSPECIFIED = 0;
SDK_CLIENT_ERROR_ALREADY_EXISTS = 1;
SDK_CLIENT_ERROR_NOT_FOUND = 2;
SDK_CLIENT_ERROR_HAS_RELATED_DATA = 3; // hard-delete blocked by FK (client has grants or transaction logs)
SDK_CLIENT_ERROR_INTERNAL = 4;
}
message SdkClientApproveRequest {
bytes pubkey = 1; // 32-byte ed25519 public key
}
message SdkClientRevokeRequest {
int32 client_id = 1;
}
message SdkClientEntry {
int32 id = 1;
bytes pubkey = 2;
int32 created_at = 3;
}
message SdkClientList {
repeated SdkClientEntry clients = 1;
}
message SdkClientApproveResponse {
oneof result {
SdkClientEntry client = 1;
SdkClientError error = 2;
}
}
message SdkClientRevokeResponse {
oneof result {
google.protobuf.Empty ok = 1;
SdkClientError error = 2;
}
}
message SdkClientListResponse {
oneof result {
SdkClientList clients = 1;
SdkClientError error = 2;
}
}
message AuthChallengeRequest { message AuthChallengeRequest {
bytes pubkey = 1; bytes pubkey = 1;
optional string bootstrap_token = 2; optional string bootstrap_token = 2;
@@ -106,6 +57,16 @@ enum VaultState {
VAULT_STATE_ERROR = 4; VAULT_STATE_ERROR = 4;
} }
message ClientConnectionRequest {
bytes pubkey = 1;
}
message ClientConnectionResponse {
bool approved = 1;
}
message ClientConnectionCancel {}
message UserAgentRequest { message UserAgentRequest {
oneof payload { oneof payload {
AuthChallengeRequest auth_challenge_request = 1; AuthChallengeRequest auth_challenge_request = 1;
@@ -118,10 +79,7 @@ message UserAgentRequest {
arbiter.evm.EvmGrantCreateRequest evm_grant_create = 8; arbiter.evm.EvmGrantCreateRequest evm_grant_create = 8;
arbiter.evm.EvmGrantDeleteRequest evm_grant_delete = 9; arbiter.evm.EvmGrantDeleteRequest evm_grant_delete = 9;
arbiter.evm.EvmGrantListRequest evm_grant_list = 10; arbiter.evm.EvmGrantListRequest evm_grant_list = 10;
// field 11 reserved: was client_connection_response (online approval removed) ClientConnectionResponse client_connection_response = 11;
SdkClientApproveRequest sdk_client_approve = 12;
SdkClientRevokeRequest sdk_client_revoke = 13;
google.protobuf.Empty sdk_client_list = 14;
} }
} }
message UserAgentResponse { message UserAgentResponse {
@@ -136,9 +94,7 @@ message UserAgentResponse {
arbiter.evm.EvmGrantCreateResponse evm_grant_create = 8; arbiter.evm.EvmGrantCreateResponse evm_grant_create = 8;
arbiter.evm.EvmGrantDeleteResponse evm_grant_delete = 9; arbiter.evm.EvmGrantDeleteResponse evm_grant_delete = 9;
arbiter.evm.EvmGrantListResponse evm_grant_list = 10; arbiter.evm.EvmGrantListResponse evm_grant_list = 10;
// fields 11, 12 reserved: were client_connection_request, client_connection_cancel (online approval removed) ClientConnectionRequest client_connection_request = 11;
SdkClientApproveResponse sdk_client_approve = 13; ClientConnectionCancel client_connection_cancel = 12;
SdkClientRevokeResponse sdk_client_revoke = 14;
SdkClientListResponse sdk_client_list = 15;
} }
} }

94
server/Cargo.lock generated
View File

@@ -678,18 +678,6 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]] [[package]]
name = "arbiter-client" name = "arbiter-client"
version = "0.1.0" version = "0.1.0"
dependencies = [
"alloy",
"arbiter-proto",
"async-trait",
"ed25519-dalek",
"http",
"rustls-webpki",
"thiserror",
"tokio",
"tokio-stream",
"tonic",
]
[[package]] [[package]]
name = "arbiter-proto" name = "arbiter-proto"
@@ -703,7 +691,6 @@ dependencies = [
"miette", "miette",
"prost", "prost",
"prost-types", "prost-types",
"protoc-bin-vendored",
"rand 0.10.0", "rand 0.10.0",
"rcgen", "rcgen",
"rstest", "rstest",
@@ -761,13 +748,6 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "arbiter-terrors-poc"
version = "0.1.0"
dependencies = [
"terrors",
]
[[package]] [[package]]
name = "arbiter-tokens-registry" name = "arbiter-tokens-registry"
version = "0.1.0" version = "0.1.0"
@@ -3680,70 +3660,6 @@ dependencies = [
"prost", "prost",
] ]
[[package]]
name = "protoc-bin-vendored"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1c381df33c98266b5f08186583660090a4ffa0889e76c7e9a5e175f645a67fa"
dependencies = [
"protoc-bin-vendored-linux-aarch_64",
"protoc-bin-vendored-linux-ppcle_64",
"protoc-bin-vendored-linux-s390_64",
"protoc-bin-vendored-linux-x86_32",
"protoc-bin-vendored-linux-x86_64",
"protoc-bin-vendored-macos-aarch_64",
"protoc-bin-vendored-macos-x86_64",
"protoc-bin-vendored-win32",
]
[[package]]
name = "protoc-bin-vendored-linux-aarch_64"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c350df4d49b5b9e3ca79f7e646fde2377b199e13cfa87320308397e1f37e1a4c"
[[package]]
name = "protoc-bin-vendored-linux-ppcle_64"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a55a63e6c7244f19b5c6393f025017eb5d793fd5467823a099740a7a4222440c"
[[package]]
name = "protoc-bin-vendored-linux-s390_64"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1dba5565db4288e935d5330a07c264a4ee8e4a5b4a4e6f4e83fad824cc32f3b0"
[[package]]
name = "protoc-bin-vendored-linux-x86_32"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8854774b24ee28b7868cd71dccaae8e02a2365e67a4a87a6cd11ee6cdbdf9cf5"
[[package]]
name = "protoc-bin-vendored-linux-x86_64"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b38b07546580df720fa464ce124c4b03630a6fb83e05c336fea2a241df7e5d78"
[[package]]
name = "protoc-bin-vendored-macos-aarch_64"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89278a9926ce312e51f1d999fee8825d324d603213344a9a706daa009f1d8092"
[[package]]
name = "protoc-bin-vendored-macos-x86_64"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81745feda7ccfb9471d7a4de888f0652e806d5795b61480605d4943176299756"
[[package]]
name = "protoc-bin-vendored-win32"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95067976aca6421a523e491fce939a3e65249bac4b977adee0ee9771568e8aa3"
[[package]] [[package]]
name = "pulldown-cmark" name = "pulldown-cmark"
version = "0.13.1" version = "0.13.1"
@@ -4863,12 +4779,6 @@ dependencies = [
"windows-sys 0.60.2", "windows-sys 0.60.2",
] ]
[[package]]
name = "terrors"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "987fd8c678ca950df2a18b2c6e9da6ca511d449278fab3565efe0d49c0c07a5d"
[[package]] [[package]]
name = "test-log" name = "test-log"
version = "0.2.19" version = "0.2.19"
@@ -4981,9 +4891,9 @@ dependencies = [
[[package]] [[package]]
name = "tinyvec" name = "tinyvec"
version = "1.10.0" version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
dependencies = [ dependencies = [
"tinyvec_macros", "tinyvec_macros",
] ]

View File

@@ -20,7 +20,7 @@ tokio = { version = "1.49.0", features = ["full"] }
ed25519-dalek = { version = "3.0.0-pre.6", features = ["rand_core"] } ed25519-dalek = { version = "3.0.0-pre.6", features = ["rand_core"] }
chrono = { version = "0.4.43", features = ["serde"] } chrono = { version = "0.4.43", features = ["serde"] }
rand = "0.10.0" rand = "0.10.0"
rustls = "0.23.36" rustls = { version = "0.23.36", features = ["aws-lc-rs"] }
smlang = "0.8.0" smlang = "0.8.0"
miette = { version = "7.6.0", features = ["fancy", "serde"] } miette = { version = "7.6.0", features = ["fancy", "serde"] }
thiserror = "2.0.18" thiserror = "2.0.18"

View File

@@ -5,18 +5,4 @@ edition = "2024"
repository = "https://git.markettakers.org/MarketTakers/arbiter" repository = "https://git.markettakers.org/MarketTakers/arbiter"
license = "Apache-2.0" license = "Apache-2.0"
[lints]
workspace = true
[dependencies] [dependencies]
arbiter-proto.path = "../arbiter-proto"
alloy.workspace = true
tonic.workspace = true
tonic.features = ["tls-aws-lc"]
tokio.workspace = true
tokio-stream.workspace = true
ed25519-dalek.workspace = true
thiserror.workspace = true
http = "1.4.0"
rustls-webpki = { version = "0.103.9", features = ["aws-lc-rs"] }
async-trait.workspace = true

View File

@@ -1,272 +1,14 @@
use alloy::{ pub fn add(left: u64, right: u64) -> u64 {
consensus::SignableTransaction, left + right
network::TxSigner,
primitives::{Address, B256, ChainId, Signature},
signers::{Error, Result, Signer},
};
use arbiter_proto::{
format_challenge,
proto::{
arbiter_service_client::ArbiterServiceClient,
client::{
AuthChallengeRequest, AuthChallengeSolution, ClientRequest, ClientResponse,
client_connect_error, client_request::Payload as ClientRequestPayload,
client_response::Payload as ClientResponsePayload,
},
evm::{
EvmSignTransactionRequest, evm_sign_transaction_response::Result as SignResponseResult,
},
},
url::ArbiterUrl,
};
use async_trait::async_trait;
use ed25519_dalek::Signer as _;
use tokio::sync::{Mutex, mpsc};
use tokio_stream::wrappers::ReceiverStream;
use tonic::transport::ClientTlsConfig;
#[derive(Debug, thiserror::Error)]
pub enum ConnectError {
#[error("Could not 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),
#[error("Auth challenge was not returned by server")]
MissingAuthChallenge,
#[error("Client approval denied by User Agent")]
ApprovalDenied,
#[error("No User Agents online to approve client")]
NoUserAgentsOnline,
#[error("Unexpected auth response payload")]
UnexpectedAuthResponse,
} }
#[derive(Debug, thiserror::Error)] #[cfg(test)]
enum ClientSignError { mod tests {
#[error("Transport channel closed")] use super::*;
ChannelClosed,
#[error("Connection closed by server")] #[test]
ConnectionClosed, fn it_works() {
let result = add(2, 2);
#[error("Invalid response payload")] assert_eq!(result, 4);
InvalidResponse,
#[error("Remote signing was rejected")]
Rejected,
}
struct ClientTransport {
sender: mpsc::Sender<ClientRequest>,
receiver: tonic::Streaming<ClientResponse>,
}
impl ClientTransport {
async fn send(&mut self, request: ClientRequest) -> std::result::Result<(), ClientSignError> {
self.sender
.send(request)
.await
.map_err(|_| ClientSignError::ChannelClosed)
}
async fn recv(&mut self) -> std::result::Result<ClientResponse, ClientSignError> {
match self.receiver.message().await {
Ok(Some(resp)) => Ok(resp),
Ok(None) => Err(ClientSignError::ConnectionClosed),
Err(_) => Err(ClientSignError::ConnectionClosed),
}
}
}
pub struct ArbiterSigner {
transport: Mutex<ClientTransport>,
address: Address,
chain_id: Option<ChainId>,
}
impl ArbiterSigner {
pub async fn connect_grpc(
url: ArbiterUrl,
key: ed25519_dalek::SigningKey,
address: Address,
) -> std::result::Result<Self, ConnectError> {
let anchor = webpki::anchor_from_trusted_cert(&url.ca_cert)?.to_owned();
let tls = ClientTlsConfig::new().trust_anchor(anchor);
// NOTE: We intentionally keep the same URL construction strategy as the user-agent crate
// to avoid behavior drift between the two clients.
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 response_stream = client.client(ReceiverStream::new(rx)).await?.into_inner();
let mut transport = ClientTransport {
sender: tx,
receiver: response_stream,
};
authenticate(&mut transport, key).await?;
Ok(Self {
transport: Mutex::new(transport),
address,
chain_id: None,
})
}
async fn sign_transaction_via_arbiter(
&self,
tx: &mut dyn SignableTransaction<Signature>,
) -> Result<Signature> {
if let Some(chain_id) = self.chain_id
&& !tx.set_chain_id_checked(chain_id)
{
return Err(Error::TransactionChainIdMismatch {
signer: chain_id,
tx: tx.chain_id().unwrap(),
});
}
let mut rlp_transaction = Vec::new();
tx.encode_for_signing(&mut rlp_transaction);
let request = ClientRequest {
payload: Some(ClientRequestPayload::EvmSignTransaction(
EvmSignTransactionRequest {
wallet_address: self.address.as_slice().to_vec(),
rlp_transaction,
},
)),
};
let mut transport = self.transport.lock().await;
transport.send(request).await.map_err(Error::other)?;
let response = transport.recv().await.map_err(Error::other)?;
let payload = response
.payload
.ok_or_else(|| Error::other(ClientSignError::InvalidResponse))?;
let ClientResponsePayload::EvmSignTransaction(sign_response) = payload else {
return Err(Error::other(ClientSignError::InvalidResponse));
};
let Some(result) = sign_response.result else {
return Err(Error::other(ClientSignError::InvalidResponse));
};
match result {
SignResponseResult::Signature(bytes) => {
Signature::try_from(bytes.as_slice()).map_err(Error::other)
}
SignResponseResult::EvalError(_) | SignResponseResult::Error(_) => {
Err(Error::other(ClientSignError::Rejected))
}
}
}
}
async fn authenticate(
transport: &mut ClientTransport,
key: ed25519_dalek::SigningKey,
) -> std::result::Result<(), ConnectError> {
transport
.send(ClientRequest {
payload: Some(ClientRequestPayload::AuthChallengeRequest(
AuthChallengeRequest {
pubkey: key.verifying_key().to_bytes().to_vec(),
},
)),
})
.await
.map_err(|_| ConnectError::UnexpectedAuthResponse)?;
let response = transport
.recv()
.await
.map_err(|_| ConnectError::MissingAuthChallenge)?;
let payload = response.payload.ok_or(ConnectError::MissingAuthChallenge)?;
match payload {
ClientResponsePayload::AuthChallenge(challenge) => {
let challenge_payload = format_challenge(challenge.nonce, &challenge.pubkey);
let signature = key.sign(&challenge_payload).to_bytes().to_vec();
transport
.send(ClientRequest {
payload: Some(ClientRequestPayload::AuthChallengeSolution(
AuthChallengeSolution { signature },
)),
})
.await
.map_err(|_| ConnectError::UnexpectedAuthResponse)?;
// Current server flow does not emit `AuthOk` for SDK clients, so we proceed after
// sending the solution. If authentication fails, the first business request will return
// a `ClientConnectError` or the stream will close.
Ok(())
}
ClientResponsePayload::ClientConnectError(err) => {
match client_connect_error::Code::try_from(err.code)
.unwrap_or(client_connect_error::Code::Unknown)
{
client_connect_error::Code::ApprovalDenied => Err(ConnectError::ApprovalDenied),
client_connect_error::Code::NoUserAgentsOnline => {
Err(ConnectError::NoUserAgentsOnline)
}
client_connect_error::Code::Unknown => Err(ConnectError::UnexpectedAuthResponse),
}
}
_ => Err(ConnectError::UnexpectedAuthResponse),
}
}
#[async_trait]
impl Signer for ArbiterSigner {
async fn sign_hash(&self, _hash: &B256) -> Result<Signature> {
Err(Error::other(
"hash-only signing is not supported for ArbiterSigner; use transaction signing",
))
}
fn address(&self) -> Address {
self.address
}
fn chain_id(&self) -> Option<ChainId> {
self.chain_id
}
fn set_chain_id(&mut self, chain_id: Option<ChainId>) {
self.chain_id = chain_id;
}
}
#[async_trait]
impl TxSigner<Signature> for ArbiterSigner {
fn address(&self) -> Address {
self.address
}
async fn sign_transaction(
&self,
tx: &mut dyn SignableTransaction<Signature>,
) -> Result<Signature> {
self.sign_transaction_via_arbiter(tx).await
} }
} }

View File

@@ -24,7 +24,6 @@ async-trait.workspace = true
[build-dependencies] [build-dependencies]
tonic-prost-build = "0.14.3" tonic-prost-build = "0.14.3"
protoc-bin-vendored = "3"
[dev-dependencies] [dev-dependencies]
rstest.workspace = true rstest.workspace = true

View File

@@ -3,12 +3,6 @@ use tonic_prost_build::configure;
static PROTOBUF_DIR: &str = "../../../protobufs"; static PROTOBUF_DIR: &str = "../../../protobufs";
fn main() -> Result<(), Box<dyn std::error::Error>> { fn main() -> Result<(), Box<dyn std::error::Error>> {
if std::env::var("PROTOC").is_err() {
println!("cargo:warning=PROTOC environment variable not set, using vendored protoc");
let protoc = protoc_bin_vendored::protoc_bin_path().unwrap();
unsafe { std::env::set_var("PROTOC", protoc) };
}
println!("cargo::rerun-if-changed={PROTOBUF_DIR}"); println!("cargo::rerun-if-changed={PROTOBUF_DIR}");
configure() configure()

View File

@@ -1 +0,0 @@
DROP INDEX IF EXISTS program_client_public_key_unique;

View File

@@ -1,2 +0,0 @@
CREATE UNIQUE INDEX program_client_public_key_unique
ON program_client (public_key);

View File

@@ -8,13 +8,19 @@ use arbiter_proto::{
}, },
transport::expect_message, transport::expect_message,
}; };
use diesel::{ExpressionMethods as _, OptionalExtension as _, QueryDsl as _, update}; use diesel::{
ExpressionMethods as _, OptionalExtension as _, QueryDsl as _, dsl::insert_into, update,
};
use diesel_async::RunQueryDsl as _; use diesel_async::RunQueryDsl as _;
use ed25519_dalek::VerifyingKey; use ed25519_dalek::VerifyingKey;
use kameo::error::SendError;
use tracing::error; use tracing::error;
use crate::{ use crate::{
actors::client::ClientConnection, actors::{
client::ClientConnection,
router::{self, RequestClientApproval},
},
db::{self, schema::program_client}, db::{self, schema::program_client},
}; };
@@ -34,20 +40,27 @@ pub enum Error {
DatabaseOperationFailed, DatabaseOperationFailed,
#[error("Invalid challenge solution")] #[error("Invalid challenge solution")]
InvalidChallengeSolution, InvalidChallengeSolution,
#[error("Client not registered")] #[error("Client approval request failed")]
NotRegistered, ApproveError(#[from] ApproveError),
#[error("Internal error")] #[error("Internal error")]
InternalError, InternalError,
#[error("Transport error")] #[error("Transport error")]
Transport, Transport,
} }
#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
pub enum ApproveError {
#[error("Internal error")]
Internal,
#[error("Client connection denied by user agents")]
Denied,
#[error("Upstream error: {0}")]
Upstream(router::ApprovalError),
}
/// Atomically reads and increments the nonce for a known client. /// Atomically reads and increments the nonce for a known client.
/// Returns `None` if the pubkey is not registered. /// Returns `None` if the pubkey is not registered.
async fn get_nonce( async fn get_nonce(db: &db::DatabasePool, pubkey: &VerifyingKey) -> Result<Option<i32>, Error> {
db: &db::DatabasePool,
pubkey: &VerifyingKey,
) -> Result<Option<(i32, i32)>, Error> {
let pubkey_bytes = pubkey.as_bytes().to_vec(); let pubkey_bytes = pubkey.as_bytes().to_vec();
let mut conn = db.get().await.map_err(|e| { let mut conn = db.get().await.map_err(|e| {
@@ -58,10 +71,10 @@ async fn get_nonce(
conn.exclusive_transaction(|conn| { conn.exclusive_transaction(|conn| {
let pubkey_bytes = pubkey_bytes.clone(); let pubkey_bytes = pubkey_bytes.clone();
Box::pin(async move { Box::pin(async move {
let Some((client_id, current_nonce)) = program_client::table let Some(current_nonce) = program_client::table
.filter(program_client::public_key.eq(&pubkey_bytes)) .filter(program_client::public_key.eq(&pubkey_bytes))
.select((program_client::id, program_client::nonce)) .select(program_client::nonce)
.first::<(i32, i32)>(conn) .first::<i32>(conn)
.await .await
.optional()? .optional()?
else { else {
@@ -74,7 +87,7 @@ async fn get_nonce(
.execute(conn) .execute(conn)
.await?; .await?;
Ok(Some((client_id, current_nonce))) Ok(Some(current_nonce))
}) })
}) })
.await .await
@@ -84,6 +97,59 @@ async fn get_nonce(
}) })
} }
async fn approve_new_client(
actors: &crate::actors::GlobalActors,
pubkey: VerifyingKey,
) -> Result<(), Error> {
let result = actors
.router
.ask(RequestClientApproval {
client_pubkey: pubkey,
})
.await;
match result {
Ok(true) => Ok(()),
Ok(false) => Err(Error::ApproveError(ApproveError::Denied)),
Err(SendError::HandlerError(e)) => {
error!(error = ?e, "Approval upstream error");
Err(Error::ApproveError(ApproveError::Upstream(e)))
}
Err(e) => {
error!(error = ?e, "Approval request to router failed");
Err(Error::ApproveError(ApproveError::Internal))
}
}
}
async fn insert_client(db: &db::DatabasePool, pubkey: &VerifyingKey) -> Result<(), Error> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i32;
let mut conn = db.get().await.map_err(|e| {
error!(error = ?e, "Database pool error");
Error::DatabasePoolUnavailable
})?;
insert_into(program_client::table)
.values((
program_client::public_key.eq(pubkey.as_bytes().to_vec()),
program_client::nonce.eq(1), // pre-incremented; challenge uses 0
program_client::created_at.eq(now),
program_client::updated_at.eq(now),
))
.execute(&mut conn)
.await
.map_err(|e| {
error!(error = ?e, "Failed to insert new client");
Error::DatabaseOperationFailed
})?;
Ok(())
}
async fn challenge_client( async fn challenge_client(
props: &mut ClientConnection, props: &mut ClientConnection,
pubkey: VerifyingKey, pubkey: VerifyingKey,
@@ -134,12 +200,15 @@ async fn challenge_client(
fn connect_error_code(err: &Error) -> ConnectErrorCode { fn connect_error_code(err: &Error) -> ConnectErrorCode {
match err { match err {
Error::NotRegistered => ConnectErrorCode::ApprovalDenied, Error::ApproveError(ApproveError::Denied) => ConnectErrorCode::ApprovalDenied,
Error::ApproveError(ApproveError::Upstream(
router::ApprovalError::NoUserAgentsConnected,
)) => ConnectErrorCode::NoUserAgentsOnline,
_ => ConnectErrorCode::Unknown, _ => ConnectErrorCode::Unknown,
} }
} }
async fn authenticate(props: &mut ClientConnection) -> Result<(VerifyingKey, i32), Error> { async fn authenticate(props: &mut ClientConnection) -> Result<VerifyingKey, Error> {
let Some(ClientRequest { let Some(ClientRequest {
payload: Some(ClientRequestPayload::AuthChallengeRequest(challenge)), payload: Some(ClientRequestPayload::AuthChallengeRequest(challenge)),
}) = props.transport.recv().await }) = props.transport.recv().await
@@ -154,19 +223,23 @@ async fn authenticate(props: &mut ClientConnection) -> Result<(VerifyingKey, i32
let pubkey = let pubkey =
VerifyingKey::from_bytes(pubkey_bytes).map_err(|_| Error::InvalidAuthPubkeyEncoding)?; VerifyingKey::from_bytes(pubkey_bytes).map_err(|_| Error::InvalidAuthPubkeyEncoding)?;
let (client_id, nonce) = match get_nonce(&props.db, &pubkey).await? { let nonce = match get_nonce(&props.db, &pubkey).await? {
Some((client_id, nonce)) => (client_id, nonce), Some(nonce) => nonce,
None => return Err(Error::NotRegistered), None => {
approve_new_client(&props.actors, pubkey).await?;
insert_client(&props.db, &pubkey).await?;
0
}
}; };
challenge_client(props, pubkey, nonce).await?; challenge_client(props, pubkey, nonce).await?;
Ok((pubkey, client_id)) Ok(pubkey)
} }
pub async fn authenticate_and_create(mut props: ClientConnection) -> Result<ClientSession, Error> { pub async fn authenticate_and_create(mut props: ClientConnection) -> Result<ClientSession, Error> {
match authenticate(&mut props).await { match authenticate(&mut props).await {
Ok((_pubkey, client_id)) => Ok(ClientSession::new(props, client_id)), Ok(_pubkey) => Ok(ClientSession::new(props)),
Err(err) => { Err(err) => {
let code = connect_error_code(&err); let code = connect_error_code(&err);
let _ = props let _ = props

View File

@@ -1,35 +1,19 @@
use alloy::{consensus::TxEip1559, primitives::Address, rlp::Decodable}; use arbiter_proto::proto::client::{ClientRequest, ClientResponse};
use arbiter_proto::proto::{
client::{
ClientRequest, ClientResponse, client_request::Payload as ClientRequestPayload,
client_response::Payload as ClientResponsePayload,
},
evm::{
EvmError, EvmSignTransactionResponse, evm_sign_transaction_response::Result as SignResult,
},
};
use kameo::Actor; use kameo::Actor;
use tokio::select; use tokio::select;
use tracing::{error, info}; use tracing::{error, info};
use crate::{ use crate::{actors::{
actors::{ GlobalActors, client::{ClientError, ClientConnection}, router::RegisterClient
GlobalActors, }, db};
client::{ClientConnection, ClientError},
evm::ClientSignTransaction,
router::RegisterClient,
},
db,
};
pub struct ClientSession { pub struct ClientSession {
props: ClientConnection, props: ClientConnection,
client_id: i32,
} }
impl ClientSession { impl ClientSession {
pub(crate) fn new(props: ClientConnection, client_id: i32) -> Self { pub(crate) fn new(props: ClientConnection) -> Self {
Self { props, client_id } Self { props }
} }
pub async fn process_transport_inbound(&mut self, req: ClientRequest) -> Output { pub async fn process_transport_inbound(&mut self, req: ClientRequest) -> Output {
@@ -38,46 +22,8 @@ impl ClientSession {
ClientError::MissingRequestPayload ClientError::MissingRequestPayload
})?; })?;
match msg { let _ = msg;
ClientRequestPayload::EvmSignTransaction(sign_req) => { Err(ClientError::UnexpectedRequestPayload)
let wallet_address: [u8; 20] = sign_req
.wallet_address
.try_into()
.map_err(|_| ClientError::UnexpectedRequestPayload)?;
let mut rlp_bytes: &[u8] = &sign_req.rlp_transaction;
let tx = TxEip1559::decode(&mut rlp_bytes)
.map_err(|_| ClientError::UnexpectedRequestPayload)?;
let result = self
.props
.actors
.evm
.ask(ClientSignTransaction {
client_id: self.client_id,
wallet_address: Address::from_slice(&wallet_address),
transaction: tx,
})
.await;
let response_result = match result {
Ok(signature) => SignResult::Signature(signature.as_bytes().to_vec()),
Err(err) => {
error!(?err, "client sign transaction failed");
SignResult::Error(EvmError::Internal.into())
}
};
Ok(ClientResponse {
payload: Some(ClientResponsePayload::EvmSignTransaction(
EvmSignTransactionResponse {
result: Some(response_result),
},
)),
})
}
_ => Err(ClientError::UnexpectedRequestPayload),
}
} }
} }
@@ -143,9 +89,6 @@ impl ClientSession {
use arbiter_proto::transport::DummyTransport; use arbiter_proto::transport::DummyTransport;
let transport: super::Transport = Box::new(DummyTransport::new()); let transport: super::Transport = Box::new(DummyTransport::new());
let props = ClientConnection::new(db, transport, actors); let props = ClientConnection::new(db, transport, actors);
Self { Self { props }
props,
client_id: 0,
}
} }
} }

View File

@@ -1,14 +1,20 @@
use std::{collections::HashMap, ops::ControlFlow}; use std::{collections::HashMap, ops::ControlFlow};
use ed25519_dalek::VerifyingKey;
use kameo::{ use kameo::{
Actor, Actor,
actor::{ActorId, ActorRef}, actor::{ActorId, ActorRef},
messages, messages,
prelude::{ActorStopReason, Context, WeakActorRef}, prelude::{ActorStopReason, Context, WeakActorRef},
reply::DelegatedReply,
}; };
use tracing::info; use tokio::{sync::watch, task::JoinSet};
use tracing::{info, warn};
use crate::actors::{client::session::ClientSession, user_agent::session::UserAgentSession}; use crate::actors::{
client::session::ClientSession,
user_agent::session::{RequestNewClientApproval, UserAgentSession},
};
#[derive(Default)] #[derive(Default)]
pub struct MessageRouter { pub struct MessageRouter {
@@ -50,6 +56,73 @@ impl Actor for MessageRouter {
} }
} }
#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq, Hash)]
pub enum ApprovalError {
#[error("No user agents connected")]
NoUserAgentsConnected,
}
async fn request_client_approval(
user_agents: &[WeakActorRef<UserAgentSession>],
client_pubkey: VerifyingKey,
) -> Result<bool, ApprovalError> {
if user_agents.is_empty() {
return Err(ApprovalError::NoUserAgentsConnected);
}
let mut pool = JoinSet::new();
let (cancel_tx, cancel_rx) = watch::channel(());
for weak_ref in user_agents {
match weak_ref.upgrade() {
Some(agent) => {
let cancel_rx = cancel_rx.clone();
pool.spawn(async move {
agent
.ask(RequestNewClientApproval {
client_pubkey,
cancel_flag: cancel_rx.clone(),
})
.await
});
}
None => {
warn!(
id = weak_ref.id().to_string(),
actor = "MessageRouter",
event = "useragent.disconnected_before_approval"
);
}
}
}
while let Some(result) = pool.join_next().await {
match result {
Ok(Ok(approved)) => {
// cancel other pending requests
let _ = cancel_tx.send(());
return Ok(approved);
}
Ok(Err(err)) => {
warn!(
?err,
actor = "MessageRouter",
event = "useragent.approval_error"
);
}
Err(err) => {
warn!(
?err,
actor = "MessageRouter",
event = "useragent.approval_task_failed"
);
}
}
}
Err(ApprovalError::NoUserAgentsConnected)
}
#[messages] #[messages]
impl MessageRouter { impl MessageRouter {
#[message(ctx)] #[message(ctx)]
@@ -73,4 +146,29 @@ impl MessageRouter {
ctx.actor_ref().link(&actor).await; ctx.actor_ref().link(&actor).await;
self.clients.insert(actor.id(), actor); self.clients.insert(actor.id(), actor);
} }
#[message(ctx)]
pub async fn request_client_approval(
&mut self,
client_pubkey: VerifyingKey,
ctx: &mut Context<Self, DelegatedReply<Result<bool, ApprovalError>>>,
) -> DelegatedReply<Result<bool, ApprovalError>> {
let (reply, Some(reply_sender)) = ctx.reply_sender() else {
panic!("Exptected `request_client_approval` to have callback channel");
};
let weak_refs = self
.user_agents
.values()
.map(|agent| agent.downgrade())
.collect::<Vec<_>>();
// handle in subtask to not to lock the actor
tokio::task::spawn(async move {
let result = request_client_approval(&weak_refs, client_pubkey).await;
reply_sender.send(result);
});
reply
}
} }

View File

@@ -3,32 +3,25 @@ use std::{ops::DerefMut, sync::Mutex};
use arbiter_proto::proto::{ use arbiter_proto::proto::{
evm as evm_proto, evm as evm_proto,
user_agent::{ user_agent::{
SdkClientApproveRequest, SdkClientApproveResponse, SdkClientEntry, ClientConnectionCancel, ClientConnectionRequest, UnsealEncryptedKey, UnsealResult,
SdkClientError as ProtoSdkClientError, SdkClientList, SdkClientListResponse,
SdkClientRevokeRequest, SdkClientRevokeResponse, UnsealEncryptedKey, UnsealResult,
UnsealStart, UnsealStartResponse, UserAgentRequest, UserAgentResponse, UnsealStart, UnsealStartResponse, UserAgentRequest, UserAgentResponse,
sdk_client_approve_response, sdk_client_list_response, sdk_client_revoke_response,
user_agent_request::Payload as UserAgentRequestPayload, user_agent_request::Payload as UserAgentRequestPayload,
user_agent_response::Payload as UserAgentResponsePayload, user_agent_response::Payload as UserAgentResponsePayload,
}, },
}; };
use chacha20poly1305::{AeadInPlace, XChaCha20Poly1305, XNonce, aead::KeyInit}; use chacha20poly1305::{AeadInPlace, XChaCha20Poly1305, XNonce, aead::KeyInit};
use diesel::{ExpressionMethods as _, QueryDsl as _, dsl::insert_into}; use ed25519_dalek::VerifyingKey;
use diesel_async::RunQueryDsl as _; use kameo::{Actor, error::SendError, messages, prelude::Context};
use kameo::{Actor, error::SendError, prelude::Context};
use memsafe::MemSafe; use memsafe::MemSafe;
use tokio::select; use tokio::{select, sync::watch};
use tracing::{error, info}; use tracing::{error, info};
use x25519_dalek::{EphemeralSecret, PublicKey}; use x25519_dalek::{EphemeralSecret, PublicKey};
use crate::{ use crate::actors::{
actors::{ evm::{Generate, ListWallets},
evm::{Generate, ListWallets}, keyholder::{self, TryUnseal},
keyholder::{self, TryUnseal}, router::RegisterUserAgent,
router::RegisterUserAgent, user_agent::{TransportResponseError, UserAgentConnection},
user_agent::{TransportResponseError, UserAgentConnection},
},
db::schema::program_client,
}; };
mod state; mod state;
@@ -115,6 +108,52 @@ impl UserAgentSession {
} }
} }
#[messages]
impl UserAgentSession {
// TODO: Think about refactoring it to state-machine based flow, as we already have one
#[message(ctx)]
pub async fn request_new_client_approval(
&mut self,
client_pubkey: VerifyingKey,
mut cancel_flag: watch::Receiver<()>,
ctx: &mut Context<Self, Result<bool, Error>>,
) -> Result<bool, Error> {
self.send_msg(
UserAgentResponsePayload::ClientConnectionRequest(ClientConnectionRequest {
pubkey: client_pubkey.as_bytes().to_vec(),
}),
ctx,
)
.await?;
let extractor = |msg| {
if let UserAgentRequestPayload::ClientConnectionResponse(client_connection_response) =
msg
{
Some(client_connection_response)
} else {
None
}
};
tokio::select! {
_ = cancel_flag.changed() => {
info!(actor = "useragent", "client connection approval cancelled");
self.send_msg(
UserAgentResponsePayload::ClientConnectionCancel(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)
}
}
}
}
impl UserAgentSession { impl UserAgentSession {
pub async fn process_transport_inbound(&mut self, req: UserAgentRequest) -> Output { pub async fn process_transport_inbound(&mut self, req: UserAgentRequest) -> Output {
let msg = req.payload.ok_or_else(|| { let msg = req.payload.ok_or_else(|| {
@@ -131,13 +170,6 @@ impl UserAgentSession {
} }
UserAgentRequestPayload::EvmWalletCreate(_) => self.handle_evm_wallet_create().await, UserAgentRequestPayload::EvmWalletCreate(_) => self.handle_evm_wallet_create().await,
UserAgentRequestPayload::EvmWalletList(_) => self.handle_evm_wallet_list().await, UserAgentRequestPayload::EvmWalletList(_) => self.handle_evm_wallet_list().await,
UserAgentRequestPayload::SdkClientApprove(req) => {
self.handle_sdk_client_approve(req).await
}
UserAgentRequestPayload::SdkClientRevoke(req) => {
self.handle_sdk_client_revoke(req).await
}
UserAgentRequestPayload::SdkClientList(_) => self.handle_sdk_client_list().await,
_ => Err(TransportResponseError::UnexpectedRequestPayload), _ => Err(TransportResponseError::UnexpectedRequestPayload),
} }
} }
@@ -299,204 +331,6 @@ impl UserAgentSession {
} }
} }
impl UserAgentSession {
async fn handle_sdk_client_approve(&mut self, req: SdkClientApproveRequest) -> Output {
use sdk_client_approve_response::Result as ApproveResult;
if req.pubkey.len() != 32 {
return Ok(response(UserAgentResponsePayload::SdkClientApprove(
SdkClientApproveResponse {
result: Some(ApproveResult::Error(ProtoSdkClientError::Internal.into())),
},
)));
}
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i32;
let mut conn = match self.props.db.get().await {
Ok(c) => c,
Err(e) => {
error!(?e, "Failed to get DB connection for sdk_client_approve");
return Ok(response(UserAgentResponsePayload::SdkClientApprove(
SdkClientApproveResponse {
result: Some(ApproveResult::Error(ProtoSdkClientError::Internal.into())),
},
)));
}
};
let pubkey_bytes = req.pubkey.clone();
let insert_result = insert_into(program_client::table)
.values((
program_client::public_key.eq(&pubkey_bytes),
program_client::nonce.eq(1), // pre-incremented; challenge will use nonce=0
program_client::created_at.eq(now),
program_client::updated_at.eq(now),
))
.execute(&mut conn)
.await;
match insert_result {
Ok(_) => {
match program_client::table
.filter(program_client::public_key.eq(&pubkey_bytes))
.order(program_client::id.desc())
.select((
program_client::id,
program_client::public_key,
program_client::created_at,
))
.first::<(i32, Vec<u8>, i32)>(&mut conn)
.await
{
Ok((id, pubkey, created_at)) => Ok(response(
UserAgentResponsePayload::SdkClientApprove(SdkClientApproveResponse {
result: Some(ApproveResult::Client(SdkClientEntry {
id,
pubkey,
created_at,
})),
}),
)),
Err(e) => {
error!(?e, "Failed to fetch inserted SDK client");
Ok(response(UserAgentResponsePayload::SdkClientApprove(
SdkClientApproveResponse {
result: Some(ApproveResult::Error(
ProtoSdkClientError::Internal.into(),
)),
},
)))
}
}
}
Err(diesel::result::Error::DatabaseError(
diesel::result::DatabaseErrorKind::UniqueViolation,
_,
)) => Ok(response(UserAgentResponsePayload::SdkClientApprove(
SdkClientApproveResponse {
result: Some(ApproveResult::Error(
ProtoSdkClientError::AlreadyExists.into(),
)),
},
))),
Err(e) => {
error!(?e, "Failed to insert SDK client");
Ok(response(UserAgentResponsePayload::SdkClientApprove(
SdkClientApproveResponse {
result: Some(ApproveResult::Error(ProtoSdkClientError::Internal.into())),
},
)))
}
}
}
async fn handle_sdk_client_list(&mut self) -> Output {
let mut conn = match self.props.db.get().await {
Ok(c) => c,
Err(e) => {
error!(?e, "Failed to get DB connection for sdk_client_list");
return Ok(response(UserAgentResponsePayload::SdkClientList(
SdkClientListResponse {
result: Some(sdk_client_list_response::Result::Error(
ProtoSdkClientError::Internal.into(),
)),
},
)));
}
};
match program_client::table
.select((
program_client::id,
program_client::public_key,
program_client::created_at,
))
.load::<(i32, Vec<u8>, i32)>(&mut conn)
.await
{
Ok(rows) => Ok(response(UserAgentResponsePayload::SdkClientList(
SdkClientListResponse {
result: Some(sdk_client_list_response::Result::Clients(SdkClientList {
clients: rows
.into_iter()
.map(|(id, pubkey, created_at)| SdkClientEntry {
id,
pubkey,
created_at,
})
.collect(),
})),
},
))),
Err(e) => {
error!(?e, "Failed to list SDK clients");
Ok(response(UserAgentResponsePayload::SdkClientList(
SdkClientListResponse {
result: Some(sdk_client_list_response::Result::Error(
ProtoSdkClientError::Internal.into(),
)),
},
)))
}
}
}
async fn handle_sdk_client_revoke(&mut self, req: SdkClientRevokeRequest) -> Output {
use sdk_client_revoke_response::Result as RevokeResult;
let mut conn = match self.props.db.get().await {
Ok(c) => c,
Err(e) => {
error!(?e, "Failed to get DB connection for sdk_client_revoke");
return Ok(response(UserAgentResponsePayload::SdkClientRevoke(
SdkClientRevokeResponse {
result: Some(RevokeResult::Error(ProtoSdkClientError::Internal.into())),
},
)));
}
};
match diesel::delete(program_client::table)
.filter(program_client::id.eq(req.client_id))
.execute(&mut conn)
.await
{
Ok(0) => Ok(response(UserAgentResponsePayload::SdkClientRevoke(
SdkClientRevokeResponse {
result: Some(RevokeResult::Error(ProtoSdkClientError::NotFound.into())),
},
))),
Ok(_) => Ok(response(UserAgentResponsePayload::SdkClientRevoke(
SdkClientRevokeResponse {
result: Some(RevokeResult::Ok(())),
},
))),
Err(diesel::result::Error::DatabaseError(
diesel::result::DatabaseErrorKind::ForeignKeyViolation,
_,
)) => Ok(response(UserAgentResponsePayload::SdkClientRevoke(
SdkClientRevokeResponse {
result: Some(RevokeResult::Error(
ProtoSdkClientError::HasRelatedData.into(),
)),
},
))),
Err(e) => {
error!(?e, "Failed to delete SDK client");
Ok(response(UserAgentResponsePayload::SdkClientRevoke(
SdkClientRevokeResponse {
result: Some(RevokeResult::Error(ProtoSdkClientError::Internal.into())),
},
)))
}
}
}
}
fn map_evm_error<M>(op: &str, err: SendError<M, crate::actors::evm::Error>) -> evm_proto::EvmError { 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}; use crate::actors::{evm::Error as EvmError, keyholder::Error as KhError};
match err { match err {

View File

@@ -79,7 +79,7 @@ fn client_auth_error_status(value: &client::auth::Error) -> Status {
Status::invalid_argument("Failed to convert pubkey to VerifyingKey") Status::invalid_argument("Failed to convert pubkey to VerifyingKey")
} }
Error::InvalidChallengeSolution => Status::unauthenticated(value.to_string()), Error::InvalidChallengeSolution => Status::unauthenticated(value.to_string()),
Error::NotRegistered => Status::permission_denied(value.to_string()), Error::ApproveError(_) => Status::permission_denied(value.to_string()),
Error::Transport => Status::internal("Transport error"), Error::Transport => Status::internal("Transport error"),
Error::DatabasePoolUnavailable => Status::internal("Database pool error"), Error::DatabasePoolUnavailable => Status::internal("Database pool error"),
Error::DatabaseOperationFailed => Status::internal("Database error"), Error::DatabaseOperationFailed => Status::internal("Database error"),

View File

@@ -3,6 +3,7 @@ use std::net::SocketAddr;
use arbiter_proto::{proto::arbiter_service_server::ArbiterServiceServer, url::ArbiterUrl}; use arbiter_proto::{proto::arbiter_service_server::ArbiterServiceServer, url::ArbiterUrl};
use arbiter_server::{Server, actors::bootstrap::GetToken, context::ServerContext, db}; use arbiter_server::{Server, actors::bootstrap::GetToken, context::ServerContext, db};
use miette::miette; use miette::miette;
use rustls::crypto::aws_lc_rs;
use tonic::transport::{Identity, ServerTlsConfig}; use tonic::transport::{Identity, ServerTlsConfig};
use tracing::info; use tracing::info;
@@ -10,6 +11,8 @@ const PORT: u16 = 50051;
#[tokio::main] #[tokio::main]
async fn main() -> miette::Result<()> { async fn main() -> miette::Result<()> {
aws_lc_rs::default_provider().install_default().unwrap();
tracing_subscriber::fmt() tracing_subscriber::fmt()
.with_env_filter( .with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env() tracing_subscriber::EnvFilter::try_from_default_env()

View File

@@ -1,15 +1,7 @@
use alloy::{ use arbiter_proto::proto::client::{
consensus::TxEip1559, AuthChallengeRequest, AuthChallengeSolution, ClientRequest,
primitives::{Address, Bytes, TxKind, U256}, client_request::Payload as ClientRequestPayload,
rlp::Encodable, client_response::Payload as ClientResponsePayload,
};
use arbiter_proto::proto::{
client::{
AuthChallengeRequest, AuthChallengeSolution, ClientRequest,
client_request::Payload as ClientRequestPayload,
client_response::Payload as ClientResponsePayload,
},
evm::EvmSignTransactionRequest,
}; };
use arbiter_proto::transport::Bi; use arbiter_proto::transport::Bi;
use arbiter_server::actors::GlobalActors; use arbiter_server::actors::GlobalActors;
@@ -117,106 +109,3 @@ pub async fn test_challenge_auth() {
// Auth completes, session spawned // Auth completes, session spawned
task.await.unwrap(); task.await.unwrap();
} }
#[tokio::test]
#[test_log::test]
pub async fn test_evm_sign_request_payload_is_handled() {
let db = db::create_test_pool().await;
let new_key = ed25519_dalek::SigningKey::generate(&mut rand::rng());
let pubkey_bytes = new_key.verifying_key().to_bytes().to_vec();
{
let mut conn = db.get().await.unwrap();
insert_into(schema::program_client::table)
.values(schema::program_client::public_key.eq(pubkey_bytes.clone()))
.execute(&mut conn)
.await
.unwrap();
}
let (server_transport, mut test_transport) = ChannelTransport::new();
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
let props = ClientConnection::new(db.clone(), Box::new(server_transport), actors);
let task = tokio::spawn(connect_client(props));
test_transport
.send(ClientRequest {
payload: Some(ClientRequestPayload::AuthChallengeRequest(
AuthChallengeRequest {
pubkey: pubkey_bytes,
},
)),
})
.await
.unwrap();
let response = test_transport
.recv()
.await
.expect("should receive challenge");
let challenge = match response {
Ok(resp) => match resp.payload {
Some(ClientResponsePayload::AuthChallenge(c)) => c,
other => panic!("Expected AuthChallenge, got {other:?}"),
},
Err(err) => panic!("Expected Ok response, got Err({err:?})"),
};
let formatted_challenge = arbiter_proto::format_challenge(challenge.nonce, &challenge.pubkey);
let signature = new_key.sign(&formatted_challenge);
test_transport
.send(ClientRequest {
payload: Some(ClientRequestPayload::AuthChallengeSolution(
AuthChallengeSolution {
signature: signature.to_bytes().to_vec(),
},
)),
})
.await
.unwrap();
task.await.unwrap();
let tx = TxEip1559 {
chain_id: 1,
nonce: 0,
gas_limit: 21_000,
max_fee_per_gas: 1,
max_priority_fee_per_gas: 1,
to: TxKind::Call(Address::from_slice(&[0x11; 20])),
value: U256::ZERO,
input: Bytes::new(),
access_list: Default::default(),
};
let mut rlp_transaction = Vec::new();
tx.encode(&mut rlp_transaction);
test_transport
.send(ClientRequest {
payload: Some(ClientRequestPayload::EvmSignTransaction(
EvmSignTransactionRequest {
wallet_address: [0x22; 20].to_vec(),
rlp_transaction,
},
)),
})
.await
.unwrap();
let response = test_transport
.recv()
.await
.expect("should receive sign response");
match response {
Ok(resp) => match resp.payload {
Some(ClientResponsePayload::EvmSignTransaction(_)) => {}
other => panic!("Expected EvmSignTransaction response, got {other:?}"),
},
Err(err) => panic!("Expected Ok response, got Err({err:?})"),
}
}

View File

@@ -2,7 +2,5 @@ mod common;
#[path = "user_agent/auth.rs"] #[path = "user_agent/auth.rs"]
mod auth; mod auth;
#[path = "user_agent/sdk_client.rs"]
mod sdk_client;
#[path = "user_agent/unseal.rs"] #[path = "user_agent/unseal.rs"]
mod unseal; mod unseal;

View File

@@ -1,270 +0,0 @@
use arbiter_proto::proto::user_agent::{
SdkClientApproveRequest, SdkClientError as ProtoSdkClientError, SdkClientRevokeRequest,
UserAgentRequest, sdk_client_approve_response, sdk_client_list_response,
sdk_client_revoke_response, user_agent_request::Payload as UserAgentRequestPayload,
user_agent_response::Payload as UserAgentResponsePayload,
};
use arbiter_server::{
actors::{GlobalActors, user_agent::session::UserAgentSession},
db,
};
/// Shared helper: create a session and register a client pubkey via sdk_client_approve.
async fn make_session(db: &db::DatabasePool) -> UserAgentSession {
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
UserAgentSession::new_test(db.clone(), actors)
}
#[tokio::test]
#[test_log::test]
async fn test_sdk_client_approve_registers_client() {
let db = db::create_test_pool().await;
let mut session = make_session(&db).await;
let pubkey = [0x42u8; 32];
let response = session
.process_transport_inbound(UserAgentRequest {
payload: Some(UserAgentRequestPayload::SdkClientApprove(
SdkClientApproveRequest {
pubkey: pubkey.to_vec(),
},
)),
})
.await
.expect("handler should succeed");
let entry = match response.payload.unwrap() {
UserAgentResponsePayload::SdkClientApprove(resp) => match resp.result.unwrap() {
sdk_client_approve_response::Result::Client(e) => e,
sdk_client_approve_response::Result::Error(e) => {
panic!("Expected Client, got error {:?}", e)
}
},
other => panic!("Expected SdkClientApprove, got {other:?}"),
};
assert_eq!(entry.pubkey, pubkey.to_vec());
assert!(entry.id > 0);
}
#[tokio::test]
#[test_log::test]
async fn test_sdk_client_approve_duplicate_returns_already_exists() {
let db = db::create_test_pool().await;
let mut session = make_session(&db).await;
let pubkey = [0x11u8; 32];
let req = UserAgentRequest {
payload: Some(UserAgentRequestPayload::SdkClientApprove(
SdkClientApproveRequest {
pubkey: pubkey.to_vec(),
},
)),
};
session
.process_transport_inbound(req.clone())
.await
.unwrap();
let response = session
.process_transport_inbound(req)
.await
.expect("second insert should not panic");
match response.payload.unwrap() {
UserAgentResponsePayload::SdkClientApprove(resp) => match resp.result.unwrap() {
sdk_client_approve_response::Result::Error(code) => {
assert_eq!(code, ProtoSdkClientError::AlreadyExists as i32);
}
sdk_client_approve_response::Result::Client(_) => {
panic!("Expected AlreadyExists error for duplicate pubkey")
}
},
other => panic!("Expected SdkClientApprove, got {other:?}"),
}
}
#[tokio::test]
#[test_log::test]
async fn test_sdk_client_list_shows_registered_clients() {
let db = db::create_test_pool().await;
let mut session = make_session(&db).await;
let pubkey_a = [0x0Au8; 32];
let pubkey_b = [0x0Bu8; 32];
for pubkey in [pubkey_a, pubkey_b] {
session
.process_transport_inbound(UserAgentRequest {
payload: Some(UserAgentRequestPayload::SdkClientApprove(
SdkClientApproveRequest {
pubkey: pubkey.to_vec(),
},
)),
})
.await
.unwrap();
}
let response = session
.process_transport_inbound(UserAgentRequest {
payload: Some(UserAgentRequestPayload::SdkClientList(())),
})
.await
.expect("list should succeed");
let clients = match response.payload.unwrap() {
UserAgentResponsePayload::SdkClientList(resp) => match resp.result.unwrap() {
sdk_client_list_response::Result::Clients(list) => list.clients,
sdk_client_list_response::Result::Error(e) => {
panic!("Expected Clients, got error {:?}", e)
}
},
other => panic!("Expected SdkClientList, got {other:?}"),
};
assert_eq!(clients.len(), 2);
let pubkeys: Vec<Vec<u8>> = clients.into_iter().map(|e| e.pubkey).collect();
assert!(pubkeys.contains(&pubkey_a.to_vec()));
assert!(pubkeys.contains(&pubkey_b.to_vec()));
}
#[tokio::test]
#[test_log::test]
async fn test_sdk_client_revoke_removes_client() {
let db = db::create_test_pool().await;
let mut session = make_session(&db).await;
let pubkey = [0xBBu8; 32];
// Register a client and get its id
let approve_response = session
.process_transport_inbound(UserAgentRequest {
payload: Some(UserAgentRequestPayload::SdkClientApprove(
SdkClientApproveRequest {
pubkey: pubkey.to_vec(),
},
)),
})
.await
.unwrap();
let client_id = match approve_response.payload.unwrap() {
UserAgentResponsePayload::SdkClientApprove(resp) => match resp.result.unwrap() {
sdk_client_approve_response::Result::Client(e) => e.id,
sdk_client_approve_response::Result::Error(e) => panic!("approve failed: {:?}", e),
},
other => panic!("{other:?}"),
};
// Revoke the client
let revoke_response = session
.process_transport_inbound(UserAgentRequest {
payload: Some(UserAgentRequestPayload::SdkClientRevoke(
SdkClientRevokeRequest { client_id },
)),
})
.await
.expect("revoke should succeed");
match revoke_response.payload.unwrap() {
UserAgentResponsePayload::SdkClientRevoke(resp) => match resp.result.unwrap() {
sdk_client_revoke_response::Result::Ok(_) => {}
sdk_client_revoke_response::Result::Error(e) => {
panic!("Expected Ok, got error {:?}", e)
}
},
other => panic!("Expected SdkClientRevoke, got {other:?}"),
}
// List should now be empty
let list_response = session
.process_transport_inbound(UserAgentRequest {
payload: Some(UserAgentRequestPayload::SdkClientList(())),
})
.await
.unwrap();
let clients = match list_response.payload.unwrap() {
UserAgentResponsePayload::SdkClientList(resp) => match resp.result.unwrap() {
sdk_client_list_response::Result::Clients(list) => list.clients,
sdk_client_list_response::Result::Error(e) => panic!("list error: {:?}", e),
},
other => panic!("{other:?}"),
};
assert!(clients.is_empty(), "client should be removed after revoke");
}
#[tokio::test]
#[test_log::test]
async fn test_sdk_client_revoke_not_found_returns_error() {
let db = db::create_test_pool().await;
let mut session = make_session(&db).await;
let response = session
.process_transport_inbound(UserAgentRequest {
payload: Some(UserAgentRequestPayload::SdkClientRevoke(
SdkClientRevokeRequest { client_id: 9999 },
)),
})
.await
.unwrap();
match response.payload.unwrap() {
UserAgentResponsePayload::SdkClientRevoke(resp) => match resp.result.unwrap() {
sdk_client_revoke_response::Result::Error(code) => {
assert_eq!(code, ProtoSdkClientError::NotFound as i32);
}
sdk_client_revoke_response::Result::Ok(_) => {
panic!("Expected NotFound error for missing client_id")
}
},
other => panic!("Expected SdkClientRevoke, got {other:?}"),
}
}
#[tokio::test]
#[test_log::test]
async fn test_sdk_client_approve_rejected_client_cannot_auth() {
// Verify the core flow: only pre-approved clients can authenticate
use arbiter_proto::proto::client::{
AuthChallengeRequest, ClientRequest, client_request::Payload as ClientRequestPayload,
client_response::Payload as ClientResponsePayload,
};
use arbiter_proto::transport::Bi as _;
use arbiter_server::actors::client::{ClientConnection, connect_client};
let db = db::create_test_pool().await;
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
let new_key = ed25519_dalek::SigningKey::generate(&mut rand::rng());
let pubkey_bytes = new_key.verifying_key().to_bytes().to_vec();
let (server_transport, mut test_transport) = super::common::ChannelTransport::<_, _>::new();
let props = ClientConnection::new(db.clone(), Box::new(server_transport), actors.clone());
let task = tokio::spawn(connect_client(props));
test_transport
.send(ClientRequest {
payload: Some(ClientRequestPayload::AuthChallengeRequest(
AuthChallengeRequest {
pubkey: pubkey_bytes.clone(),
},
)),
})
.await
.unwrap();
let response = test_transport.recv().await.unwrap().unwrap();
assert!(
matches!(
response.payload.unwrap(),
ClientResponsePayload::ClientConnectError(_)
),
"unregistered client should be rejected"
);
task.await.unwrap();
}

View File

@@ -1,7 +0,0 @@
[package]
name = "arbiter-terrors-poc"
version = "0.1.0"
edition = "2024"
[dependencies]
terrors = "0.3"

View File

@@ -1,139 +0,0 @@
use crate::errors::{InternalError1, InternalError2, InvalidSignature, NotRegistered};
use terrors::OneOf;
use crate::errors::ProtoError;
// Each sub-call's error type already implements DrainInto<ProtoError>, so we convert
// directly to ProtoError without broaden — no turbofish needed anywhere.
//
// Call chain:
// load_config() → OneOf<(InternalError2,)> → ProtoError::from
// get_nonce() → OneOf<(InternalError1, InternalError2)> → ProtoError::from
// verify_sig() → OneOf<(InvalidSignature,)> → ProtoError::from
pub fn process_request(id: u32, sig: &str) -> Result<String, ProtoError> {
if id == 0 {
return Err(ProtoError::NotRegistered);
}
let config = load_config(id).map_err(ProtoError::from)?;
let nonce = crate::db::get_nonce(id).map_err(ProtoError::from)?;
verify_signature(nonce, sig).map_err(ProtoError::from)?;
Ok(format!("config={config} nonce={nonce} sig={sig}"))
}
// Simulates loading a config value.
// id=97 triggers InternalError2 ("config read failed").
fn load_config(id: u32) -> Result<String, OneOf<(InternalError2,)>> {
if id == 97 {
return Err(OneOf::new(InternalError2("config read failed".to_owned())));
}
Ok(format!("cfg-{id}"))
}
pub fn verify_signature(_nonce: u32, sig: &str) -> Result<(), OneOf<(InvalidSignature,)>> {
if sig != "ok" {
return Err(OneOf::new(InvalidSignature));
}
Ok(())
}
type AuthError = OneOf<(
NotRegistered,
InvalidSignature,
InternalError1,
InternalError2,
)>;
pub fn authenticate(id: u32, sig: &str) -> Result<u32, AuthError> {
if id == 0 {
return Err(OneOf::new(NotRegistered));
}
// Return type AuthError lets the compiler infer the broaden target.
let nonce = crate::db::get_nonce(id).map_err(OneOf::broaden)?;
verify_signature(nonce, sig).map_err(OneOf::broaden)?;
Ok(nonce)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn verify_signature_ok() {
assert!(verify_signature(42, "ok").is_ok());
}
#[test]
fn verify_signature_bad() {
let err = verify_signature(42, "bad").unwrap_err();
assert!(err.narrow::<crate::errors::InvalidSignature, _>().is_ok());
}
#[test]
fn authenticate_success() {
assert_eq!(authenticate(1, "ok").unwrap(), 42);
}
#[test]
fn authenticate_not_registered() {
let err = authenticate(0, "ok").unwrap_err();
assert!(err.narrow::<crate::errors::NotRegistered, _>().is_ok());
}
#[test]
fn authenticate_invalid_signature() {
let err = authenticate(1, "bad").unwrap_err();
assert!(err.narrow::<crate::errors::InvalidSignature, _>().is_ok());
}
#[test]
fn authenticate_internal_error1() {
let err = authenticate(99, "ok").unwrap_err();
assert!(err.narrow::<crate::errors::InternalError1, _>().is_ok());
}
#[test]
fn authenticate_internal_error2() {
let err = authenticate(98, "ok").unwrap_err();
assert!(err.narrow::<crate::errors::InternalError2, _>().is_ok());
}
#[test]
fn process_request_success() {
let result = process_request(1, "ok").unwrap();
assert!(result.contains("nonce=42"));
}
#[test]
fn process_request_not_registered() {
let err = process_request(0, "ok").unwrap_err();
assert!(matches!(err, crate::errors::ProtoError::NotRegistered));
}
#[test]
fn process_request_invalid_signature() {
let err = process_request(1, "bad").unwrap_err();
assert!(matches!(err, crate::errors::ProtoError::InvalidSignature));
}
#[test]
fn process_request_internal_from_config() {
// id=97 → load_config returns InternalError2
let err = process_request(97, "ok").unwrap_err();
assert!(
matches!(err, crate::errors::ProtoError::Internal(ref msg) if msg == "config read failed")
);
}
#[test]
fn process_request_internal_from_db() {
// id=99 → get_nonce returns InternalError1
let err = process_request(99, "ok").unwrap_err();
assert!(
matches!(err, crate::errors::ProtoError::Internal(ref msg) if msg == "db pool unavailable")
);
}
}

View File

@@ -1,38 +0,0 @@
use crate::errors::{InternalError1, InternalError2};
use terrors::OneOf;
// Simulates fetching a nonce from a database.
// id=99 → InternalError1 (pool unavailable)
// id=98 → InternalError2 (query timeout)
pub fn get_nonce(id: u32) -> Result<u32, OneOf<(InternalError1, InternalError2)>> {
match id {
99 => Err(OneOf::new(InternalError1("db pool unavailable".to_owned()))),
98 => Err(OneOf::new(InternalError2("query timeout".to_owned()))),
_ => Ok(42),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn get_nonce_returns_nonce_for_valid_id() {
assert_eq!(get_nonce(1).unwrap(), 42);
}
#[test]
fn get_nonce_returns_internal_error1_for_sentinel() {
let err = get_nonce(99).unwrap_err();
let internal = err.narrow::<crate::errors::InternalError1, _>().unwrap();
assert_eq!(internal.0, "db pool unavailable");
}
#[test]
fn get_nonce_returns_internal_error2_for_sentinel() {
let err = get_nonce(98).unwrap_err();
let e = err.narrow::<crate::errors::InternalError1, _>().unwrap_err();
let internal = e.take::<crate::errors::InternalError2>();
assert_eq!(internal.0, "query timeout");
}
}

View File

@@ -1,130 +0,0 @@
use terrors::OneOf;
// Wire boundary type — what would go into a proto response
#[derive(Debug)]
pub enum ProtoError {
NotRegistered,
InvalidSignature,
Internal(String), // Or Box<dyn Error>, who cares?
}
// Internal terrors types
#[derive(Debug)]
pub struct NotRegistered;
#[derive(Debug)]
pub struct InvalidSignature;
#[derive(Debug)]
pub struct InternalError1(pub String);
#[derive(Debug)]
pub struct InternalError2(pub String);
// Errors can be scattered across the codebase as long as they implement Into<ProtoError>
impl From<NotRegistered> for ProtoError {
fn from(_: NotRegistered) -> Self {
ProtoError::NotRegistered
}
}
impl From<InvalidSignature> for ProtoError {
fn from(_: InvalidSignature) -> Self {
ProtoError::InvalidSignature
}
}
impl From<InternalError1> for ProtoError {
fn from(e: InternalError1) -> Self {
ProtoError::Internal(e.0)
}
}
impl From<InternalError2> for ProtoError {
fn from(e: InternalError2) -> Self {
ProtoError::Internal(e.0)
}
}
/// Private helper trait for converting from OneOf<T...> where each T can be converted
/// into the target type `O` by recursively narrowing until a match is found.
///
/// IDK why this isn't already in terrors.
trait DrainInto<O>: terrors::TypeSet + Sized {
fn drain(e: OneOf<Self>) -> O;
}
macro_rules! impl_drain_into {
($head:ident) => {
impl<$head, O> DrainInto<O> for ($head,)
where
$head: Into<O> + 'static,
{
fn drain(e: OneOf<($head,)>) -> O {
e.take().into()
}
}
};
($head:ident, $($tail:ident),+) => {
impl<$head, $($tail),+, O> DrainInto<O> for ($head, $($tail),+)
where
$head: Into<O> + 'static,
($($tail,)+): DrainInto<O>,
{
fn drain(e: OneOf<($head, $($tail),+)>) -> O {
match e.narrow::<$head, _>() {
Ok(h) => h.into(),
Err(rest) => <($($tail,)+)>::drain(rest),
}
}
}
impl_drain_into!($($tail),+);
};
}
// Generates impls for all tuple sizes from 1 up to 7 (restricted by terrors internal impl).
// Each invocation produces one impl then recurses on the tail.
impl_drain_into!(A, B, C, D, E, F, G, H, I);
// Blanket From impl: body delegates to the recursive drain.
impl<E: DrainInto<ProtoError>> From<OneOf<E>> for ProtoError {
fn from(e: OneOf<E>) -> Self {
E::drain(e)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn not_registered_converts_to_proto() {
let e: ProtoError = NotRegistered.into();
assert!(matches!(e, ProtoError::NotRegistered));
}
#[test]
fn invalid_signature_converts_to_proto() {
let e: ProtoError = InvalidSignature.into();
assert!(matches!(e, ProtoError::InvalidSignature));
}
#[test]
fn internal_converts_to_proto() {
let e: ProtoError = InternalError1("boom".into()).into();
assert!(matches!(e, ProtoError::Internal(msg) if msg == "boom"));
}
#[test]
fn one_of_remainder_converts_to_proto_invalid_signature() {
use terrors::OneOf;
let e: OneOf<(InvalidSignature, InternalError1)> = OneOf::new(InvalidSignature);
let proto = ProtoError::from(e);
assert!(matches!(proto, ProtoError::InvalidSignature));
}
#[test]
fn one_of_remainder_converts_to_proto_internal() {
use terrors::OneOf;
let e: OneOf<(InvalidSignature, InternalError1)> =
OneOf::new(InternalError1("db fail".into()));
let proto = ProtoError::from(e);
assert!(matches!(proto, ProtoError::Internal(msg) if msg == "db fail"));
}
}

View File

@@ -1,43 +0,0 @@
mod auth;
mod db;
mod errors;
use errors::ProtoError;
fn run(id: u32, sig: &str) {
print!("authenticate(id={id}, sig={sig:?}) => ");
match auth::authenticate(id, sig) {
Ok(nonce) => println!("Ok(nonce={nonce})"),
Err(e) => match e.narrow::<errors::NotRegistered, _>() {
Ok(_) => println!("Err(NotRegistered) — handled locally"),
Err(remaining) => {
let proto = ProtoError::from(remaining);
println!("Err(ProtoError::{proto:?}) — forwarded to wire");
}
},
}
}
fn run_process(id: u32, sig: &str) {
print!("process_request(id={id}, sig={sig:?}) => ");
match auth::process_request(id, sig) {
Ok(s) => println!("Ok({s})"),
Err(e) => println!("Err(ProtoError::{e:?})"),
}
}
fn main() {
println!("=== authenticate ===");
run(0, "ok"); // NotRegistered
run(1, "bad"); // InvalidSignature
run(99, "ok"); // InternalError1
run(98, "ok"); // InternalError2
run(1, "ok"); // success
println!("\n=== process_request (Try chain) ===");
run_process(0, "ok"); // NotRegistered (guard, no I/O)
run_process(97, "ok"); // InternalError2 from load_config
run_process(99, "ok"); // InternalError1 from get_nonce
run_process(1, "bad"); // InvalidSignature from verify_signature
run_process(1, "ok"); // success
}