Compare commits
4 Commits
e135519c06
...
terrors-re
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
efb11d2271 | ||
| 2148faa376 | |||
|
|
eb37ee0a0c | ||
|
|
1f07fd6a98 |
6
server/Cargo.lock
generated
6
server/Cargo.lock
generated
@@ -686,6 +686,7 @@ dependencies = [
|
||||
"http",
|
||||
"rand 0.10.0",
|
||||
"rustls-webpki",
|
||||
"terrors",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
@@ -4908,6 +4909,11 @@ dependencies = [
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "terrors"
|
||||
version = "0.5.1"
|
||||
source = "git+https://github.com/CleverWild/terrors#a0867fd9ca3fbb44c32e92113a917f1577b5716a"
|
||||
|
||||
[[package]]
|
||||
name = "test-log"
|
||||
version = "0.2.19"
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
[workspace]
|
||||
members = [
|
||||
"crates/*",
|
||||
]
|
||||
members = ["crates/*"]
|
||||
resolver = "3"
|
||||
|
||||
[workspace.lints.clippy]
|
||||
@@ -43,3 +41,4 @@ k256 = { version = "0.13.4", features = ["ecdsa", "pkcs8"] }
|
||||
rsa = { version = "0.9", features = ["sha2"] }
|
||||
sha2 = "0.10"
|
||||
spki = "0.7"
|
||||
terrors = { version = "0.5", git = "https://github.com/CleverWild/terrors" }
|
||||
|
||||
@@ -8,9 +8,12 @@ license = "Apache-2.0"
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
evm = ["dep:alloy"]
|
||||
|
||||
[dependencies]
|
||||
arbiter-proto.path = "../arbiter-proto"
|
||||
alloy.workspace = true
|
||||
alloy = { workspace = true, optional = true }
|
||||
tonic.workspace = true
|
||||
tonic.features = ["tls-aws-lc"]
|
||||
tokio.workspace = true
|
||||
@@ -21,3 +24,4 @@ http = "1.4.0"
|
||||
rustls-webpki = { version = "0.103.10", features = ["aws-lc-rs"] }
|
||||
async-trait.workspace = true
|
||||
rand.workspace = true
|
||||
terrors.workspace = true
|
||||
103
server/crates/arbiter-client/src/auth.rs
Normal file
103
server/crates/arbiter-client/src/auth.rs
Normal file
@@ -0,0 +1,103 @@
|
||||
use arbiter_proto::{
|
||||
format_challenge,
|
||||
proto::client::{
|
||||
AuthChallengeRequest, AuthChallengeSolution, AuthResult, ClientRequest,
|
||||
client_request::Payload as ClientRequestPayload,
|
||||
client_response::Payload as ClientResponsePayload,
|
||||
},
|
||||
};
|
||||
use ed25519_dalek::Signer as _;
|
||||
use terrors::OneOf;
|
||||
|
||||
use crate::{
|
||||
errors::{
|
||||
ConnectError, MissingAuthChallengeError, UnexpectedAuthResponseError, map_auth_code_error,
|
||||
},
|
||||
transport::{ClientTransport, next_request_id},
|
||||
};
|
||||
|
||||
async fn send_auth_challenge_request(
|
||||
transport: &mut ClientTransport,
|
||||
key: &ed25519_dalek::SigningKey,
|
||||
) -> std::result::Result<(), ConnectError> {
|
||||
transport
|
||||
.send(ClientRequest {
|
||||
request_id: next_request_id(),
|
||||
payload: Some(ClientRequestPayload::AuthChallengeRequest(
|
||||
AuthChallengeRequest {
|
||||
pubkey: key.verifying_key().to_bytes().to_vec(),
|
||||
},
|
||||
)),
|
||||
})
|
||||
.await
|
||||
.map_err(|_| OneOf::new(UnexpectedAuthResponseError))
|
||||
}
|
||||
|
||||
async fn receive_auth_challenge(
|
||||
transport: &mut ClientTransport,
|
||||
) -> std::result::Result<arbiter_proto::proto::client::AuthChallenge, ConnectError> {
|
||||
let response = transport
|
||||
.recv()
|
||||
.await
|
||||
.map_err(|_| OneOf::new(MissingAuthChallengeError))?;
|
||||
|
||||
let payload = response
|
||||
.payload
|
||||
.ok_or_else(|| OneOf::new(MissingAuthChallengeError))?;
|
||||
match payload {
|
||||
ClientResponsePayload::AuthChallenge(challenge) => Ok(challenge),
|
||||
ClientResponsePayload::AuthResult(result) => Err(map_auth_code_error(result)),
|
||||
_ => Err(OneOf::new(UnexpectedAuthResponseError)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_auth_challenge_solution(
|
||||
transport: &mut ClientTransport,
|
||||
key: &ed25519_dalek::SigningKey,
|
||||
challenge: arbiter_proto::proto::client::AuthChallenge,
|
||||
) -> std::result::Result<(), ConnectError> {
|
||||
let challenge_payload = format_challenge(challenge.nonce, &challenge.pubkey);
|
||||
let signature = key.sign(&challenge_payload).to_bytes().to_vec();
|
||||
|
||||
transport
|
||||
.send(ClientRequest {
|
||||
request_id: next_request_id(),
|
||||
payload: Some(ClientRequestPayload::AuthChallengeSolution(
|
||||
AuthChallengeSolution { signature },
|
||||
)),
|
||||
})
|
||||
.await
|
||||
.map_err(|_| OneOf::new(UnexpectedAuthResponseError))
|
||||
}
|
||||
|
||||
async fn receive_auth_confirmation(
|
||||
transport: &mut ClientTransport,
|
||||
) -> std::result::Result<(), ConnectError> {
|
||||
let response = transport
|
||||
.recv()
|
||||
.await
|
||||
.map_err(|_| OneOf::new(UnexpectedAuthResponseError))?;
|
||||
|
||||
let payload = response
|
||||
.payload
|
||||
.ok_or_else(|| OneOf::new(UnexpectedAuthResponseError))?;
|
||||
match payload {
|
||||
ClientResponsePayload::AuthResult(result)
|
||||
if AuthResult::try_from(result).ok() == Some(AuthResult::Success) =>
|
||||
{
|
||||
Ok(())
|
||||
}
|
||||
ClientResponsePayload::AuthResult(result) => Err(map_auth_code_error(result)),
|
||||
_ => Err(OneOf::new(UnexpectedAuthResponseError)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn authenticate(
|
||||
transport: &mut ClientTransport,
|
||||
key: &ed25519_dalek::SigningKey,
|
||||
) -> std::result::Result<(), ConnectError> {
|
||||
send_auth_challenge_request(transport, key).await?;
|
||||
let challenge = receive_auth_challenge(transport).await?;
|
||||
send_auth_challenge_solution(transport, key, challenge).await?;
|
||||
receive_auth_confirmation(transport).await
|
||||
}
|
||||
82
server/crates/arbiter-client/src/client.rs
Normal file
82
server/crates/arbiter-client/src/client.rs
Normal file
@@ -0,0 +1,82 @@
|
||||
use arbiter_proto::{proto::arbiter_service_client::ArbiterServiceClient, url::ArbiterUrl};
|
||||
use std::sync::Arc;
|
||||
use terrors::{Broaden as _, OneOf};
|
||||
use tokio::sync::{Mutex, mpsc};
|
||||
use tokio_stream::wrappers::ReceiverStream;
|
||||
use tonic::transport::ClientTlsConfig;
|
||||
|
||||
use crate::{
|
||||
auth::authenticate,
|
||||
errors::ConnectError,
|
||||
storage::{FileSigningKeyStorage, SigningKeyStorage},
|
||||
transport::{BUFFER_LENGTH, ClientTransport},
|
||||
};
|
||||
|
||||
#[cfg(feature = "evm")]
|
||||
use crate::errors::{ClientConnectionClosedError, ClientError};
|
||||
|
||||
#[cfg(feature = "evm")]
|
||||
use crate::wallets::evm::ArbiterEvmWallet;
|
||||
|
||||
pub struct ArbiterClient {
|
||||
#[allow(dead_code)]
|
||||
transport: Arc<Mutex<ClientTransport>>,
|
||||
}
|
||||
|
||||
impl ArbiterClient {
|
||||
pub async fn connect(url: ArbiterUrl) -> Result<Self, ConnectError> {
|
||||
let storage = FileSigningKeyStorage::from_default_location().broaden()?;
|
||||
Self::connect_with_storage(url, &storage).await
|
||||
}
|
||||
|
||||
pub async fn connect_with_storage<S: SigningKeyStorage>(
|
||||
url: ArbiterUrl,
|
||||
storage: &S,
|
||||
) -> Result<Self, ConnectError> {
|
||||
let key = storage.load_or_create().broaden()?;
|
||||
Self::connect_with_key(url, key).await
|
||||
}
|
||||
|
||||
pub async fn connect_with_key(
|
||||
url: ArbiterUrl,
|
||||
key: ed25519_dalek::SigningKey,
|
||||
) -> Result<Self, ConnectError> {
|
||||
let anchor = webpki::anchor_from_trusted_cert(&url.ca_cert)
|
||||
.map_err(OneOf::new)?
|
||||
.to_owned();
|
||||
let tls = ClientTlsConfig::new().trust_anchor(anchor);
|
||||
|
||||
let channel = tonic::transport::Channel::from_shared(format!("{}:{}", url.host, url.port))
|
||||
.map_err(OneOf::new)?
|
||||
.tls_config(tls)
|
||||
.map_err(OneOf::new)?
|
||||
.connect()
|
||||
.await
|
||||
.map_err(OneOf::new)?;
|
||||
|
||||
let mut client = ArbiterServiceClient::new(channel);
|
||||
let (tx, rx) = mpsc::channel(BUFFER_LENGTH);
|
||||
let response_stream = client
|
||||
.client(ReceiverStream::new(rx))
|
||||
.await
|
||||
.map_err(OneOf::new)?
|
||||
.into_inner();
|
||||
|
||||
let mut transport = ClientTransport {
|
||||
sender: tx,
|
||||
receiver: response_stream,
|
||||
};
|
||||
|
||||
authenticate(&mut transport, &key).await?;
|
||||
|
||||
Ok(Self {
|
||||
transport: Arc::new(Mutex::new(transport)),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(feature = "evm")]
|
||||
pub async fn evm_wallets(&self) -> Result<Vec<ArbiterEvmWallet>, ClientError> {
|
||||
let _ = &self.transport;
|
||||
Err(OneOf::new(ClientConnectionClosedError))
|
||||
}
|
||||
}
|
||||
127
server/crates/arbiter-client/src/errors.rs
Normal file
127
server/crates/arbiter-client/src/errors.rs
Normal file
@@ -0,0 +1,127 @@
|
||||
use terrors::OneOf;
|
||||
use thiserror::Error;
|
||||
|
||||
#[cfg(feature = "evm")]
|
||||
use alloy::{primitives::ChainId, signers::Error as AlloySignerError};
|
||||
|
||||
pub type StorageError = OneOf<(std::io::Error, InvalidKeyLengthError)>;
|
||||
|
||||
pub type ConnectError = OneOf<(
|
||||
tonic::transport::Error,
|
||||
http::uri::InvalidUri,
|
||||
webpki::Error,
|
||||
tonic::Status,
|
||||
MissingAuthChallengeError,
|
||||
ApprovalDeniedError,
|
||||
NoUserAgentsOnlineError,
|
||||
UnexpectedAuthResponseError,
|
||||
std::io::Error,
|
||||
InvalidKeyLengthError,
|
||||
)>;
|
||||
|
||||
pub type ClientError = OneOf<(tonic::Status, ClientConnectionClosedError)>;
|
||||
|
||||
pub(crate) type ClientTransportError =
|
||||
OneOf<(TransportChannelClosedError, TransportConnectionClosedError)>;
|
||||
|
||||
#[cfg(feature = "evm")]
|
||||
pub(crate) type EvmWalletError = OneOf<(
|
||||
EvmChainIdMismatchError,
|
||||
EvmHashSigningUnsupportedError,
|
||||
EvmTransactionSigningUnsupportedError,
|
||||
)>;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
|
||||
#[error("Invalid signing key length in storage: expected {expected} bytes, got {actual} bytes")]
|
||||
pub struct InvalidKeyLengthError {
|
||||
pub expected: usize,
|
||||
pub actual: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
|
||||
#[error("Auth challenge was not returned by server")]
|
||||
pub struct MissingAuthChallengeError;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
|
||||
#[error("Client approval denied by User Agent")]
|
||||
pub struct ApprovalDeniedError;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
|
||||
#[error("No User Agents online to approve client")]
|
||||
pub struct NoUserAgentsOnlineError;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
|
||||
#[error("Unexpected auth response payload")]
|
||||
pub struct UnexpectedAuthResponseError;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
|
||||
#[error("Connection closed by server")]
|
||||
pub struct ClientConnectionClosedError;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
|
||||
#[error("Transport channel closed")]
|
||||
pub struct TransportChannelClosedError;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
|
||||
#[error("Connection closed by server")]
|
||||
pub struct TransportConnectionClosedError;
|
||||
|
||||
#[cfg(feature = "evm")]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
|
||||
#[error("Transaction chain id mismatch: signer {signer}, tx {tx}")]
|
||||
pub struct EvmChainIdMismatchError {
|
||||
pub signer: ChainId,
|
||||
pub tx: ChainId,
|
||||
}
|
||||
|
||||
#[cfg(feature = "evm")]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
|
||||
#[error("hash-only signing is not supported for ArbiterEvmWallet; use transaction signing")]
|
||||
pub struct EvmHashSigningUnsupportedError;
|
||||
|
||||
#[cfg(feature = "evm")]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
|
||||
#[error("transaction signing is not supported by current arbiter.client protocol")]
|
||||
pub struct EvmTransactionSigningUnsupportedError;
|
||||
|
||||
pub(crate) fn map_auth_code_error(code: i32) -> ConnectError {
|
||||
use arbiter_proto::proto::client::AuthResult;
|
||||
|
||||
match AuthResult::try_from(code).unwrap_or(AuthResult::Unspecified) {
|
||||
AuthResult::ApprovalDenied => OneOf::new(ApprovalDeniedError),
|
||||
AuthResult::NoUserAgentsOnline => OneOf::new(NoUserAgentsOnlineError),
|
||||
AuthResult::Unspecified
|
||||
| AuthResult::Success
|
||||
| AuthResult::InvalidKey
|
||||
| AuthResult::InvalidSignature
|
||||
| AuthResult::Internal => OneOf::new(UnexpectedAuthResponseError),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "evm")]
|
||||
impl From<EvmChainIdMismatchError> for AlloySignerError {
|
||||
fn from(value: EvmChainIdMismatchError) -> Self {
|
||||
AlloySignerError::TransactionChainIdMismatch {
|
||||
signer: value.signer,
|
||||
tx: value.tx,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "evm")]
|
||||
impl From<EvmHashSigningUnsupportedError> for AlloySignerError {
|
||||
fn from(_value: EvmHashSigningUnsupportedError) -> Self {
|
||||
AlloySignerError::other(
|
||||
"hash-only signing is not supported for ArbiterEvmWallet; use transaction signing",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "evm")]
|
||||
impl From<EvmTransactionSigningUnsupportedError> for AlloySignerError {
|
||||
fn from(_value: EvmTransactionSigningUnsupportedError) -> Self {
|
||||
AlloySignerError::other(
|
||||
"transaction signing is not supported by current arbiter.client protocol",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,455 +1,13 @@
|
||||
use alloy::{
|
||||
consensus::SignableTransaction,
|
||||
network::TxSigner,
|
||||
primitives::{Address, B256, ChainId, Signature},
|
||||
signers::{Error, Result, Signer},
|
||||
};
|
||||
use arbiter_proto::{
|
||||
format_challenge, home_path,
|
||||
proto::{
|
||||
arbiter_service_client::ArbiterServiceClient,
|
||||
client::{
|
||||
AuthChallengeRequest, AuthChallengeSolution, AuthResult, ClientRequest, ClientResponse,
|
||||
client_request::Payload as ClientRequestPayload,
|
||||
client_response::Payload as ClientResponsePayload,
|
||||
},
|
||||
},
|
||||
url::ArbiterUrl,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use ed25519_dalek::Signer as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicI32, Ordering};
|
||||
use tokio::sync::{Mutex, mpsc};
|
||||
use tokio_stream::wrappers::ReceiverStream;
|
||||
use tonic::transport::ClientTlsConfig;
|
||||
|
||||
const BUFFER_LENGTH: usize = 16;
|
||||
static NEXT_REQUEST_ID: AtomicI32 = AtomicI32::new(1);
|
||||
|
||||
fn next_request_id() -> i32 {
|
||||
NEXT_REQUEST_ID.fetch_add(1, Ordering::Relaxed)
|
||||
}
|
||||
|
||||
#[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,
|
||||
|
||||
#[error("Signing key storage error")]
|
||||
Storage(#[from] StorageError),
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum StorageError {
|
||||
#[error("I/O error")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("Invalid signing key length in storage: expected {expected} bytes, got {actual} bytes")]
|
||||
InvalidKeyLength { expected: usize, actual: usize },
|
||||
}
|
||||
|
||||
pub trait SigningKeyStorage {
|
||||
fn load_or_create(&self) -> std::result::Result<ed25519_dalek::SigningKey, StorageError>;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileSigningKeyStorage {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl FileSigningKeyStorage {
|
||||
pub const DEFAULT_FILE_NAME: &str = "sdk_client_ed25519.key";
|
||||
|
||||
pub fn new(path: impl Into<PathBuf>) -> Self {
|
||||
Self { path: path.into() }
|
||||
}
|
||||
|
||||
pub fn from_default_location() -> std::result::Result<Self, StorageError> {
|
||||
Ok(Self::new(home_path()?.join(Self::DEFAULT_FILE_NAME)))
|
||||
}
|
||||
|
||||
fn read_key(path: &Path) -> std::result::Result<ed25519_dalek::SigningKey, StorageError> {
|
||||
let bytes = std::fs::read(path)?;
|
||||
let raw: [u8; 32] =
|
||||
bytes
|
||||
.try_into()
|
||||
.map_err(|v: Vec<u8>| StorageError::InvalidKeyLength {
|
||||
expected: 32,
|
||||
actual: v.len(),
|
||||
})?;
|
||||
Ok(ed25519_dalek::SigningKey::from_bytes(&raw))
|
||||
}
|
||||
}
|
||||
|
||||
impl SigningKeyStorage for FileSigningKeyStorage {
|
||||
fn load_or_create(&self) -> std::result::Result<ed25519_dalek::SigningKey, StorageError> {
|
||||
if let Some(parent) = self.path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
if self.path.exists() {
|
||||
return Self::read_key(&self.path);
|
||||
}
|
||||
|
||||
let key = ed25519_dalek::SigningKey::generate(&mut rand::rng());
|
||||
let raw_key = key.to_bytes();
|
||||
|
||||
// Use create_new to prevent accidental overwrite if another process creates the key first.
|
||||
match std::fs::OpenOptions::new()
|
||||
.create_new(true)
|
||||
.write(true)
|
||||
.open(&self.path)
|
||||
{
|
||||
Ok(mut file) => {
|
||||
use std::io::Write as _;
|
||||
file.write_all(&raw_key)?;
|
||||
Ok(key)
|
||||
}
|
||||
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||
Self::read_key(&self.path)
|
||||
}
|
||||
Err(err) => Err(StorageError::Io(err)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
enum ClientSignError {
|
||||
#[error("Transport channel closed")]
|
||||
ChannelClosed,
|
||||
|
||||
#[error("Connection closed by server")]
|
||||
ConnectionClosed,
|
||||
|
||||
#[error("Wallet address is not configured")]
|
||||
WalletAddressNotConfigured,
|
||||
}
|
||||
|
||||
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: Option<Address>,
|
||||
chain_id: Option<ChainId>,
|
||||
}
|
||||
|
||||
impl ArbiterSigner {
|
||||
pub async fn connect_grpc(url: ArbiterUrl) -> std::result::Result<Self, ConnectError> {
|
||||
let storage = FileSigningKeyStorage::from_default_location()?;
|
||||
Self::connect_grpc_with_storage(url, &storage).await
|
||||
}
|
||||
|
||||
pub async fn connect_grpc_with_storage<S: SigningKeyStorage>(
|
||||
url: ArbiterUrl,
|
||||
storage: &S,
|
||||
) -> std::result::Result<Self, ConnectError> {
|
||||
let key = storage.load_or_create()?;
|
||||
Self::connect_grpc_with_key(url, key).await
|
||||
}
|
||||
|
||||
pub async fn connect_grpc_with_key(
|
||||
url: ArbiterUrl,
|
||||
key: ed25519_dalek::SigningKey,
|
||||
) -> 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(BUFFER_LENGTH);
|
||||
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: None,
|
||||
chain_id: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn wallet_address(&self) -> Option<Address> {
|
||||
self.address
|
||||
}
|
||||
|
||||
pub fn set_wallet_address(&mut self, address: Option<Address>) {
|
||||
self.address = address;
|
||||
}
|
||||
|
||||
pub fn with_wallet_address(mut self, address: Address) -> Self {
|
||||
self.address = Some(address);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_chain_id(mut self, chain_id: ChainId) -> Self {
|
||||
self.chain_id = Some(chain_id);
|
||||
self
|
||||
}
|
||||
|
||||
fn validate_chain_id(&self, tx: &mut dyn SignableTransaction<Signature>) -> Result<()> {
|
||||
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(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_wallet_address(&self) -> Result<Address> {
|
||||
let wallet_address = self
|
||||
.address
|
||||
.ok_or_else(|| Error::other(ClientSignError::WalletAddressNotConfigured))?;
|
||||
|
||||
Ok(wallet_address)
|
||||
}
|
||||
}
|
||||
|
||||
fn map_auth_result(code: i32) -> ConnectError {
|
||||
match AuthResult::try_from(code).unwrap_or(AuthResult::Unspecified) {
|
||||
AuthResult::ApprovalDenied => ConnectError::ApprovalDenied,
|
||||
AuthResult::NoUserAgentsOnline => ConnectError::NoUserAgentsOnline,
|
||||
AuthResult::Unspecified
|
||||
| AuthResult::Success
|
||||
| AuthResult::InvalidKey
|
||||
| AuthResult::InvalidSignature
|
||||
| AuthResult::Internal => ConnectError::UnexpectedAuthResponse,
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_auth_challenge_request(
|
||||
transport: &mut ClientTransport,
|
||||
key: &ed25519_dalek::SigningKey,
|
||||
) -> std::result::Result<(), ConnectError> {
|
||||
transport
|
||||
.send(ClientRequest {
|
||||
request_id: next_request_id(),
|
||||
payload: Some(ClientRequestPayload::AuthChallengeRequest(
|
||||
AuthChallengeRequest {
|
||||
pubkey: key.verifying_key().to_bytes().to_vec(),
|
||||
},
|
||||
)),
|
||||
})
|
||||
.await
|
||||
.map_err(|_| ConnectError::UnexpectedAuthResponse)
|
||||
}
|
||||
|
||||
async fn receive_auth_challenge(
|
||||
transport: &mut ClientTransport,
|
||||
) -> std::result::Result<arbiter_proto::proto::client::AuthChallenge, ConnectError> {
|
||||
let response = transport
|
||||
.recv()
|
||||
.await
|
||||
.map_err(|_| ConnectError::MissingAuthChallenge)?;
|
||||
|
||||
let payload = response.payload.ok_or(ConnectError::MissingAuthChallenge)?;
|
||||
match payload {
|
||||
ClientResponsePayload::AuthChallenge(challenge) => Ok(challenge),
|
||||
ClientResponsePayload::AuthResult(result) => Err(map_auth_result(result)),
|
||||
_ => Err(ConnectError::UnexpectedAuthResponse),
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_auth_challenge_solution(
|
||||
transport: &mut ClientTransport,
|
||||
key: &ed25519_dalek::SigningKey,
|
||||
challenge: arbiter_proto::proto::client::AuthChallenge,
|
||||
) -> std::result::Result<(), ConnectError> {
|
||||
let challenge_payload = format_challenge(challenge.nonce, &challenge.pubkey);
|
||||
let signature = key.sign(&challenge_payload).to_bytes().to_vec();
|
||||
|
||||
transport
|
||||
.send(ClientRequest {
|
||||
request_id: next_request_id(),
|
||||
payload: Some(ClientRequestPayload::AuthChallengeSolution(
|
||||
AuthChallengeSolution { signature },
|
||||
)),
|
||||
})
|
||||
.await
|
||||
.map_err(|_| ConnectError::UnexpectedAuthResponse)
|
||||
}
|
||||
|
||||
async fn receive_auth_confirmation(
|
||||
transport: &mut ClientTransport,
|
||||
) -> std::result::Result<(), ConnectError> {
|
||||
let response = transport
|
||||
.recv()
|
||||
.await
|
||||
.map_err(|_| ConnectError::UnexpectedAuthResponse)?;
|
||||
|
||||
let payload = response
|
||||
.payload
|
||||
.ok_or(ConnectError::UnexpectedAuthResponse)?;
|
||||
match payload {
|
||||
ClientResponsePayload::AuthResult(result)
|
||||
if AuthResult::try_from(result).ok() == Some(AuthResult::Success) =>
|
||||
{
|
||||
Ok(())
|
||||
}
|
||||
ClientResponsePayload::AuthResult(result) => Err(map_auth_result(result)),
|
||||
_ => Err(ConnectError::UnexpectedAuthResponse),
|
||||
}
|
||||
}
|
||||
|
||||
async fn authenticate(
|
||||
transport: &mut ClientTransport,
|
||||
key: &ed25519_dalek::SigningKey,
|
||||
) -> std::result::Result<(), ConnectError> {
|
||||
send_auth_challenge_request(transport, key).await?;
|
||||
let challenge = receive_auth_challenge(transport).await?;
|
||||
send_auth_challenge_solution(transport, key, challenge).await?;
|
||||
receive_auth_confirmation(transport).await
|
||||
}
|
||||
|
||||
#[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.unwrap_or(Address::ZERO)
|
||||
}
|
||||
|
||||
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.unwrap_or(Address::ZERO)
|
||||
}
|
||||
|
||||
async fn sign_transaction(
|
||||
&self,
|
||||
tx: &mut dyn SignableTransaction<Signature>,
|
||||
) -> Result<Signature> {
|
||||
let _transport = self.transport.lock().await;
|
||||
self.validate_chain_id(tx)?;
|
||||
let _ = self.ensure_wallet_address()?;
|
||||
|
||||
Err(Error::other(
|
||||
"transaction signing is not supported by current arbiter.client protocol",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{FileSigningKeyStorage, SigningKeyStorage, StorageError};
|
||||
|
||||
fn unique_temp_key_path() -> std::path::PathBuf {
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.expect("clock should be after unix epoch")
|
||||
.as_nanos();
|
||||
std::env::temp_dir().join(format!(
|
||||
"arbiter-client-key-{}-{}.bin",
|
||||
std::process::id(),
|
||||
nanos
|
||||
))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_storage_creates_and_reuses_key() {
|
||||
let path = unique_temp_key_path();
|
||||
let storage = FileSigningKeyStorage::new(path.clone());
|
||||
|
||||
let key_a = storage
|
||||
.load_or_create()
|
||||
.expect("first load_or_create should create key");
|
||||
let key_b = storage
|
||||
.load_or_create()
|
||||
.expect("second load_or_create should read same key");
|
||||
|
||||
assert_eq!(key_a.to_bytes(), key_b.to_bytes());
|
||||
assert!(path.exists());
|
||||
|
||||
std::fs::remove_file(path).expect("temp key file should be removable");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_storage_rejects_invalid_key_length() {
|
||||
let path = unique_temp_key_path();
|
||||
std::fs::write(&path, [42u8; 31]).expect("should write invalid key file");
|
||||
let storage = FileSigningKeyStorage::new(path.clone());
|
||||
|
||||
let err = storage
|
||||
.load_or_create()
|
||||
.expect_err("storage should reject non-32-byte key file");
|
||||
|
||||
match err {
|
||||
StorageError::InvalidKeyLength { expected, actual } => {
|
||||
assert_eq!(expected, 32);
|
||||
assert_eq!(actual, 31);
|
||||
}
|
||||
other => panic!("unexpected error: {other:?}"),
|
||||
}
|
||||
|
||||
std::fs::remove_file(path).expect("temp key file should be removable");
|
||||
}
|
||||
}
|
||||
mod auth;
|
||||
mod client;
|
||||
mod errors;
|
||||
mod storage;
|
||||
mod transport;
|
||||
pub mod wallets;
|
||||
|
||||
pub use client::ArbiterClient;
|
||||
pub use errors::{ClientError, ConnectError, StorageError};
|
||||
pub use storage::{FileSigningKeyStorage, SigningKeyStorage};
|
||||
|
||||
#[cfg(feature = "evm")]
|
||||
pub use wallets::evm::ArbiterEvmWallet;
|
||||
|
||||
130
server/crates/arbiter-client/src/storage.rs
Normal file
130
server/crates/arbiter-client/src/storage.rs
Normal file
@@ -0,0 +1,130 @@
|
||||
use arbiter_proto::home_path;
|
||||
use std::path::{Path, PathBuf};
|
||||
use terrors::OneOf;
|
||||
|
||||
use crate::errors::{InvalidKeyLengthError, StorageError};
|
||||
|
||||
pub trait SigningKeyStorage {
|
||||
fn load_or_create(&self) -> std::result::Result<ed25519_dalek::SigningKey, StorageError>;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileSigningKeyStorage {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl FileSigningKeyStorage {
|
||||
pub const DEFAULT_FILE_NAME: &str = "sdk_client_ed25519.key";
|
||||
|
||||
pub fn new(path: impl Into<PathBuf>) -> Self {
|
||||
Self { path: path.into() }
|
||||
}
|
||||
|
||||
pub fn from_default_location() -> std::result::Result<Self, StorageError> {
|
||||
Ok(Self::new(
|
||||
home_path()
|
||||
.map_err(OneOf::new)?
|
||||
.join(Self::DEFAULT_FILE_NAME),
|
||||
))
|
||||
}
|
||||
|
||||
fn read_key(path: &Path) -> std::result::Result<ed25519_dalek::SigningKey, StorageError> {
|
||||
let bytes = std::fs::read(path).map_err(OneOf::new)?;
|
||||
let raw: [u8; 32] = bytes.try_into().map_err(|v: Vec<u8>| {
|
||||
OneOf::new(InvalidKeyLengthError {
|
||||
expected: 32,
|
||||
actual: v.len(),
|
||||
})
|
||||
})?;
|
||||
Ok(ed25519_dalek::SigningKey::from_bytes(&raw))
|
||||
}
|
||||
}
|
||||
|
||||
impl SigningKeyStorage for FileSigningKeyStorage {
|
||||
fn load_or_create(&self) -> std::result::Result<ed25519_dalek::SigningKey, StorageError> {
|
||||
if let Some(parent) = self.path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(OneOf::new)?;
|
||||
}
|
||||
|
||||
if self.path.exists() {
|
||||
return Self::read_key(&self.path);
|
||||
}
|
||||
|
||||
let key = ed25519_dalek::SigningKey::generate(&mut rand::rng());
|
||||
let raw_key = key.to_bytes();
|
||||
|
||||
// Use create_new to prevent accidental overwrite if another process creates the key first.
|
||||
match std::fs::OpenOptions::new()
|
||||
.create_new(true)
|
||||
.write(true)
|
||||
.open(&self.path)
|
||||
{
|
||||
Ok(mut file) => {
|
||||
use std::io::Write as _;
|
||||
file.write_all(&raw_key).map_err(OneOf::new)?;
|
||||
Ok(key)
|
||||
}
|
||||
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||
Self::read_key(&self.path)
|
||||
}
|
||||
Err(err) => Err(OneOf::new(err)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{FileSigningKeyStorage, SigningKeyStorage};
|
||||
use crate::errors::InvalidKeyLengthError;
|
||||
|
||||
fn unique_temp_key_path() -> std::path::PathBuf {
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.expect("clock should be after unix epoch")
|
||||
.as_nanos();
|
||||
std::env::temp_dir().join(format!(
|
||||
"arbiter-client-key-{}-{}.bin",
|
||||
std::process::id(),
|
||||
nanos
|
||||
))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_storage_creates_and_reuses_key() {
|
||||
let path = unique_temp_key_path();
|
||||
let storage = FileSigningKeyStorage::new(path.clone());
|
||||
|
||||
let key_a = storage
|
||||
.load_or_create()
|
||||
.expect("first load_or_create should create key");
|
||||
let key_b = storage
|
||||
.load_or_create()
|
||||
.expect("second load_or_create should read same key");
|
||||
|
||||
assert_eq!(key_a.to_bytes(), key_b.to_bytes());
|
||||
assert!(path.exists());
|
||||
|
||||
std::fs::remove_file(path).expect("temp key file should be removable");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_storage_rejects_invalid_key_length() {
|
||||
let path = unique_temp_key_path();
|
||||
std::fs::write(&path, [42u8; 31]).expect("should write invalid key file");
|
||||
let storage = FileSigningKeyStorage::new(path.clone());
|
||||
|
||||
let err = storage
|
||||
.load_or_create()
|
||||
.expect_err("storage should reject non-32-byte key file");
|
||||
|
||||
match err.narrow::<InvalidKeyLengthError, _>() {
|
||||
Ok(invalid_len) => {
|
||||
assert_eq!(invalid_len.expected, 32);
|
||||
assert_eq!(invalid_len.actual, 31);
|
||||
}
|
||||
Err(other) => panic!("unexpected io error: {other:?}"),
|
||||
}
|
||||
|
||||
std::fs::remove_file(path).expect("temp key file should be removable");
|
||||
}
|
||||
}
|
||||
42
server/crates/arbiter-client/src/transport.rs
Normal file
42
server/crates/arbiter-client/src/transport.rs
Normal file
@@ -0,0 +1,42 @@
|
||||
use arbiter_proto::proto::client::{ClientRequest, ClientResponse};
|
||||
use std::sync::atomic::{AtomicI32, Ordering};
|
||||
use terrors::OneOf;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use crate::errors::{
|
||||
ClientTransportError, TransportChannelClosedError, TransportConnectionClosedError,
|
||||
};
|
||||
|
||||
pub(crate) const BUFFER_LENGTH: usize = 16;
|
||||
static NEXT_REQUEST_ID: AtomicI32 = AtomicI32::new(1);
|
||||
|
||||
pub(crate) fn next_request_id() -> i32 {
|
||||
NEXT_REQUEST_ID.fetch_add(1, Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub(crate) struct ClientTransport {
|
||||
pub(crate) sender: mpsc::Sender<ClientRequest>,
|
||||
pub(crate) receiver: tonic::Streaming<ClientResponse>,
|
||||
}
|
||||
|
||||
impl ClientTransport {
|
||||
pub(crate) async fn send(
|
||||
&mut self,
|
||||
request: ClientRequest,
|
||||
) -> std::result::Result<(), ClientTransportError> {
|
||||
self.sender
|
||||
.send(request)
|
||||
.await
|
||||
.map_err(|_| OneOf::new(TransportChannelClosedError))
|
||||
}
|
||||
|
||||
pub(crate) async fn recv(
|
||||
&mut self,
|
||||
) -> std::result::Result<ClientResponse, ClientTransportError> {
|
||||
match self.receiver.message().await {
|
||||
Ok(Some(resp)) => Ok(resp),
|
||||
Ok(None) => Err(OneOf::new(TransportConnectionClosedError)),
|
||||
Err(_) => Err(OneOf::new(TransportConnectionClosedError)),
|
||||
}
|
||||
}
|
||||
}
|
||||
97
server/crates/arbiter-client/src/wallets/evm.rs
Normal file
97
server/crates/arbiter-client/src/wallets/evm.rs
Normal file
@@ -0,0 +1,97 @@
|
||||
use alloy::{
|
||||
consensus::SignableTransaction,
|
||||
network::TxSigner,
|
||||
primitives::{Address, B256, ChainId, Signature},
|
||||
signers::{Result, Signer},
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use std::sync::Arc;
|
||||
use terrors::OneOf;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::{
|
||||
errors::{
|
||||
EvmChainIdMismatchError, EvmHashSigningUnsupportedError,
|
||||
EvmTransactionSigningUnsupportedError, EvmWalletError,
|
||||
},
|
||||
transport::ClientTransport,
|
||||
};
|
||||
|
||||
pub struct ArbiterEvmWallet {
|
||||
transport: Arc<Mutex<ClientTransport>>,
|
||||
address: Address,
|
||||
chain_id: Option<ChainId>,
|
||||
}
|
||||
|
||||
impl ArbiterEvmWallet {
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn new(transport: Arc<Mutex<ClientTransport>>, address: Address) -> Self {
|
||||
Self {
|
||||
transport,
|
||||
address,
|
||||
chain_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn address(&self) -> Address {
|
||||
self.address
|
||||
}
|
||||
|
||||
pub fn with_chain_id(mut self, chain_id: ChainId) -> Self {
|
||||
self.chain_id = Some(chain_id);
|
||||
self
|
||||
}
|
||||
|
||||
fn validate_chain_id(
|
||||
&self,
|
||||
tx: &mut dyn SignableTransaction<Signature>,
|
||||
) -> std::result::Result<(), EvmWalletError> {
|
||||
if let Some(chain_id) = self.chain_id
|
||||
&& !tx.set_chain_id_checked(chain_id)
|
||||
{
|
||||
return Err(OneOf::new(EvmChainIdMismatchError {
|
||||
signer: chain_id,
|
||||
tx: tx.chain_id().unwrap(),
|
||||
}));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Signer for ArbiterEvmWallet {
|
||||
async fn sign_hash(&self, _hash: &B256) -> Result<Signature> {
|
||||
Err(EvmWalletError::new(EvmHashSigningUnsupportedError).into())
|
||||
}
|
||||
|
||||
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 ArbiterEvmWallet {
|
||||
fn address(&self) -> Address {
|
||||
self.address
|
||||
}
|
||||
|
||||
async fn sign_transaction(
|
||||
&self,
|
||||
tx: &mut dyn SignableTransaction<Signature>,
|
||||
) -> Result<Signature> {
|
||||
let _transport = self.transport.lock().await;
|
||||
self.validate_chain_id(tx)
|
||||
.map_err(OneOf::into::<alloy::signers::Error>)?;
|
||||
|
||||
Err(EvmWalletError::new(EvmTransactionSigningUnsupportedError).into())
|
||||
}
|
||||
}
|
||||
2
server/crates/arbiter-client/src/wallets/mod.rs
Normal file
2
server/crates/arbiter-client/src/wallets/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
#[cfg(feature = "evm")]
|
||||
pub mod evm;
|
||||
Reference in New Issue
Block a user