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;
}
message ClientConnectionRequest {
message SdkClientConnectionRequest {
bytes pubkey = 1;
}
message ClientConnectionResponse {
message SdkClientConnectionResponse {
bool approved = 1;
}
message ClientConnectionCancel {}
message SdkClientConnectionCancel {}
message UserAgentRequest {
oneof payload {
@@ -128,7 +128,7 @@ message UserAgentRequest {
arbiter.evm.EvmGrantCreateRequest evm_grant_create = 8;
arbiter.evm.EvmGrantDeleteRequest evm_grant_delete = 9;
arbiter.evm.EvmGrantListRequest evm_grant_list = 10;
ClientConnectionResponse client_connection_response = 11;
SdkClientConnectionResponse sdk_client_connection_response = 11;
SdkClientApproveRequest sdk_client_approve = 12;
SdkClientRevokeRequest sdk_client_revoke = 13;
google.protobuf.Empty sdk_client_list = 14;
@@ -146,8 +146,8 @@ message UserAgentResponse {
arbiter.evm.EvmGrantCreateResponse evm_grant_create = 8;
arbiter.evm.EvmGrantDeleteResponse evm_grant_delete = 9;
arbiter.evm.EvmGrantListResponse evm_grant_list = 10;
ClientConnectionRequest client_connection_request = 11;
ClientConnectionCancel client_connection_cancel = 12;
SdkClientConnectionRequest sdk_client_connection_request = 11;
SdkClientConnectionCancel sdk_client_connection_cancel = 12;
SdkClientApproveResponse sdk_client_approve = 13;
SdkClientRevokeResponse sdk_client_revoke = 14;
SdkClientListResponse sdk_client_list = 15;

139
server/Cargo.lock generated
View File

@@ -100,7 +100,7 @@ dependencies = [
"serde",
"serde_json",
"serde_with",
"thiserror",
"thiserror 2.0.18",
]
[[package]]
@@ -136,7 +136,7 @@ dependencies = [
"futures",
"futures-util",
"serde_json",
"thiserror",
"thiserror 2.0.18",
]
[[package]]
@@ -178,7 +178,7 @@ dependencies = [
"alloy-rlp",
"crc",
"serde",
"thiserror",
"thiserror 2.0.18",
]
[[package]]
@@ -203,7 +203,7 @@ dependencies = [
"alloy-rlp",
"borsh",
"serde",
"thiserror",
"thiserror 2.0.18",
]
[[package]]
@@ -239,7 +239,7 @@ dependencies = [
"serde",
"serde_with",
"sha2 0.10.9",
"thiserror",
"thiserror 2.0.18",
]
[[package]]
@@ -280,7 +280,7 @@ dependencies = [
"http",
"serde",
"serde_json",
"thiserror",
"thiserror 2.0.18",
"tracing",
]
@@ -307,7 +307,7 @@ dependencies = [
"futures-utils-wasm",
"serde",
"serde_json",
"thiserror",
"thiserror 2.0.18",
]
[[package]]
@@ -382,7 +382,7 @@ dependencies = [
"reqwest",
"serde",
"serde_json",
"thiserror",
"thiserror 2.0.18",
"tokio",
"tracing",
"url",
@@ -475,7 +475,7 @@ dependencies = [
"serde",
"serde_json",
"serde_with",
"thiserror",
"thiserror 2.0.18",
]
[[package]]
@@ -501,7 +501,7 @@ dependencies = [
"either",
"elliptic-curve",
"k256",
"thiserror",
"thiserror 2.0.18",
]
[[package]]
@@ -517,7 +517,7 @@ dependencies = [
"async-trait",
"k256",
"rand 0.8.5",
"thiserror",
"thiserror 2.0.18",
]
[[package]]
@@ -608,7 +608,7 @@ dependencies = [
"parking_lot",
"serde",
"serde_json",
"thiserror",
"thiserror 2.0.18",
"tokio",
"tower",
"tracing",
@@ -644,7 +644,7 @@ dependencies = [
"nybbles",
"serde",
"smallvec",
"thiserror",
"thiserror 2.0.18",
"tracing",
]
@@ -684,8 +684,9 @@ dependencies = [
"async-trait",
"ed25519-dalek",
"http",
"rand 0.10.0",
"rustls-webpki",
"thiserror",
"thiserror 2.0.18",
"tokio",
"tokio-stream",
"tonic",
@@ -708,7 +709,7 @@ dependencies = [
"rcgen",
"rstest",
"rustls-pki-types",
"thiserror",
"thiserror 2.0.18",
"tokio",
"tonic",
"tonic-prost",
@@ -733,6 +734,7 @@ dependencies = [
"diesel-async",
"diesel_migrations",
"ed25519-dalek",
"fatality",
"futures",
"insta",
"k256",
@@ -751,7 +753,7 @@ dependencies = [
"spki",
"strum",
"test-log",
"thiserror",
"thiserror 2.0.18",
"tokio",
"tokio-stream",
"tonic",
@@ -761,13 +763,6 @@ dependencies = [
"zeroize",
]
[[package]]
name = "arbiter-terrors-poc"
version = "0.1.0"
dependencies = [
"terrors",
]
[[package]]
name = "arbiter-tokens-registry"
version = "0.1.0"
@@ -791,7 +786,7 @@ dependencies = [
"sha2 0.10.9",
"smlang",
"spki",
"thiserror",
"thiserror 2.0.18",
"tokio",
"tokio-stream",
"tonic",
@@ -1019,7 +1014,7 @@ dependencies = [
"nom",
"num-traits",
"rusticata-macros",
"thiserror",
"thiserror 2.0.18",
"time",
]
@@ -2079,6 +2074,21 @@ dependencies = [
"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]]
name = "fastrand"
version = "2.3.0"
@@ -2107,6 +2117,30 @@ dependencies = [
"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]]
name = "ff"
version = "0.13.1"
@@ -2129,6 +2163,16 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "find-msvc-tools"
version = "0.1.9"
@@ -2190,6 +2234,15 @@ dependencies = [
"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]]
name = "fs_extra"
version = "1.3.0"
@@ -3784,7 +3837,7 @@ dependencies = [
"rustc-hash",
"rustls",
"socket2",
"thiserror",
"thiserror 2.0.18",
"tokio",
"tracing",
"web-time",
@@ -3805,7 +3858,7 @@ dependencies = [
"rustls",
"rustls-pki-types",
"slab",
"thiserror",
"thiserror 2.0.18",
"tinyvec",
"tracing",
"web-time",
@@ -4140,7 +4193,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8a1f2315036ef6b1fbacd1972e8ee7688030b0a2121edfc2a6550febd41574d"
dependencies = [
"hashbrown 0.16.1",
"thiserror",
"thiserror 2.0.18",
]
[[package]]
@@ -4863,12 +4916,6 @@ dependencies = [
"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]]
name = "test-log"
version = "0.2.19"
@@ -4900,13 +4947,33 @@ dependencies = [
"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]]
name = "thiserror"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
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]]
@@ -6000,7 +6067,7 @@ dependencies = [
"nom",
"oid-registry",
"rusticata-macros",
"thiserror",
"thiserror 2.0.18",
"time",
]

View File

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

View File

@@ -5,7 +5,7 @@ use alloy::{
signers::{Error, Result, Signer},
};
use arbiter_proto::{
format_challenge,
format_challenge, home_path,
proto::{
arbiter_service_client::ArbiterServiceClient,
client::{
@@ -21,10 +21,13 @@ use arbiter_proto::{
};
use async_trait::async_trait;
use ed25519_dalek::Signer as _;
use std::path::{Path, PathBuf};
use tokio::sync::{Mutex, mpsc};
use tokio_stream::wrappers::ReceiverStream;
use tonic::transport::ClientTlsConfig;
const BUFFER_LENGTH: usize = 16;
#[derive(Debug, thiserror::Error)]
pub enum ConnectError {
#[error("Could not establish connection")]
@@ -50,6 +53,83 @@ pub enum ConnectError {
#[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)]
@@ -65,6 +145,9 @@ enum ClientSignError {
#[error("Remote signing was rejected")]
Rejected,
#[error("Wallet address is not configured")]
WalletAddressNotConfigured,
}
struct ClientTransport {
@@ -91,15 +174,27 @@ impl ClientTransport {
pub struct ArbiterSigner {
transport: Mutex<ClientTransport>,
address: Address,
address: Option<Address>,
chain_id: Option<ChainId>,
}
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,
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);
@@ -112,7 +207,7 @@ impl ArbiterSigner {
.await?;
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 mut transport = ClientTransport {
@@ -120,19 +215,37 @@ impl ArbiterSigner {
receiver: response_stream,
};
authenticate(&mut transport, key).await?;
authenticate(&mut transport, &key).await?;
Ok(Self {
transport: Mutex::new(transport),
address,
address: 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,
tx: &mut dyn SignableTransaction<Signature>,
) -> Result<Signature> {
) -> Result<ClientRequest> {
if let Some(chain_id) = self.chain_id
&& !tx.set_chain_id_checked(chain_id)
{
@@ -145,15 +258,21 @@ impl ArbiterSigner {
let mut rlp_transaction = Vec::new();
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(
EvmSignTransactionRequest {
wallet_address: self.address.as_slice().to_vec(),
wallet_address: wallet_address.as_slice().to_vec(),
rlp_transaction,
},
)),
};
})
}
async fn execute_sign_transaction_request(&self, request: ClientRequest) -> Result<Signature> {
let mut transport = self.transport.lock().await;
transport.send(request).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,
key: ed25519_dalek::SigningKey,
key: &ed25519_dalek::SigningKey,
) -> std::result::Result<(), ConnectError> {
transport
.send(ClientRequest {
@@ -194,8 +322,12 @@ async fn authenticate(
)),
})
.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
.recv()
.await
@@ -203,39 +335,58 @@ async fn authenticate(
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),
}
}
ClientResponsePayload::AuthChallenge(challenge) => Ok(challenge),
ClientResponsePayload::ClientConnectError(err) => Err(map_connect_error(err.code)),
_ => 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]
impl Signer for ArbiterSigner {
async fn sign_hash(&self, _hash: &B256) -> Result<Signature> {
@@ -245,7 +396,7 @@ impl Signer for ArbiterSigner {
}
fn address(&self) -> Address {
self.address
self.address.unwrap_or(Address::ZERO)
}
fn chain_id(&self) -> Option<ChainId> {
@@ -260,13 +411,70 @@ impl Signer for ArbiterSigner {
#[async_trait]
impl TxSigner<Signature> for ArbiterSigner {
fn address(&self) -> Address {
self.address
self.address.unwrap_or(Address::ZERO)
}
async fn sign_transaction(
&self,
tx: &mut dyn SignableTransaction<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
miette.workspace = true
thiserror.workspace = true
fatality = "0.1.1"
diesel_migrations = { version = "2.3.1", features = ["sqlite"] }
async-trait.workspace = true
secrecy = "0.10.3"

View File

@@ -1,8 +1,8 @@
use arbiter_proto::{
format_challenge,
proto::client::{
AuthChallenge, AuthChallengeSolution, ClientConnectError, ClientRequest, ClientResponse,
client_connect_error::Code as ConnectErrorCode,
AuthChallenge, AuthChallengeSolution, AuthOk, ClientConnectError, ClientRequest,
ClientResponse, client_connect_error::Code as ConnectErrorCode,
client_request::Payload as ClientRequestPayload,
client_response::Payload as ClientResponsePayload,
},
@@ -26,6 +26,25 @@ use crate::{
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)]
pub enum Error {
#[error("Unexpected message payload")]
@@ -63,7 +82,7 @@ pub enum ApproveError {
async fn get_nonce(
db: &db::DatabasePool,
pubkey: &VerifyingKey,
) -> Result<Option<(i32, i32)>, Error> {
) -> Result<Option<ClientNonceState>, Error> {
let pubkey_bytes = pubkey.as_bytes().to_vec();
let mut conn = db.get().await.map_err(|e| {
@@ -90,7 +109,10 @@ async fn get_nonce(
.execute(conn)
.await?;
Ok(Some((client_id, current_nonce)))
Ok(Some(ClientNonceState {
client_id: ClientId::new(client_id),
nonce: current_nonce,
}))
})
})
.await
@@ -126,7 +148,7 @@ async fn approve_new_client(
}
enum InsertClientResult {
Inserted(i32),
Inserted(ClientId),
AlreadyExists,
}
@@ -176,7 +198,7 @@ async fn insert_client(
Error::DatabaseOperationFailed
})?;
Ok(InsertClientResult::Inserted(client_id))
Ok(InsertClientResult::Inserted(ClientId::new(client_id)))
}
async fn challenge_client(
@@ -224,6 +246,17 @@ async fn challenge_client(
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(())
}
@@ -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 {
payload: Some(ClientRequestPayload::AuthChallengeRequest(challenge)),
}) = 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)?;
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 => {
approve_new_client(&props.actors, pubkey).await?;
match insert_client(&props.db, &pubkey).await? {
InsertClientResult::Inserted(client_id) => (client_id, 0),
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),
},
}

View File

@@ -15,7 +15,7 @@ use tracing::{error, info};
use crate::{
actors::{
GlobalActors,
client::{ClientConnection, ClientError},
client::{ClientConnection, ClientError, auth::ClientId},
evm::ClientSignTransaction,
router::RegisterClient,
},
@@ -24,11 +24,11 @@ use crate::{
pub struct ClientSession {
props: ClientConnection,
client_id: i32,
client_id: ClientId,
}
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 }
}
@@ -54,7 +54,7 @@ impl ClientSession {
.actors
.evm
.ask(ClientSignTransaction {
client_id: self.client_id,
client_id: self.client_id.as_i32(),
wallet_address: Address::from_slice(&wallet_address),
transaction: tx,
})
@@ -145,7 +145,7 @@ impl ClientSession {
let props = ClientConnection::new(db, transport, actors);
Self {
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 {
match result {
Ok(Ok(approved)) => {
// cancel other pending requests
let _ = cancel_tx.send(());
return Ok(approved);
}
@@ -153,7 +154,7 @@ impl MessageRouter {
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");
panic!("Expected `request_client_approval` to have callback channel");
};
let weak_refs = self

View File

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

View File

@@ -3,8 +3,8 @@ use std::{ops::DerefMut, sync::Mutex};
use arbiter_proto::proto::{
evm as evm_proto,
user_agent::{
ClientConnectionCancel, ClientConnectionRequest, SdkClientApproveRequest,
SdkClientApproveResponse, SdkClientEntry, SdkClientError as ProtoSdkClientError,
SdkClientApproveRequest, SdkClientApproveResponse, SdkClientConnectionCancel,
SdkClientConnectionRequest, SdkClientEntry, SdkClientError as ProtoSdkClientError,
SdkClientList, SdkClientListResponse, SdkClientRevokeRequest, SdkClientRevokeResponse,
UnsealEncryptedKey, UnsealResult, UnsealStart, UnsealStartResponse, UserAgentRequest,
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_async::RunQueryDsl as _;
use ed25519_dalek::VerifyingKey;
use fatality::Fatality;
use kameo::{Actor, error::SendError, messages, prelude::Context};
use memsafe::MemSafe;
use tokio::{select, sync::watch};
@@ -127,7 +128,7 @@ impl UserAgentSession {
ctx: &mut Context<Self, Result<bool, Error>>,
) -> Result<bool, Error> {
self.send_msg(
UserAgentResponsePayload::ClientConnectionRequest(ClientConnectionRequest {
UserAgentResponsePayload::SdkClientConnectionRequest(SdkClientConnectionRequest {
pubkey: client_pubkey.as_bytes().to_vec(),
}),
ctx,
@@ -135,8 +136,9 @@ impl UserAgentSession {
.await?;
let extractor = |msg| {
if let UserAgentRequestPayload::ClientConnectionResponse(client_connection_response) =
msg
if let UserAgentRequestPayload::SdkClientConnectionResponse(
client_connection_response,
) = msg
{
Some(client_connection_response)
} else {
@@ -148,7 +150,7 @@ impl UserAgentSession {
_ = cancel_flag.changed() => {
info!(actor = "useragent", "client connection approval cancelled");
self.send_msg(
UserAgentResponsePayload::ClientConnectionCancel(ClientConnectionCancel {}),
UserAgentResponsePayload::SdkClientConnectionCancel(SdkClientConnectionCancel {}),
ctx,
).await?;
Ok(false)
@@ -379,39 +381,24 @@ impl UserAgentSession {
program_client::created_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;
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");
Err(TransportResponseError::SdkClientApprove(
ProtoSdkClientError::Internal,
))
}
}
}
Ok((id, pubkey, created_at)) => Ok(response(
UserAgentResponsePayload::SdkClientApprove(SdkClientApproveResponse {
result: Some(ApproveResult::Client(SdkClientEntry {
id,
pubkey,
created_at,
})),
}),
)),
Err(diesel::result::Error::DatabaseError(
diesel::result::DatabaseErrorKind::UniqueViolation,
_,
@@ -573,7 +560,7 @@ impl Actor for UserAgentSession {
}
}
Err(err) => {
let should_stop = err.is_terminal();
let should_stop = err.is_fatal();
if self.props.transport.send(Err(err)).await.is_err() {
error!(actor = "useragent", reason = "channel closed", "send.failed");
return Some(kameo::mailbox::Signal::Stop);

View File

@@ -114,6 +114,15 @@ pub async fn test_challenge_auth() {
.await
.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
task.await.unwrap();
}
@@ -178,6 +187,15 @@ pub async fn test_evm_sign_request_payload_is_handled() {
.await
.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();
let tx = TxEip1559 {