9 Commits

Author SHA1 Message Date
CleverWild
784261f4d8 perf(user-agent): use sqlite INSERT ... RETURNING for sdk client approve
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline was successful
ci/woodpecker/pr/server-test Pipeline was successful
2026-03-19 19:07:28 +01:00
CleverWild
971db0e919 refactor(client-auth): introduce ClientId newtype to avoid client_id/nonce confusion
refactor(user-agent): replace manual terminality helper with fatality::Fatality
2026-03-19 19:07:19 +01:00
CleverWild
e1a8553142 feat(client-auth): emit and require AuthOk for SDK client challenge flow 2026-03-19 19:06:27 +01:00
CleverWild
ec70561c93 refactor(arbiter-client): split auth handshake into check/do steps and simplify TxSigner signing flow 2026-03-19 19:05:56 +01:00
CleverWild
3993d3a8cc refactor(client): decouple grpc connect from wallet address and add explicit wallet configuration 2026-03-19 18:21:09 +01:00
CleverWild
c87456ae2f feat(client): add file-backed signing key storage with transparent first-run key creation 2026-03-19 18:10:43 +01:00
CleverWild
e89983de3a refactor(proto): align remaining ClientConnection protobuf pairs with SdkClient* naming 2026-03-19 18:00:10 +01:00
CleverWild
f56668d9f6 chore: make const for buffer size 2026-03-19 17:54:31 +01:00
CleverWild
434738bae5 fix: return very important comment 2026-03-19 17:52:11 +01:00
11 changed files with 460 additions and 143 deletions

View File

