From 5a3446322837cfdd5aeee558bec3a39ba823b8f7 Mon Sep 17 00:00:00 2001 From: CleverWild Date: Wed, 8 Apr 2026 12:09:54 +0200 Subject: [PATCH 1/6] security(server): bind grant revocation state (revoked_at) to integrity hash --- server/crates/arbiter-server/src/evm/mod.rs | 21 +++++++++++++++++++ .../crates/arbiter-server/src/evm/policies.rs | 2 ++ .../src/evm/policies/ether_transfer/tests.rs | 1 + .../src/evm/policies/token_transfers/tests.rs | 1 + .../src/grpc/user_agent/inbound.rs | 1 + 5 files changed, 26 insertions(+) diff --git a/server/crates/arbiter-server/src/evm/mod.rs b/server/crates/arbiter-server/src/evm/mod.rs index 15ac999..fe603db 100644 --- a/server/crates/arbiter-server/src/evm/mod.rs +++ b/server/crates/arbiter-server/src/evm/mod.rs @@ -394,6 +394,7 @@ mod tests { chain: CHAIN_ID, valid_from: None, valid_until: None, + revoked_at: None, max_gas_fee_per_gas: None, max_priority_fee_per_gas: None, rate_limit: None, @@ -596,4 +597,24 @@ mod tests { assert!(violations.is_empty()); } } + + #[test] + fn shared_settings_hash_changes_when_revoked_at_changes() { + use arbiter_crypto::hashing::Hashable; + use sha2::Digest; + + let active = shared_settings(); + let revoked = SharedGrantSettings { + revoked_at: Some(Utc::now()), + ..shared_settings() + }; + + let mut active_hash = sha2::Sha256::new(); + active.hash(&mut active_hash); + + let mut revoked_hash = sha2::Sha256::new(); + revoked.hash(&mut revoked_hash); + + assert_ne!(active_hash.finalize(), revoked_hash.finalize()); + } } diff --git a/server/crates/arbiter-server/src/evm/policies.rs b/server/crates/arbiter-server/src/evm/policies.rs index 828c52e..984caef 100644 --- a/server/crates/arbiter-server/src/evm/policies.rs +++ b/server/crates/arbiter-server/src/evm/policies.rs @@ -146,6 +146,7 @@ pub struct SharedGrantSettings { pub valid_from: Option>, pub valid_until: Option>, + pub revoked_at: Option>, pub max_gas_fee_per_gas: Option, pub max_priority_fee_per_gas: Option, @@ -160,6 +161,7 @@ impl SharedGrantSettings { chain: model.chain_id as u64, // safe because chain_id is stored as i32 but is guaranteed to be a valid ChainId by the API when creating grants valid_from: model.valid_from.map(Into::into), valid_until: model.valid_until.map(Into::into), + revoked_at: model.revoked_at.map(Into::into), max_gas_fee_per_gas: model .max_gas_fee_per_gas .map(|b| utils::try_bytes_to_u256(&b)) diff --git a/server/crates/arbiter-server/src/evm/policies/ether_transfer/tests.rs b/server/crates/arbiter-server/src/evm/policies/ether_transfer/tests.rs index 5253a25..22cafb0 100644 --- a/server/crates/arbiter-server/src/evm/policies/ether_transfer/tests.rs +++ b/server/crates/arbiter-server/src/evm/policies/ether_transfer/tests.rs @@ -78,6 +78,7 @@ fn shared() -> SharedGrantSettings { chain: CHAIN_ID, valid_from: None, valid_until: None, + revoked_at: None, max_gas_fee_per_gas: None, max_priority_fee_per_gas: None, rate_limit: None, diff --git a/server/crates/arbiter-server/src/evm/policies/token_transfers/tests.rs b/server/crates/arbiter-server/src/evm/policies/token_transfers/tests.rs index c059b0b..790b4df 100644 --- a/server/crates/arbiter-server/src/evm/policies/token_transfers/tests.rs +++ b/server/crates/arbiter-server/src/evm/policies/token_transfers/tests.rs @@ -95,6 +95,7 @@ fn shared() -> SharedGrantSettings { chain: CHAIN_ID, valid_from: None, valid_until: None, + revoked_at: None, max_gas_fee_per_gas: None, max_priority_fee_per_gas: None, rate_limit: None, diff --git a/server/crates/arbiter-server/src/grpc/user_agent/inbound.rs b/server/crates/arbiter-server/src/grpc/user_agent/inbound.rs index 6cfb2e5..52fa516 100644 --- a/server/crates/arbiter-server/src/grpc/user_agent/inbound.rs +++ b/server/crates/arbiter-server/src/grpc/user_agent/inbound.rs @@ -87,6 +87,7 @@ impl TryConvert for ProtoSharedSettings { .valid_until .map(ProtoTimestamp::try_convert) .transpose()?, + revoked_at: None, max_gas_fee_per_gas: self .max_gas_fee_per_gas .as_deref() From e287459b10c4a6b652e54f44e7050e177d01ccd6 Mon Sep 17 00:00:00 2001 From: CleverWild Date: Tue, 9 Jun 2026 18:45:30 +0200 Subject: [PATCH 2/6] revert(server): bind grant revocation state (revoked_at) to integrity hash --- server/crates/arbiter-server/src/evm/mod.rs | 21 +++++++++++++++++++ .../crates/arbiter-server/src/evm/policies.rs | 2 ++ .../src/evm/policies/ether_transfer/tests.rs | 1 + .../src/evm/policies/token_transfers/tests.rs | 1 + .../src/grpc/user_agent/inbound.rs | 1 + 5 files changed, 26 insertions(+) diff --git a/server/crates/arbiter-server/src/evm/mod.rs b/server/crates/arbiter-server/src/evm/mod.rs index 15ac999..fe603db 100644 --- a/server/crates/arbiter-server/src/evm/mod.rs +++ b/server/crates/arbiter-server/src/evm/mod.rs @@ -394,6 +394,7 @@ mod tests { chain: CHAIN_ID, valid_from: None, valid_until: None, + revoked_at: None, max_gas_fee_per_gas: None, max_priority_fee_per_gas: None, rate_limit: None, @@ -596,4 +597,24 @@ mod tests { assert!(violations.is_empty()); } } + + #[test] + fn shared_settings_hash_changes_when_revoked_at_changes() { + use arbiter_crypto::hashing::Hashable; + use sha2::Digest; + + let active = shared_settings(); + let revoked = SharedGrantSettings { + revoked_at: Some(Utc::now()), + ..shared_settings() + }; + + let mut active_hash = sha2::Sha256::new(); + active.hash(&mut active_hash); + + let mut revoked_hash = sha2::Sha256::new(); + revoked.hash(&mut revoked_hash); + + assert_ne!(active_hash.finalize(), revoked_hash.finalize()); + } } diff --git a/server/crates/arbiter-server/src/evm/policies.rs b/server/crates/arbiter-server/src/evm/policies.rs index 828c52e..984caef 100644 --- a/server/crates/arbiter-server/src/evm/policies.rs +++ b/server/crates/arbiter-server/src/evm/policies.rs @@ -146,6 +146,7 @@ pub struct SharedGrantSettings { pub valid_from: Option>, pub valid_until: Option>, + pub revoked_at: Option>, pub max_gas_fee_per_gas: Option, pub max_priority_fee_per_gas: Option, @@ -160,6 +161,7 @@ impl SharedGrantSettings { chain: model.chain_id as u64, // safe because chain_id is stored as i32 but is guaranteed to be a valid ChainId by the API when creating grants valid_from: model.valid_from.map(Into::into), valid_until: model.valid_until.map(Into::into), + revoked_at: model.revoked_at.map(Into::into), max_gas_fee_per_gas: model .max_gas_fee_per_gas .map(|b| utils::try_bytes_to_u256(&b)) diff --git a/server/crates/arbiter-server/src/evm/policies/ether_transfer/tests.rs b/server/crates/arbiter-server/src/evm/policies/ether_transfer/tests.rs index 5253a25..22cafb0 100644 --- a/server/crates/arbiter-server/src/evm/policies/ether_transfer/tests.rs +++ b/server/crates/arbiter-server/src/evm/policies/ether_transfer/tests.rs @@ -78,6 +78,7 @@ fn shared() -> SharedGrantSettings { chain: CHAIN_ID, valid_from: None, valid_until: None, + revoked_at: None, max_gas_fee_per_gas: None, max_priority_fee_per_gas: None, rate_limit: None, diff --git a/server/crates/arbiter-server/src/evm/policies/token_transfers/tests.rs b/server/crates/arbiter-server/src/evm/policies/token_transfers/tests.rs index c059b0b..790b4df 100644 --- a/server/crates/arbiter-server/src/evm/policies/token_transfers/tests.rs +++ b/server/crates/arbiter-server/src/evm/policies/token_transfers/tests.rs @@ -95,6 +95,7 @@ fn shared() -> SharedGrantSettings { chain: CHAIN_ID, valid_from: None, valid_until: None, + revoked_at: None, max_gas_fee_per_gas: None, max_priority_fee_per_gas: None, rate_limit: None, diff --git a/server/crates/arbiter-server/src/grpc/user_agent/inbound.rs b/server/crates/arbiter-server/src/grpc/user_agent/inbound.rs index 6cfb2e5..52fa516 100644 --- a/server/crates/arbiter-server/src/grpc/user_agent/inbound.rs +++ b/server/crates/arbiter-server/src/grpc/user_agent/inbound.rs @@ -87,6 +87,7 @@ impl TryConvert for ProtoSharedSettings { .valid_until .map(ProtoTimestamp::try_convert) .transpose()?, + revoked_at: None, max_gas_fee_per_gas: self .max_gas_fee_per_gas .as_deref() From b0a3f37cea652c74eddc80b2dcfc16d21b265fa6 Mon Sep 17 00:00:00 2001 From: CleverWild Date: Tue, 9 Jun 2026 19:11:39 +0200 Subject: [PATCH 3/6] refactor(evm): implement revoke_grant method for grant revocation --- .../arbiter-server/src/actors/evm/mod.rs | 27 +- server/crates/arbiter-server/src/evm/mod.rs | 253 +++++++++++++++++- 2 files changed, 253 insertions(+), 27 deletions(-) diff --git a/server/crates/arbiter-server/src/actors/evm/mod.rs b/server/crates/arbiter-server/src/actors/evm/mod.rs index c31cdd0..3acb7a7 100644 --- a/server/crates/arbiter-server/src/actors/evm/mod.rs +++ b/server/crates/arbiter-server/src/actors/evm/mod.rs @@ -158,28 +158,11 @@ impl EvmActor { } #[message] - pub async fn useragent_delete_grant(&mut self, _grant_id: i32) -> Result<(), Error> { - // let mut conn = self.db.get().await.map_err(DatabaseError::from)?; - // let keyholder = self.keyholder.clone(); - - // diesel_async::AsyncConnection::transaction(&mut conn, |conn| { - // Box::pin(async move { - // diesel::update(schema::evm_basic_grant::table) - // .filter(schema::evm_basic_grant::id.eq(grant_id)) - // .set(schema::evm_basic_grant::revoked_at.eq(SqliteTimestamp::now())) - // .execute(conn) - // .await?; - - // let signed = integrity::evm::load_signed_grant_by_basic_id(conn, grant_id).await?; - - // diesel::result::QueryResult::Ok(()) - // }) - // }) - // .await - // .map_err(DatabaseError::from)?; - - // Ok(()) - todo!() + pub async fn useragent_delete_grant(&mut self, grant_id: i32) -> Result<(), Error> { + self.engine + .revoke_grant(grant_id) + .await + .map_err(Error::from) } #[message] diff --git a/server/crates/arbiter-server/src/evm/mod.rs b/server/crates/arbiter-server/src/evm/mod.rs index fe603db..b02e288 100644 --- a/server/crates/arbiter-server/src/evm/mod.rs +++ b/server/crates/arbiter-server/src/evm/mod.rs @@ -1,12 +1,16 @@ pub mod abi; pub mod safe_signer; +use alloy::primitives::Address; use alloy::{ consensus::TxEip1559, primitives::{TxKind, U256}, }; use chrono::Utc; -use diesel::{ExpressionMethods as _, QueryDsl as _, QueryResult, insert_into, sqlite::Sqlite}; +use diesel::{ + ExpressionMethods as _, OptionalExtension, QueryDsl as _, QueryResult, SelectableHelper, + insert_into, sqlite::Sqlite, update, +}; use diesel_async::{AsyncConnection, RunQueryDsl}; use kameo::actor::ActorRef; @@ -16,14 +20,16 @@ use crate::{ db::{ self, DatabaseError, models::{ - EvmBasicGrant, EvmWalletAccess, NewEvmBasicGrant, NewEvmTransactionLog, SqliteTimestamp, + EvmBasicGrant, EvmEtherTransferGrant, EvmEtherTransferGrantTarget, + EvmEtherTransferLimit, EvmTokenTransferGrant, EvmTokenTransferVolumeLimit, + EvmWalletAccess, NewEvmBasicGrant, NewEvmTransactionLog, SqliteTimestamp, }, schema::{self, evm_transaction_log}, }, evm::policies::{ CombinedSettings, DatabaseID, EvalContext, EvalViolation, Grant, Policy, - SharedGrantSettings, SpecificGrant, SpecificMeaning, ether_transfer::EtherTransfer, - token_transfers::TokenTransfer, + SharedGrantSettings, SpecificGrant, SpecificMeaning, VolumeRateLimit, + ether_transfer::EtherTransfer, token_transfers::TokenTransfer, }, }; @@ -270,6 +276,150 @@ impl Engine { Ok(id) } + pub async fn revoke_grant(&self, basic_grant_id: i32) -> Result<(), DatabaseError> { + let mut conn = self.db.get().await.map_err(DatabaseError::from)?; + let keyholder = self.keyholder.clone(); + + conn.transaction(|conn| { + Box::pin(async move { + use crate::db::schema::{ + evm_basic_grant, evm_ether_transfer_grant, evm_ether_transfer_grant_target, + evm_ether_transfer_limit, evm_token_transfer_grant, + evm_token_transfer_volume_limit, + }; + + update(evm_basic_grant::table) + .filter(evm_basic_grant::id.eq(basic_grant_id)) + .set(evm_basic_grant::revoked_at.eq(SqliteTimestamp(Utc::now()))) + .execute(conn) + .await?; + + let basic_grant: EvmBasicGrant = evm_basic_grant::table + .filter(evm_basic_grant::id.eq(basic_grant_id)) + .select(EvmBasicGrant::as_select()) + .first(conn) + .await?; + + let shared = SharedGrantSettings::try_from_model(basic_grant)?; + + if let Some(ether_grant) = evm_ether_transfer_grant::table + .filter(evm_ether_transfer_grant::basic_grant_id.eq(basic_grant_id)) + .select(EvmEtherTransferGrant::as_select()) + .first(conn) + .await + .optional()? + { + let target_rows: Vec = + evm_ether_transfer_grant_target::table + .filter(evm_ether_transfer_grant_target::grant_id.eq(ether_grant.id)) + .select(EvmEtherTransferGrantTarget::as_select()) + .load(conn) + .await?; + let targets: Vec
= target_rows + .into_iter() + .filter_map(|target| { + let arr: [u8; 20] = target.address.try_into().ok()?; + Some(Address::from(arr)) + }) + .collect(); + + let limit: EvmEtherTransferLimit = evm_ether_transfer_limit::table + .filter(evm_ether_transfer_limit::id.eq(ether_grant.limit_id)) + .select(EvmEtherTransferLimit::as_select()) + .first(conn) + .await?; + + let settings = CombinedSettings { + shared: shared.clone(), + specific: crate::evm::policies::ether_transfer::Settings { + target: targets, + limit: VolumeRateLimit { + max_volume: utils::try_bytes_to_u256(&limit.max_volume).map_err( + |err| { + diesel::result::Error::DeserializationError(Box::new(err)) + }, + )?, + window: chrono::Duration::seconds(limit.window_secs as i64), + }, + }, + }; + + integrity::sign_entity(conn, &keyholder, &settings, basic_grant_id) + .await + .map_err(|_| diesel::result::Error::RollbackTransaction)?; + + return QueryResult::Ok(()); + } + + if let Some(token_grant) = evm_token_transfer_grant::table + .filter(evm_token_transfer_grant::basic_grant_id.eq(basic_grant_id)) + .select(EvmTokenTransferGrant::as_select()) + .first(conn) + .await + .optional()? + { + let volume_limit_rows: Vec = + evm_token_transfer_volume_limit::table + .filter(evm_token_transfer_volume_limit::grant_id.eq(token_grant.id)) + .select(EvmTokenTransferVolumeLimit::as_select()) + .load(conn) + .await?; + let volume_limits: Vec = volume_limit_rows + .into_iter() + .map(|row| { + Ok(VolumeRateLimit { + max_volume: utils::try_bytes_to_u256(&row.max_volume).map_err( + |err| { + diesel::result::Error::DeserializationError(Box::new(err)) + }, + )?, + window: chrono::Duration::seconds(row.window_secs as i64), + }) + }) + .collect::>>()?; + + let target: Option
= match token_grant.receiver { + None => None, + Some(bytes) => { + let arr: [u8; 20] = bytes.try_into().map_err(|_| { + diesel::result::Error::DeserializationError( + "Invalid receiver address length".into(), + ) + })?; + Some(Address::from(arr)) + } + }; + + let token_contract: [u8; 20] = + token_grant.token_contract.clone().try_into().map_err(|_| { + diesel::result::Error::DeserializationError( + "Invalid token contract address length".into(), + ) + })?; + + let settings = CombinedSettings { + shared, + specific: crate::evm::policies::token_transfers::Settings { + token_contract: Address::from(token_contract), + target, + volume_limits, + }, + }; + + integrity::sign_entity(conn, &keyholder, &settings, basic_grant_id) + .await + .map_err(|_| diesel::result::Error::RollbackTransaction)?; + + return QueryResult::Ok(()); + } + + Err(diesel::result::Error::NotFound) + }) + }) + .await + .map_err(DatabaseError::from) + } + async fn list_one_kind( &self, conn: &mut impl AsyncConnection, @@ -349,11 +499,15 @@ impl Engine { #[cfg(test)] mod tests { use alloy::primitives::{Address, Bytes, U256, address}; + use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _}; use chrono::{Duration, Utc}; use diesel::{SelectableHelper, insert_into}; use diesel_async::RunQueryDsl; + use kameo::{actor::ActorRef, prelude::Spawn}; use rstest::rstest; + use crate::actors::keyholder::{Bootstrap, KeyHolder}; + use crate::crypto::integrity; use crate::db::{ self, DatabaseConnection, models::{ @@ -361,8 +515,10 @@ mod tests { }, schema::{evm_basic_grant, evm_transaction_log}, }; + use crate::evm::policies::ether_transfer::EtherTransfer; use crate::evm::policies::{ - EvalContext, EvalViolation, SharedGrantSettings, TransactionRateLimit, + CombinedSettings, EvalContext, EvalViolation, Policy, SharedGrantSettings, + TransactionRateLimit, VolumeRateLimit, }; use super::check_shared_constraints; @@ -598,6 +754,93 @@ mod tests { } } + async fn bootstrapped_keyholder(db: &db::DatabasePool) -> ActorRef { + let actor = KeyHolder::spawn(KeyHolder::new(db.clone()).await.unwrap()); + actor + .ask(Bootstrap { + seal_key_raw: SafeCell::new(b"integrity-test-seal-key".to_vec()), + }) + .await + .unwrap(); + actor + } + + #[tokio::test] + async fn revoke_grant_preserves_revoked_integrity() { + use crate::db::schema::evm_basic_grant; + use diesel::ExpressionMethods as _; + + let db = db::create_test_pool().await; + let keyholder = bootstrapped_keyholder(&db).await; + let engine = super::Engine::new(db.clone(), keyholder.clone()); + + let full_grant = CombinedSettings { + shared: SharedGrantSettings { + wallet_access_id: WALLET_ACCESS_ID, + chain: CHAIN_ID, + valid_from: None, + valid_until: None, + revoked_at: None, + max_gas_fee_per_gas: None, + max_priority_fee_per_gas: None, + rate_limit: None, + }, + specific: crate::evm::policies::ether_transfer::Settings { + target: vec![RECIPIENT], + limit: VolumeRateLimit { + max_volume: U256::from(100u64), + window: Duration::hours(1), + }, + }, + }; + + let grant_id = engine + .create_grant::(full_grant) + .await + .unwrap(); + + engine.revoke_grant(grant_id).await.unwrap(); + + let mut conn = db.get().await.unwrap(); + diesel::update(evm_basic_grant::table) + .filter(evm_basic_grant::id.eq(grant_id)) + .set(evm_basic_grant::revoked_at.eq::>(None)) + .execute(&mut conn) + .await + .unwrap(); + + let wallet_access = EvmWalletAccess { + id: WALLET_ACCESS_ID, + wallet_id: 10, + client_id: 20, + created_at: SqliteTimestamp(Utc::now()), + }; + let context = EvalContext { + target: wallet_access, + chain: CHAIN_ID, + to: RECIPIENT, + value: U256::ONE, + calldata: Bytes::new(), + max_fee_per_gas: 1, + max_priority_fee_per_gas: 1, + }; + + let grant = crate::evm::policies::ether_transfer::EtherTransfer::try_find_grant( + &context, &mut conn, + ) + .await + .unwrap() + .unwrap(); + + let result = + integrity::verify_entity(&mut conn, &keyholder, &grant.settings, grant.id).await; + + assert!(matches!( + result, + Err(crate::crypto::integrity::Error::MacMismatch { .. }) + )); + } + #[test] fn shared_settings_hash_changes_when_revoked_at_changes() { use arbiter_crypto::hashing::Hashable; From 4bb2c062dca8e7fb0e63a9a3c766766ef3b85984 Mon Sep 17 00:00:00 2001 From: CleverWild Date: Tue, 9 Jun 2026 19:16:21 +0200 Subject: [PATCH 4/6] feat(evm): add wallet_access_id to grant deletion requests and revocation logic --- protobufs/evm.proto | 1 + server/crates/arbiter-server/src/actors/evm/mod.rs | 8 ++++++-- .../src/actors/user_agent/session/connection.rs | 5 +++-- server/crates/arbiter-server/src/evm/mod.rs | 10 ++++++++-- .../crates/arbiter-server/src/grpc/user_agent/evm.rs | 1 + 5 files changed, 19 insertions(+), 6 deletions(-) diff --git a/protobufs/evm.proto b/protobufs/evm.proto index 4f7f910..526f645 100644 --- a/protobufs/evm.proto +++ b/protobufs/evm.proto @@ -90,6 +90,7 @@ message EvmGrantCreateResponse { message EvmGrantDeleteRequest { int32 grant_id = 1; + int32 wallet_access_id = 2; } message EvmGrantDeleteResponse { diff --git a/server/crates/arbiter-server/src/actors/evm/mod.rs b/server/crates/arbiter-server/src/actors/evm/mod.rs index 3acb7a7..81c1670 100644 --- a/server/crates/arbiter-server/src/actors/evm/mod.rs +++ b/server/crates/arbiter-server/src/actors/evm/mod.rs @@ -158,9 +158,13 @@ impl EvmActor { } #[message] - pub async fn useragent_delete_grant(&mut self, grant_id: i32) -> Result<(), Error> { + pub async fn useragent_delete_grant( + &mut self, + grant_id: i32, + wallet_access_id: i32, + ) -> Result<(), Error> { self.engine - .revoke_grant(grant_id) + .revoke_grant(grant_id, wallet_access_id) .await .map_err(Error::from) } diff --git a/server/crates/arbiter-server/src/actors/user_agent/session/connection.rs b/server/crates/arbiter-server/src/actors/user_agent/session/connection.rs index 71f4067..e72b359 100644 --- a/server/crates/arbiter-server/src/actors/user_agent/session/connection.rs +++ b/server/crates/arbiter-server/src/actors/user_agent/session/connection.rs @@ -360,12 +360,13 @@ impl UserAgentSession { pub(crate) async fn handle_grant_delete( &mut self, grant_id: i32, + wallet_access_id: i32, ) -> Result<(), GrantMutationError> { // match self // .props // .actors // .evm - // .ask(UseragentDeleteGrant { grant_id }) + // .ask(UseragentDeleteGrant { grant_id, wallet_access_id }) // .await // { // Ok(()) => Ok(()), @@ -374,7 +375,7 @@ impl UserAgentSession { // Err(GrantMutationError::Internal) // } // } - let _ = grant_id; + let _ = (grant_id, wallet_access_id); todo!() } diff --git a/server/crates/arbiter-server/src/evm/mod.rs b/server/crates/arbiter-server/src/evm/mod.rs index b02e288..c063ce7 100644 --- a/server/crates/arbiter-server/src/evm/mod.rs +++ b/server/crates/arbiter-server/src/evm/mod.rs @@ -276,7 +276,11 @@ impl Engine { Ok(id) } - pub async fn revoke_grant(&self, basic_grant_id: i32) -> Result<(), DatabaseError> { + pub async fn revoke_grant( + &self, + basic_grant_id: i32, + wallet_access_id: i32, + ) -> Result<(), DatabaseError> { let mut conn = self.db.get().await.map_err(DatabaseError::from)?; let keyholder = self.keyholder.clone(); @@ -290,12 +294,14 @@ impl Engine { update(evm_basic_grant::table) .filter(evm_basic_grant::id.eq(basic_grant_id)) + .filter(evm_basic_grant::wallet_access_id.eq(wallet_access_id)) .set(evm_basic_grant::revoked_at.eq(SqliteTimestamp(Utc::now()))) .execute(conn) .await?; let basic_grant: EvmBasicGrant = evm_basic_grant::table .filter(evm_basic_grant::id.eq(basic_grant_id)) + .filter(evm_basic_grant::wallet_access_id.eq(wallet_access_id)) .select(EvmBasicGrant::as_select()) .first(conn) .await?; @@ -799,7 +805,7 @@ mod tests { .await .unwrap(); - engine.revoke_grant(grant_id).await.unwrap(); + engine.revoke_grant(grant_id, WALLET_ACCESS_ID).await.unwrap(); let mut conn = db.get().await.unwrap(); diesel::update(evm_basic_grant::table) diff --git a/server/crates/arbiter-server/src/grpc/user_agent/evm.rs b/server/crates/arbiter-server/src/grpc/user_agent/evm.rs index 28725c2..46246d7 100644 --- a/server/crates/arbiter-server/src/grpc/user_agent/evm.rs +++ b/server/crates/arbiter-server/src/grpc/user_agent/evm.rs @@ -170,6 +170,7 @@ async fn handle_grant_delete( let result = match actor .ask(HandleGrantDelete { grant_id: req.grant_id, + wallet_access_id: req.wallet_access_id, }) .await { From 32f317384d29abfcc04b9143bf84d0e68858f91d Mon Sep 17 00:00:00 2001 From: CleverWild Date: Tue, 9 Jun 2026 19:36:44 +0200 Subject: [PATCH 5/6] security(evm): remove client-controlled wallet_access_id from grant revocation --- protobufs/evm.proto | 1 - server/crates/arbiter-server/src/actors/evm/mod.rs | 3 +-- .../src/actors/user_agent/session/connection.rs | 5 ++--- server/crates/arbiter-server/src/evm/mod.rs | 5 +---- server/crates/arbiter-server/src/grpc/user_agent/evm.rs | 1 - 5 files changed, 4 insertions(+), 11 deletions(-) diff --git a/protobufs/evm.proto b/protobufs/evm.proto index 526f645..4f7f910 100644 --- a/protobufs/evm.proto +++ b/protobufs/evm.proto @@ -90,7 +90,6 @@ message EvmGrantCreateResponse { message EvmGrantDeleteRequest { int32 grant_id = 1; - int32 wallet_access_id = 2; } message EvmGrantDeleteResponse { diff --git a/server/crates/arbiter-server/src/actors/evm/mod.rs b/server/crates/arbiter-server/src/actors/evm/mod.rs index 81c1670..51757f3 100644 --- a/server/crates/arbiter-server/src/actors/evm/mod.rs +++ b/server/crates/arbiter-server/src/actors/evm/mod.rs @@ -161,10 +161,9 @@ impl EvmActor { pub async fn useragent_delete_grant( &mut self, grant_id: i32, - wallet_access_id: i32, ) -> Result<(), Error> { self.engine - .revoke_grant(grant_id, wallet_access_id) + .revoke_grant(grant_id) .await .map_err(Error::from) } diff --git a/server/crates/arbiter-server/src/actors/user_agent/session/connection.rs b/server/crates/arbiter-server/src/actors/user_agent/session/connection.rs index e72b359..2ebb060 100644 --- a/server/crates/arbiter-server/src/actors/user_agent/session/connection.rs +++ b/server/crates/arbiter-server/src/actors/user_agent/session/connection.rs @@ -360,13 +360,12 @@ impl UserAgentSession { pub(crate) async fn handle_grant_delete( &mut self, grant_id: i32, - wallet_access_id: i32, ) -> Result<(), GrantMutationError> { // match self // .props // .actors // .evm - // .ask(UseragentDeleteGrant { grant_id, wallet_access_id }) + // .ask(UseragentDeleteGrant { grant_id }) // .await // { // Ok(()) => Ok(()), @@ -375,7 +374,7 @@ impl UserAgentSession { // Err(GrantMutationError::Internal) // } // } - let _ = (grant_id, wallet_access_id); + let _ = grant_id; todo!() } diff --git a/server/crates/arbiter-server/src/evm/mod.rs b/server/crates/arbiter-server/src/evm/mod.rs index c063ce7..2a9dbac 100644 --- a/server/crates/arbiter-server/src/evm/mod.rs +++ b/server/crates/arbiter-server/src/evm/mod.rs @@ -279,7 +279,6 @@ impl Engine { pub async fn revoke_grant( &self, basic_grant_id: i32, - wallet_access_id: i32, ) -> Result<(), DatabaseError> { let mut conn = self.db.get().await.map_err(DatabaseError::from)?; let keyholder = self.keyholder.clone(); @@ -294,14 +293,12 @@ impl Engine { update(evm_basic_grant::table) .filter(evm_basic_grant::id.eq(basic_grant_id)) - .filter(evm_basic_grant::wallet_access_id.eq(wallet_access_id)) .set(evm_basic_grant::revoked_at.eq(SqliteTimestamp(Utc::now()))) .execute(conn) .await?; let basic_grant: EvmBasicGrant = evm_basic_grant::table .filter(evm_basic_grant::id.eq(basic_grant_id)) - .filter(evm_basic_grant::wallet_access_id.eq(wallet_access_id)) .select(EvmBasicGrant::as_select()) .first(conn) .await?; @@ -805,7 +802,7 @@ mod tests { .await .unwrap(); - engine.revoke_grant(grant_id, WALLET_ACCESS_ID).await.unwrap(); + engine.revoke_grant(grant_id).await.unwrap(); let mut conn = db.get().await.unwrap(); diesel::update(evm_basic_grant::table) diff --git a/server/crates/arbiter-server/src/grpc/user_agent/evm.rs b/server/crates/arbiter-server/src/grpc/user_agent/evm.rs index 46246d7..28725c2 100644 --- a/server/crates/arbiter-server/src/grpc/user_agent/evm.rs +++ b/server/crates/arbiter-server/src/grpc/user_agent/evm.rs @@ -170,7 +170,6 @@ async fn handle_grant_delete( let result = match actor .ask(HandleGrantDelete { grant_id: req.grant_id, - wallet_access_id: req.wallet_access_id, }) .await { From d99c87c47355b0dba633716f9613861ec2f0e6a1 Mon Sep 17 00:00:00 2001 From: CleverWild Date: Tue, 9 Jun 2026 21:07:01 +0200 Subject: [PATCH 6/6] fix: lints --- server/crates/arbiter-client/src/auth.rs | 2 +- .../crates/arbiter-server/src/grpc/client/auth.rs | 2 +- .../arbiter-server/src/grpc/operator/auth.rs | 2 +- .../crates/arbiter-server/src/peers/client/auth.rs | 2 +- .../src/peers/operator/auth/state.rs | 2 -- .../src/peers/operator/session/handlers.rs | 3 +-- .../src/peers/operator/session/mod.rs | 2 +- .../crates/arbiter-server/tests/operator/auth.rs | 2 +- .../crates/arbiter-server/tests/vault/lifecycle.rs | 14 +++++++------- .../crates/arbiter-server/tests/vault/storage.rs | 8 ++++---- 10 files changed, 18 insertions(+), 21 deletions(-) diff --git a/server/crates/arbiter-client/src/auth.rs b/server/crates/arbiter-client/src/auth.rs index eae51e9..176cd13 100644 --- a/server/crates/arbiter-client/src/auth.rs +++ b/server/crates/arbiter-client/src/auth.rs @@ -100,7 +100,7 @@ async fn send_auth_challenge_solution( key: &SigningKey, challenge: AuthChallenge, ) -> Result<(), AuthError> { - let timestamp = DateTime::from_timestamp_nanos(challenge.timestamp_nanos as i64); + let timestamp = DateTime::from_timestamp_nanos(challenge.timestamp_nanos.cast_signed()); let challenge = authn::AuthChallenge { nonce: *challenge .random diff --git a/server/crates/arbiter-server/src/grpc/client/auth.rs b/server/crates/arbiter-server/src/grpc/client/auth.rs index 25399cd..2e5375a 100644 --- a/server/crates/arbiter-server/src/grpc/client/auth.rs +++ b/server/crates/arbiter-server/src/grpc/client/auth.rs @@ -200,7 +200,7 @@ impl Convert for auth::Outbound { .timestamp .timestamp_nanos_opt() .expect("timestamp within range") - as u64, + .cast_unsigned(), random: challenge.nonce.to_vec(), }) } diff --git a/server/crates/arbiter-server/src/grpc/operator/auth.rs b/server/crates/arbiter-server/src/grpc/operator/auth.rs index a900e0b..fa15310 100644 --- a/server/crates/arbiter-server/src/grpc/operator/auth.rs +++ b/server/crates/arbiter-server/src/grpc/operator/auth.rs @@ -80,7 +80,7 @@ impl Sender> for AuthTransportAdapter<'_> { .timestamp .timestamp_nanos_opt() .expect("timestamp within range") - as u64, + .cast_unsigned(), random: challenge.nonce.to_vec(), }) } diff --git a/server/crates/arbiter-server/src/peers/client/auth.rs b/server/crates/arbiter-server/src/peers/client/auth.rs index 3742f97..f488161 100644 --- a/server/crates/arbiter-server/src/peers/client/auth.rs +++ b/server/crates/arbiter-server/src/peers/client/auth.rs @@ -298,7 +298,7 @@ where let signature = expect_message(transport, |req: Inbound| match req { Inbound::AuthChallengeSolution { signature } => Some(signature), - _ => None, + Inbound::AuthChallengeRequest { .. } => None, }) .await .map_err(|e| { diff --git a/server/crates/arbiter-server/src/peers/operator/auth/state.rs b/server/crates/arbiter-server/src/peers/operator/auth/state.rs index b37d4b5..38f1ecd 100644 --- a/server/crates/arbiter-server/src/peers/operator/auth/state.rs +++ b/server/crates/arbiter-server/src/peers/operator/auth/state.rs @@ -127,8 +127,6 @@ where }) } - #[allow(missing_docs)] - #[allow(clippy::unused_unit)] async fn verify_solution( &mut self, ChallengeContext { diff --git a/server/crates/arbiter-server/src/peers/operator/session/handlers.rs b/server/crates/arbiter-server/src/peers/operator/session/handlers.rs index df20070..5ac6cb4 100644 --- a/server/crates/arbiter-server/src/peers/operator/session/handlers.rs +++ b/server/crates/arbiter-server/src/peers/operator/session/handlers.rs @@ -212,8 +212,7 @@ impl OperatorSession { &mut self, ) -> Result, Error> { let mut conn = self.props.db.get().await?; - use crate::db::schema::evm_wallet_access; - let access_entries = evm_wallet_access::table + let access_entries = crate::db::schema::evm_wallet_access::table .select(EvmWalletAccess::as_select()) .load::<_>(&mut conn) .await?; diff --git a/server/crates/arbiter-server/src/peers/operator/session/mod.rs b/server/crates/arbiter-server/src/peers/operator/session/mod.rs index 79281bb..0fe2c84 100644 --- a/server/crates/arbiter-server/src/peers/operator/session/mod.rs +++ b/server/crates/arbiter-server/src/peers/operator/session/mod.rs @@ -63,7 +63,7 @@ impl OperatorSession { Self { props, sender, - pending_client_approvals: Default::default(), + pending_client_approvals: HashMap::default(), } } } diff --git a/server/crates/arbiter-server/tests/operator/auth.rs b/server/crates/arbiter-server/tests/operator/auth.rs index e9e585d..cc1f8f3 100644 --- a/server/crates/arbiter-server/tests/operator/auth.rs +++ b/server/crates/arbiter-server/tests/operator/auth.rs @@ -400,7 +400,7 @@ pub async fn challenge_auth_rejects_integrity_tag_mismatch_when_unsealed() { let challenge = match response { Ok(resp) => match resp { auth::Outbound::AuthChallenge { challenge } => challenge, - other => panic!("Expected AuthChallenge, got {other:?}"), + other @ auth::Outbound::AuthSuccess => panic!("Expected AuthChallenge, got {other:?}"), }, Err(err) => panic!("Expected Ok response, got Err({err:?})"), }; diff --git a/server/crates/arbiter-server/tests/vault/lifecycle.rs b/server/crates/arbiter-server/tests/vault/lifecycle.rs index 25017c4..c4ee7da 100644 --- a/server/crates/arbiter-server/tests/vault/lifecycle.rs +++ b/server/crates/arbiter-server/tests/vault/lifecycle.rs @@ -14,7 +14,7 @@ use diesel_async::RunQueryDsl; #[tokio::test] #[test_log::test] -async fn test_bootstrap() { +async fn bootstrap() { let db = db::create_test_pool().await; let mut actor = Vault::new(db.clone(), GlobalActors::spawn_message_bus()) .await @@ -39,7 +39,7 @@ async fn test_bootstrap() { #[tokio::test] #[test_log::test] -async fn test_bootstrap_rejects_double() { +async fn bootstrap_rejects_double() { let db = db::create_test_pool().await; let mut actor = common::bootstrapped_vault(&db).await; @@ -50,7 +50,7 @@ async fn test_bootstrap_rejects_double() { #[tokio::test] #[test_log::test] -async fn test_create_new_before_bootstrap_fails() { +async fn create_new_before_bootstrap_fails() { let db = db::create_test_pool().await; let mut actor = Vault::new(db, GlobalActors::spawn_message_bus()) .await @@ -65,7 +65,7 @@ async fn test_create_new_before_bootstrap_fails() { #[tokio::test] #[test_log::test] -async fn test_decrypt_before_bootstrap_fails() { +async fn decrypt_before_bootstrap_fails() { let db = db::create_test_pool().await; let mut actor = Vault::new(db, GlobalActors::spawn_message_bus()) .await @@ -77,7 +77,7 @@ async fn test_decrypt_before_bootstrap_fails() { #[tokio::test] #[test_log::test] -async fn test_new_restores_sealed_state() { +async fn new_restores_sealed_state() { let db = db::create_test_pool().await; let actor = common::bootstrapped_vault(&db).await; drop(actor); @@ -91,7 +91,7 @@ async fn test_new_restores_sealed_state() { #[tokio::test] #[test_log::test] -async fn test_unseal_correct_password() { +async fn unseal_correct_password() { let db = db::create_test_pool().await; let mut actor = common::bootstrapped_vault(&db).await; @@ -114,7 +114,7 @@ async fn test_unseal_correct_password() { #[tokio::test] #[test_log::test] -async fn test_unseal_wrong_then_correct_password() { +async fn unseal_wrong_then_correct_password() { let db = db::create_test_pool().await; let mut actor = common::bootstrapped_vault(&db).await; diff --git a/server/crates/arbiter-server/tests/vault/storage.rs b/server/crates/arbiter-server/tests/vault/storage.rs index 391080f..c1bd321 100644 --- a/server/crates/arbiter-server/tests/vault/storage.rs +++ b/server/crates/arbiter-server/tests/vault/storage.rs @@ -12,7 +12,7 @@ use std::collections::HashSet; #[tokio::test] #[test_log::test] -async fn test_create_decrypt_roundtrip() { +async fn create_decrypt_roundtrip() { let db = db::create_test_pool().await; let mut actor = common::bootstrapped_vault(&db).await; @@ -28,7 +28,7 @@ async fn test_create_decrypt_roundtrip() { #[tokio::test] #[test_log::test] -async fn test_decrypt_nonexistent_returns_not_found() { +async fn decrypt_nonexistent_returns_not_found() { let db = db::create_test_pool().await; let mut actor = common::bootstrapped_vault(&db).await; @@ -38,7 +38,7 @@ async fn test_decrypt_nonexistent_returns_not_found() { #[tokio::test] #[test_log::test] -async fn test_ciphertext_differs_across_entries() { +async fn ciphertext_differs_across_entries() { let db = db::create_test_pool().await; let mut actor = common::bootstrapped_vault(&db).await; @@ -76,7 +76,7 @@ async fn test_ciphertext_differs_across_entries() { #[tokio::test] #[test_log::test] -async fn test_nonce_never_reused() { +async fn nonce_never_reused() { let db = db::create_test_pool().await; let mut actor = common::bootstrapped_vault(&db).await;