@@ -106,15 +106,15 @@ enum VaultState {
VAULT_STATE_ERROR = 4; VAULT_STATE_ERROR = 4;
} }
message ClientConnectionRequest { message SdkClientConnectionRequest {
bytes pubkey = 1; bytes pubkey = 1;
} }
message ClientConnectionResponse { message SdkClientConnectionResponse {
bool approved = 1; bool approved = 1;
} }
message ClientConnectionCancel {} message SdkClientConnectionCancel {}
message UserAgentRequest { message UserAgentRequest {
oneof payload { oneof payload {
@@ -128,7 +128,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;
ClientConnectionResponse client_connection_response = 11; SdkClientConnectionResponse sdk_client_connection_response = 11;
SdkClientApproveRequest sdk_client_approve = 12; SdkClientApproveRequest sdk_client_approve = 12;
SdkClientRevokeRequest sdk_client_revoke = 13; SdkClientRevokeRequest sdk_client_revoke = 13;
google.protobuf.Empty sdk_client_list = 14; google.protobuf.Empty sdk_client_list = 14;
@@ -146,8 +146,8 @@ 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;
ClientConnectionRequest client_connection_request = 11; SdkClientConnectionRequest sdk_client_connection_request = 11;
ClientConnectionCancel client_connection_cancel = 12; SdkClientConnectionCancel sdk_client_connection_cancel = 12;
SdkClientApproveResponse sdk_client_approve = 13; SdkClientApproveResponse sdk_client_approve = 13;
SdkClientRevokeResponse sdk_client_revoke = 14; SdkClientRevokeResponse sdk_client_revoke = 14;
SdkClientListResponse sdk_client_list = 15; SdkClientListResponse sdk_client_list = 15;

139
server/Cargo.lock generated
View File

@@ -100,7 +100,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"serde_with", "serde_with",
"thiserror", "thiserror 2.0.18",
] ]
[[package]] [[package]]
@@ -136,7 +136,7 @@ dependencies = [
"futures", "futures",
"futures-util", "futures-util",
"serde_json", "serde_json",
"thiserror", "thiserror 2.0.18",
] ]
[[package]] [[package]]
@@ -178,7 +178,7 @@ dependencies = [
"alloy-rlp", "alloy-rlp",
"crc", "crc",
"serde", "serde",
"thiserror", "thiserror 2.0.18",
] ]
[[package]] [[package]]
@@ -203,7 +203,7 @@ dependencies = [
"alloy-rlp", "alloy-rlp",
"borsh", "borsh",
"serde", "serde",
"thiserror", "thiserror 2.0.18",
] ]
[[package]] [[package]]
@@ -239,7 +239,7 @@ dependencies = [
"serde", "serde",
"serde_with", "serde_with",
"sha2 0.10.9", "sha2 0.10.9",
"thiserror", "thiserror 2.0.18",
] ]
[[package]] [[package]]
@@ -280,7 +280,7 @@ dependencies = [
"http", "http",
"serde", "serde",
"serde_json", "serde_json",
"thiserror", "thiserror 2.0.18",
"tracing", "tracing",
] ]
@@ -307,7 +307,7 @@ dependencies = [
"futures-utils-wasm", "futures-utils-wasm",
"serde", "serde",
"serde_json", "serde_json",
"thiserror", "thiserror 2.0.18",
] ]
[[package]] [[package]]
@@ -382,7 +382,7 @@ dependencies = [
"reqwest", "reqwest",
"serde", "serde",
"serde_json", "serde_json",
"thiserror", "thiserror 2.0.18",
"tokio", "tokio",
"tracing", "tracing",
"url", "url",
@@ -475,7 +475,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"serde_with", "serde_with",
"thiserror", "thiserror 2.0.18",
] ]
[[package]] [[package]]
@@ -501,7 +501,7 @@ dependencies = [
"either", "either",
"elliptic-curve", "elliptic-curve",
"k256", "k256",
"thiserror", "thiserror 2.0.18",
] ]
[[package]] [[package]]
@@ -517,7 +517,7 @@ dependencies = [
"async-trait", "async-trait",
"k256", "k256",
"rand 0.8.5", "rand 0.8.5",
"thiserror", "thiserror 2.0.18",
] ]
[[package]] [[package]]
@@ -608,7 +608,7 @@ dependencies = [
"parking_lot", "parking_lot",
"serde", "serde",
"serde_json", "serde_json",
"thiserror", "thiserror 2.0.18",
"tokio", "tokio",
"tower", "tower",
"tracing", "tracing",
@@ -644,7 +644,7 @@ dependencies = [
"nybbles", "nybbles",
"serde", "serde",
"smallvec", "smallvec",
"thiserror", "thiserror 2.0.18",
"tracing", "tracing",
] ]
@@ -684,8 +684,9 @@ dependencies = [
"async-trait", "async-trait",
"ed25519-dalek", "ed25519-dalek",
"http", "http",
"rand 0.10.0",
"rustls-webpki", "rustls-webpki",
"thiserror", "thiserror 2.0.18",
"tokio", "tokio",
"tokio-stream", "tokio-stream",
"tonic", "tonic",
@@ -708,7 +709,7 @@ dependencies = [
"rcgen", "rcgen",
"rstest", "rstest",
"rustls-pki-types", "rustls-pki-types",
"thiserror", "thiserror 2.0.18",
"tokio", "tokio",
"tonic", "tonic",
"tonic-prost", "tonic-prost",
@@ -733,6 +734,7 @@ dependencies = [
"diesel-async", "diesel-async",
"diesel_migrations", "diesel_migrations",
"ed25519-dalek", "ed25519-dalek",
"fatality",
"futures", "futures",
"insta", "insta",
"k256", "k256",
@@ -751,7 +753,7 @@ dependencies = [
"spki", "spki",
"strum", "strum",
"test-log", "test-log",
"thiserror", "thiserror 2.0.18",
"tokio", "tokio",
"tokio-stream", "tokio-stream",
"tonic", "tonic",
@@ -761,13 +763,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"
@@ -791,7 +786,7 @@ dependencies = [
"sha2 0.10.9", "sha2 0.10.9",
"smlang", "smlang",
"spki", "spki",
"thiserror", "thiserror 2.0.18",
"tokio", "tokio",
"tokio-stream", "tokio-stream",
"tonic", "tonic",
@@ -1019,7 +1014,7 @@ dependencies = [
"nom", "nom",
"num-traits", "num-traits",
"rusticata-macros", "rusticata-macros",
"thiserror", "thiserror 2.0.18",
"time", "time",
] ]
@@ -2079,6 +2074,21 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "expander"
version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2c470c71d91ecbd179935b24170459e926382eaaa86b590b78814e180d8a8e2"
dependencies = [
"blake2",
"file-guard",
"fs-err",
"prettyplease",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]] [[package]]
name = "fastrand" name = "fastrand"
version = "2.3.0" version = "2.3.0"
@@ -2107,6 +2117,30 @@ dependencies = [
"bytes", "bytes",
] ]
[[package]]
name = "fatality"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec6f82451ff7f0568c6181287189126d492b5654e30a788add08027b6363d019"
dependencies = [
"fatality-proc-macro",
"thiserror 1.0.69",
]
[[package]]
name = "fatality-proc-macro"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb42427514b063d97ce21d5199f36c0c307d981434a6be32582bc79fe5bd2303"
dependencies = [
"expander",
"indexmap 2.13.0",
"proc-macro-crate",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]] [[package]]
name = "ff" name = "ff"
version = "0.13.1" version = "0.13.1"
@@ -2129,6 +2163,16 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24"
[[package]]
name = "file-guard"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21ef72acf95ec3d7dbf61275be556299490a245f017cf084bd23b4f68cf9407c"
dependencies = [
"libc",
"winapi",
]
[[package]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.9" version = "0.1.9"
@@ -2190,6 +2234,15 @@ dependencies = [
"percent-encoding", "percent-encoding",
] ]
[[package]]
name = "fs-err"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88a41f105fe1d5b6b34b2055e3dc59bb79b46b48b2040b9e6c7b4b5de097aa41"
dependencies = [
"autocfg",
]
[[package]] [[package]]
name = "fs_extra" name = "fs_extra"
version = "1.3.0" version = "1.3.0"
@@ -3784,7 +3837,7 @@ dependencies = [
"rustc-hash", "rustc-hash",
"rustls", "rustls",
"socket2", "socket2",
"thiserror", "thiserror 2.0.18",
"tokio", "tokio",
"tracing", "tracing",
"web-time", "web-time",
@@ -3805,7 +3858,7 @@ dependencies = [
"rustls", "rustls",
"rustls-pki-types", "rustls-pki-types",
"slab", "slab",
"thiserror", "thiserror 2.0.18",
"tinyvec", "tinyvec",
"tracing", "tracing",
"web-time", "web-time",
@@ -4140,7 +4193,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8a1f2315036ef6b1fbacd1972e8ee7688030b0a2121edfc2a6550febd41574d" checksum = "a8a1f2315036ef6b1fbacd1972e8ee7688030b0a2121edfc2a6550febd41574d"
dependencies = [ dependencies = [
"hashbrown 0.16.1", "hashbrown 0.16.1",
"thiserror", "thiserror 2.0.18",
] ]
[[package]] [[package]]
@@ -4863,12 +4916,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"
@@ -4900,13 +4947,33 @@ dependencies = [
"unicode-width 0.2.2", "unicode-width 0.2.2",
] ]
[[package]]
name = "thiserror"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl 1.0.69",
]
[[package]] [[package]]
name = "thiserror" name = "thiserror"
version = "2.0.18" version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [ dependencies = [
"thiserror-impl", "thiserror-impl 2.0.18",
]
[[package]]
name = "thiserror-impl"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
] ]
[[package]] [[package]]
@@ -6000,7 +6067,7 @@ dependencies = [
"nom", "nom",
"oid-registry", "oid-registry",
"rusticata-macros", "rusticata-macros",
"thiserror", "thiserror 2.0.18",
"time", "time",
] ]

View File

@@ -20,3 +20,4 @@ thiserror.workspace = true
http = "1.4.0" http = "1.4.0"
rustls-webpki = { version = "0.103.9", features = ["aws-lc-rs"] } rustls-webpki = { version = "0.103.9", features = ["aws-lc-rs"] }
async-trait.workspace = true async-trait.workspace = true
rand.workspace = true

View File

@@ -5,7 +5,7 @@ use alloy::{
signers::{Error, Result, Signer}, signers::{Error, Result, Signer},
}; };
use arbiter_proto::{ use arbiter_proto::{
format_challenge, format_challenge, home_path,
proto::{ proto::{
arbiter_service_client::ArbiterServiceClient, arbiter_service_client::ArbiterServiceClient,
client::{ client::{
@@ -21,10 +21,13 @@ use arbiter_proto::{
}; };
use async_trait::async_trait; use async_trait::async_trait;
use ed25519_dalek::Signer as _; use ed25519_dalek::Signer as _;
use std::path::{Path, PathBuf};
use tokio::sync::{Mutex, mpsc}; use tokio::sync::{Mutex, mpsc};
use tokio_stream::wrappers::ReceiverStream; use tokio_stream::wrappers::ReceiverStream;
use tonic::transport::ClientTlsConfig; use tonic::transport::ClientTlsConfig;
const BUFFER_LENGTH: usize = 16;
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum ConnectError { pub enum ConnectError {
#[error("Could not establish connection")] #[error("Could not establish connection")]
@@ -50,6 +53,83 @@ pub enum ConnectError {
#[error("Unexpected auth response payload")] #[error("Unexpected auth response payload")]
UnexpectedAuthResponse, 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)] #[derive(Debug, thiserror::Error)]
@@ -65,6 +145,9 @@ enum ClientSignError {
#[error("Remote signing was rejected")] #[error("Remote signing was rejected")]
Rejected, Rejected,
#[error("Wallet address is not configured")]
WalletAddressNotConfigured,
} }
struct ClientTransport { struct ClientTransport {
@@ -91,15 +174,27 @@ impl ClientTransport {
pub struct ArbiterSigner { pub struct ArbiterSigner {
transport: Mutex<ClientTransport>, transport: Mutex<ClientTransport>,
address: Address, address: Option<Address>,
chain_id: Option<ChainId>, chain_id: Option<ChainId>,
} }
impl ArbiterSigner { impl ArbiterSigner {
pub async fn connect_grpc( 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, url: ArbiterUrl,
key: ed25519_dalek::SigningKey, key: ed25519_dalek::SigningKey,
address: Address,
) -> std::result::Result<Self, ConnectError> { ) -> std::result::Result<Self, ConnectError> {
let anchor = webpki::anchor_from_trusted_cert(&url.ca_cert)?.to_owned(); let anchor = webpki::anchor_from_trusted_cert(&url.ca_cert)?.to_owned();
let tls = ClientTlsConfig::new().trust_anchor(anchor); let tls = ClientTlsConfig::new().trust_anchor(anchor);
@@ -112,7 +207,7 @@ impl ArbiterSigner {
.await?; .await?;
let mut client = ArbiterServiceClient::new(channel); let mut client = ArbiterServiceClient::new(channel);
let (tx, rx) = mpsc::channel(16); let (tx, rx) = mpsc::channel(BUFFER_LENGTH);
let response_stream = client.client(ReceiverStream::new(rx)).await?.into_inner(); let response_stream = client.client(ReceiverStream::new(rx)).await?.into_inner();
let mut transport = ClientTransport { let mut transport = ClientTransport {
@@ -120,19 +215,37 @@ impl ArbiterSigner {
receiver: response_stream, receiver: response_stream,
}; };
authenticate(&mut transport, key).await?; authenticate(&mut transport, &key).await?;
Ok(Self { Ok(Self {
transport: Mutex::new(transport), transport: Mutex::new(transport),
address, address: None,
chain_id: None, chain_id: None,
}) })
} }
async fn sign_transaction_via_arbiter( 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 build_sign_transaction_request(
&self, &self,
tx: &mut dyn SignableTransaction<Signature>, tx: &mut dyn SignableTransaction<Signature>,
) -> Result<Signature> { ) -> Result<ClientRequest> {
if let Some(chain_id) = self.chain_id if let Some(chain_id) = self.chain_id
&& !tx.set_chain_id_checked(chain_id) && !tx.set_chain_id_checked(chain_id)
{ {
@@ -145,15 +258,21 @@ impl ArbiterSigner {
let mut rlp_transaction = Vec::new(); let mut rlp_transaction = Vec::new();
tx.encode_for_signing(&mut rlp_transaction); tx.encode_for_signing(&mut rlp_transaction);
let request = ClientRequest { let wallet_address = self
.address
.ok_or_else(|| Error::other(ClientSignError::WalletAddressNotConfigured))?;
Ok(ClientRequest {
payload: Some(ClientRequestPayload::EvmSignTransaction( payload: Some(ClientRequestPayload::EvmSignTransaction(
EvmSignTransactionRequest { EvmSignTransactionRequest {
wallet_address: self.address.as_slice().to_vec(), wallet_address: wallet_address.as_slice().to_vec(),
rlp_transaction, rlp_transaction,
}, },
)), )),
}; })
}
async fn execute_sign_transaction_request(&self, request: ClientRequest) -> Result<Signature> {
let mut transport = self.transport.lock().await; let mut transport = self.transport.lock().await;
transport.send(request).await.map_err(Error::other)?; transport.send(request).await.map_err(Error::other)?;
let response = transport.recv().await.map_err(Error::other)?; let response = transport.recv().await.map_err(Error::other)?;
@@ -181,9 +300,18 @@ impl ArbiterSigner {
} }
} }
async fn authenticate( fn map_connect_error(code: i32) -> ConnectError {
match client_connect_error::Code::try_from(code).unwrap_or(client_connect_error::Code::Unknown)
{
client_connect_error::Code::ApprovalDenied => ConnectError::ApprovalDenied,
client_connect_error::Code::NoUserAgentsOnline => ConnectError::NoUserAgentsOnline,
client_connect_error::Code::Unknown => ConnectError::UnexpectedAuthResponse,
}
}
async fn send_auth_challenge_request(
transport: &mut ClientTransport, transport: &mut ClientTransport,
key: ed25519_dalek::SigningKey, key: &ed25519_dalek::SigningKey,
) -> std::result::Result<(), ConnectError> { ) -> std::result::Result<(), ConnectError> {
transport transport
.send(ClientRequest { .send(ClientRequest {
@@ -194,8 +322,12 @@ async fn authenticate(
)), )),
}) })
.await .await
.map_err(|_| ConnectError::UnexpectedAuthResponse)?; .map_err(|_| ConnectError::UnexpectedAuthResponse)
}
async fn receive_auth_challenge(
transport: &mut ClientTransport,
) -> std::result::Result<arbiter_proto::proto::client::AuthChallenge, ConnectError> {
let response = transport let response = transport
.recv() .recv()
.await .await
@@ -203,39 +335,58 @@ async fn authenticate(
let payload = response.payload.ok_or(ConnectError::MissingAuthChallenge)?; let payload = response.payload.ok_or(ConnectError::MissingAuthChallenge)?;
match payload { match payload {
ClientResponsePayload::AuthChallenge(challenge) => { ClientResponsePayload::AuthChallenge(challenge) => Ok(challenge),
let challenge_payload = format_challenge(challenge.nonce, &challenge.pubkey); ClientResponsePayload::ClientConnectError(err) => Err(map_connect_error(err.code)),
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), _ => 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 {
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::AuthOk(_) => Ok(()),
ClientResponsePayload::ClientConnectError(err) => Err(map_connect_error(err.code)),
_ => 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] #[async_trait]
impl Signer for ArbiterSigner { impl Signer for ArbiterSigner {
async fn sign_hash(&self, _hash: &B256) -> Result<Signature> { async fn sign_hash(&self, _hash: &B256) -> Result<Signature> {
@@ -245,7 +396,7 @@ impl Signer for ArbiterSigner {
} }
fn address(&self) -> Address { fn address(&self) -> Address {
self.address self.address.unwrap_or(Address::ZERO)
} }
fn chain_id(&self) -> Option<ChainId> { fn chain_id(&self) -> Option<ChainId> {
@@ -260,13 +411,70 @@ impl Signer for ArbiterSigner {
#[async_trait] #[async_trait]
impl TxSigner<Signature> for ArbiterSigner { impl TxSigner<Signature> for ArbiterSigner {
fn address(&self) -> Address { fn address(&self) -> Address {
self.address self.address.unwrap_or(Address::ZERO)
} }
async fn sign_transaction( async fn sign_transaction(
&self, &self,
tx: &mut dyn SignableTransaction<Signature>, tx: &mut dyn SignableTransaction<Signature>,
) -> Result<Signature> { ) -> Result<Signature> {
self.sign_transaction_via_arbiter(tx).await let request = self.build_sign_transaction_request(tx)?;
self.execute_sign_transaction_request(request).await
}
}
#[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");
} }
} }

View File

@@ -27,6 +27,7 @@ rustls.workspace = true
smlang.workspace = true smlang.workspace = true
miette.workspace = true miette.workspace = true
thiserror.workspace = true thiserror.workspace = true
fatality = "0.1.1"
diesel_migrations = { version = "2.3.1", features = ["sqlite"] } diesel_migrations = { version = "2.3.1", features = ["sqlite"] }
async-trait.workspace = true async-trait.workspace = true
secrecy = "0.10.3" secrecy = "0.10.3"

View File

@@ -1,8 +1,8 @@
use arbiter_proto::{ use arbiter_proto::{
format_challenge, format_challenge,
proto::client::{ proto::client::{
AuthChallenge, AuthChallengeSolution, ClientConnectError, ClientRequest, ClientResponse, AuthChallenge, AuthChallengeSolution, AuthOk, ClientConnectError, ClientRequest,
client_connect_error::Code as ConnectErrorCode, ClientResponse, client_connect_error::Code as ConnectErrorCode,
client_request::Payload as ClientRequestPayload, client_request::Payload as ClientRequestPayload,
client_response::Payload as ClientResponsePayload, client_response::Payload as ClientResponsePayload,
}, },
@@ -26,6 +26,25 @@ use crate::{
use super::session::ClientSession; use super::session::ClientSession;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ClientId(i32);
impl ClientId {
pub fn new(raw: i32) -> Self {
Self(raw)
}
pub fn as_i32(self) -> i32 {
self.0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct ClientNonceState {
client_id: ClientId,
nonce: i32,
}
#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)] #[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
pub enum Error { pub enum Error {
#[error("Unexpected message payload")] #[error("Unexpected message payload")]
@@ -63,7 +82,7 @@ pub enum ApproveError {
async fn get_nonce( async fn get_nonce(
db: &db::DatabasePool, db: &db::DatabasePool,
pubkey: &VerifyingKey, pubkey: &VerifyingKey,
) -> Result<Option<(i32, i32)>, Error> { ) -> Result<Option<ClientNonceState>, 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| {
@@ -90,7 +109,10 @@ async fn get_nonce(
.execute(conn) .execute(conn)
.await?; .await?;
Ok(Some((client_id, current_nonce))) Ok(Some(ClientNonceState {
client_id: ClientId::new(client_id),
nonce: current_nonce,
}))
}) })
}) })
.await .await
@@ -126,7 +148,7 @@ async fn approve_new_client(
} }
enum InsertClientResult { enum InsertClientResult {
Inserted(i32), Inserted(ClientId),
AlreadyExists, AlreadyExists,
} }
@@ -176,7 +198,7 @@ async fn insert_client(
Error::DatabaseOperationFailed Error::DatabaseOperationFailed
})?; })?;
Ok(InsertClientResult::Inserted(client_id)) Ok(InsertClientResult::Inserted(ClientId::new(client_id)))
} }
async fn challenge_client( async fn challenge_client(
@@ -224,6 +246,17 @@ async fn challenge_client(
Error::InvalidChallengeSolution Error::InvalidChallengeSolution
})?; })?;
props
.transport
.send(Ok(ClientResponse {
payload: Some(ClientResponsePayload::AuthOk(AuthOk {})),
}))
.await
.map_err(|e| {
error!(error = ?e, "Failed to send auth ok");
Error::Transport
})?;
Ok(()) Ok(())
} }
@@ -237,7 +270,7 @@ fn connect_error_code(err: &Error) -> ConnectErrorCode {
} }
} }
async fn authenticate(props: &mut ClientConnection) -> Result<(VerifyingKey, i32), Error> { async fn authenticate(props: &mut ClientConnection) -> Result<(VerifyingKey, ClientId), 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
@@ -253,13 +286,13 @@ async fn authenticate(props: &mut ClientConnection) -> Result<(VerifyingKey, i32
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 (client_id, nonce) = match get_nonce(&props.db, &pubkey).await? {
Some((client_id, nonce)) => (client_id, nonce), Some(state) => (state.client_id, state.nonce),
None => { None => {
approve_new_client(&props.actors, pubkey).await?; approve_new_client(&props.actors, pubkey).await?;
match insert_client(&props.db, &pubkey).await? { match insert_client(&props.db, &pubkey).await? {
InsertClientResult::Inserted(client_id) => (client_id, 0), InsertClientResult::Inserted(client_id) => (client_id, 0),
InsertClientResult::AlreadyExists => match get_nonce(&props.db, &pubkey).await? { InsertClientResult::AlreadyExists => match get_nonce(&props.db, &pubkey).await? {
Some((client_id, nonce)) => (client_id, nonce), Some(state) => (state.client_id, state.nonce),
None => return Err(Error::InternalError), None => return Err(Error::InternalError),
}, },
} }

View File

@@ -15,7 +15,7 @@ use tracing::{error, info};
use crate::{ use crate::{
actors::{ actors::{
GlobalActors, GlobalActors,
client::{ClientConnection, ClientError}, client::{ClientConnection, ClientError, auth::ClientId},
evm::ClientSignTransaction, evm::ClientSignTransaction,
router::RegisterClient, router::RegisterClient,
}, },
@@ -24,11 +24,11 @@ use crate::{
pub struct ClientSession { pub struct ClientSession {
props: ClientConnection, props: ClientConnection,
client_id: i32, client_id: ClientId,
} }
impl ClientSession { impl ClientSession {
pub(crate) fn new(props: ClientConnection, client_id: i32) -> Self { pub(crate) fn new(props: ClientConnection, client_id: ClientId) -> Self {
Self { props, client_id } Self { props, client_id }
} }
@@ -54,7 +54,7 @@ impl ClientSession {
.actors .actors
.evm .evm
.ask(ClientSignTransaction { .ask(ClientSignTransaction {
client_id: self.client_id, client_id: self.client_id.as_i32(),
wallet_address: Address::from_slice(&wallet_address), wallet_address: Address::from_slice(&wallet_address),
transaction: tx, transaction: tx,
}) })
@@ -145,7 +145,7 @@ impl ClientSession {
let props = ClientConnection::new(db, transport, actors); let props = ClientConnection::new(db, transport, actors);
Self { Self {
props, props,
client_id: 0, client_id: ClientId::new(0),
} }
} }
} }

View File

@@ -99,6 +99,7 @@ async fn request_client_approval(
while let Some(result) = pool.join_next().await { while let Some(result) = pool.join_next().await {
match result { match result {
Ok(Ok(approved)) => { Ok(Ok(approved)) => {
// cancel other pending requests
let _ = cancel_tx.send(()); let _ = cancel_tx.send(());
return Ok(approved); return Ok(approved);
} }
@@ -153,7 +154,7 @@ impl MessageRouter {
ctx: &mut Context<Self, DelegatedReply<Result<bool, ApprovalError>>>, ctx: &mut Context<Self, DelegatedReply<Result<bool, ApprovalError>>>,
) -> DelegatedReply<Result<bool, ApprovalError>> { ) -> DelegatedReply<Result<bool, ApprovalError>> {
let (reply, Some(reply_sender)) = ctx.reply_sender() else { let (reply, Some(reply_sender)) = ctx.reply_sender() else {
panic!("Exptected `request_client_approval` to have callback channel"); panic!("Expected `request_client_approval` to have callback channel");
}; };
let weak_refs = self let weak_refs = self

View File

@@ -4,6 +4,7 @@ use arbiter_proto::{
}, },
transport::Bi, transport::Bi,
}; };
use fatality::Fatality;
use kameo::actor::Spawn as _; use kameo::actor::Spawn as _;
use tracing::{error, info}; use tracing::{error, info};
@@ -38,8 +39,8 @@ pub enum TransportResponseError {
ConnectionRegistrationFailed, ConnectionRegistrationFailed,
} }
impl TransportResponseError { impl Fatality for TransportResponseError {
pub fn is_terminal(&self) -> bool { fn is_fatal(&self) -> bool {
!matches!( !matches!(
self, self,
Self::SdkClientApprove(_) | Self::SdkClientList(_) | Self::SdkClientRevoke(_) Self::SdkClientApprove(_) | Self::SdkClientList(_) | Self::SdkClientRevoke(_)

View File

@@ -3,8 +3,8 @@ 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::{
ClientConnectionCancel, ClientConnectionRequest, SdkClientApproveRequest, SdkClientApproveRequest, SdkClientApproveResponse, SdkClientConnectionCancel,
SdkClientApproveResponse, SdkClientEntry, SdkClientError as ProtoSdkClientError, SdkClientConnectionRequest, SdkClientEntry, SdkClientError as ProtoSdkClientError,
SdkClientList, SdkClientListResponse, SdkClientRevokeRequest, SdkClientRevokeResponse, SdkClientList, SdkClientListResponse, SdkClientRevokeRequest, SdkClientRevokeResponse,
UnsealEncryptedKey, UnsealResult, UnsealStart, UnsealStartResponse, UserAgentRequest, UnsealEncryptedKey, UnsealResult, UnsealStart, UnsealStartResponse, UserAgentRequest,
UserAgentResponse, sdk_client_approve_response, sdk_client_list_response, UserAgentResponse, sdk_client_approve_response, sdk_client_list_response,
@@ -16,6 +16,7 @@ use chacha20poly1305::{AeadInPlace, XChaCha20Poly1305, XNonce, aead::KeyInit};
use diesel::{ExpressionMethods as _, QueryDsl as _, dsl::insert_into}; use diesel::{ExpressionMethods as _, QueryDsl as _, dsl::insert_into};
use diesel_async::RunQueryDsl as _; use diesel_async::RunQueryDsl as _;
use ed25519_dalek::VerifyingKey; use ed25519_dalek::VerifyingKey;
use fatality::Fatality;
use kameo::{Actor, error::SendError, messages, prelude::Context}; use kameo::{Actor, error::SendError, messages, prelude::Context};
use memsafe::MemSafe; use memsafe::MemSafe;
use tokio::{select, sync::watch}; use tokio::{select, sync::watch};
@@ -127,7 +128,7 @@ impl UserAgentSession {
ctx: &mut Context<Self, Result<bool, Error>>, ctx: &mut Context<Self, Result<bool, Error>>,
) -> Result<bool, Error> { ) -> Result<bool, Error> {
self.send_msg( self.send_msg(
UserAgentResponsePayload::ClientConnectionRequest(ClientConnectionRequest { UserAgentResponsePayload::SdkClientConnectionRequest(SdkClientConnectionRequest {
pubkey: client_pubkey.as_bytes().to_vec(), pubkey: client_pubkey.as_bytes().to_vec(),
}), }),
ctx, ctx,
@@ -135,8 +136,9 @@ impl UserAgentSession {
.await?; .await?;
let extractor = |msg| { let extractor = |msg| {
if let UserAgentRequestPayload::ClientConnectionResponse(client_connection_response) = if let UserAgentRequestPayload::SdkClientConnectionResponse(
msg client_connection_response,
) = msg
{ {
Some(client_connection_response) Some(client_connection_response)
} else { } else {
@@ -148,7 +150,7 @@ impl UserAgentSession {
_ = cancel_flag.changed() => { _ = cancel_flag.changed() => {
info!(actor = "useragent", "client connection approval cancelled"); info!(actor = "useragent", "client connection approval cancelled");
self.send_msg( self.send_msg(
UserAgentResponsePayload::ClientConnectionCancel(ClientConnectionCancel {}), UserAgentResponsePayload::SdkClientConnectionCancel(SdkClientConnectionCancel {}),
ctx, ctx,
).await?; ).await?;
Ok(false) Ok(false)
@@ -379,39 +381,24 @@ impl UserAgentSession {
program_client::created_at.eq(now), program_client::created_at.eq(now),
program_client::updated_at.eq(now), program_client::updated_at.eq(now),
)) ))
.execute(&mut conn) .returning((
program_client::id,
program_client::public_key,
program_client::created_at,
))
.get_result::<(i32, Vec<u8>, i32)>(&mut conn)
.await; .await;
match insert_result { match insert_result {
Ok(_) => { Ok((id, pubkey, created_at)) => Ok(response(
match program_client::table UserAgentResponsePayload::SdkClientApprove(SdkClientApproveResponse {
.filter(program_client::public_key.eq(&pubkey_bytes)) result: Some(ApproveResult::Client(SdkClientEntry {
.order(program_client::id.desc()) id,
.select(( pubkey,
program_client::id, created_at,
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");
Err(TransportResponseError::SdkClientApprove(
ProtoSdkClientError::Internal,
))
}
}
}
Err(diesel::result::Error::DatabaseError( Err(diesel::result::Error::DatabaseError(
diesel::result::DatabaseErrorKind::UniqueViolation, diesel::result::DatabaseErrorKind::UniqueViolation,
_, _,
@@ -573,7 +560,7 @@ impl Actor for UserAgentSession {
} }
} }
Err(err) => { Err(err) => {
let should_stop = err.is_terminal(); let should_stop = err.is_fatal();
if self.props.transport.send(Err(err)).await.is_err() { if self.props.transport.send(Err(err)).await.is_err() {
error!(actor = "useragent", reason = "channel closed", "send.failed"); error!(actor = "useragent", reason = "channel closed", "send.failed");
return Some(kameo::mailbox::Signal::Stop); return Some(kameo::mailbox::Signal::Stop);

View File

@@ -114,6 +114,15 @@ pub async fn test_challenge_auth() {
.await .await
.unwrap(); .unwrap();
let response = test_transport.recv().await.expect("should receive auth ok");
match response {
Ok(resp) => match resp.payload {
Some(ClientResponsePayload::AuthOk(_)) => {}
other => panic!("Expected AuthOk, got {other:?}"),
},
Err(err) => panic!("Expected Ok response, got Err({err:?})"),
}
// Auth completes, session spawned // Auth completes, session spawned
task.await.unwrap(); task.await.unwrap();
} }
@@ -178,6 +187,15 @@ pub async fn test_evm_sign_request_payload_is_handled() {
.await .await
.unwrap(); .unwrap();
let response = test_transport.recv().await.expect("should receive auth ok");
match response {
Ok(resp) => match resp.payload {
Some(ClientResponsePayload::AuthOk(_)) => {}
other => panic!("Expected AuthOk, got {other:?}"),
},
Err(err) => panic!("Expected Ok response, got Err({err:?})"),
}
task.await.unwrap(); task.await.unwrap();
let tx = TxEip1559 { let tx = TxEip1559 {