From 4057c1fc12076fd6980cdc761fe84c280b672add Mon Sep 17 00:00:00 2001 From: CleverWild Date: Sat, 4 Apr 2026 21:52:50 +0200 Subject: [PATCH] feat(server): integrity envelope engine for EVM grants with HMAC verification --- protobufs/integrity.proto | 50 +++ server/Cargo.lock | 1 + server/Cargo.toml | 1 + server/crates/arbiter-proto/Cargo.toml | 2 +- server/crates/arbiter-proto/build.rs | 1 + server/crates/arbiter-proto/src/lib.rs | 4 + server/crates/arbiter-server/Cargo.toml | 1 + .../2026-02-14-171124-0000_init/up.sql | 16 + .../arbiter-server/src/actors/evm/mod.rs | 64 +++- .../src/actors/keyholder/mod.rs | 75 +++++ .../actors/user_agent/session/connection.rs | 26 +- server/crates/arbiter-server/src/db/models.rs | 21 +- server/crates/arbiter-server/src/db/schema.rs | 14 + server/crates/arbiter-server/src/evm/mod.rs | 60 +++- .../crates/arbiter-server/src/evm/policies.rs | 2 +- .../src/grpc/common/outbound.rs | 4 +- .../arbiter-server/src/grpc/user_agent/evm.rs | 12 +- .../arbiter-server/src/integrity/evm.rs | 259 +++++++++++++++ .../arbiter-server/src/integrity/mod.rs | 307 ++++++++++++++++++ server/crates/arbiter-server/src/lib.rs | 1 + 20 files changed, 889 insertions(+), 32 deletions(-) create mode 100644 protobufs/integrity.proto create mode 100644 server/crates/arbiter-server/src/integrity/evm.rs create mode 100644 server/crates/arbiter-server/src/integrity/mod.rs diff --git a/protobufs/integrity.proto b/protobufs/integrity.proto new file mode 100644 index 0000000..fa2c2fc --- /dev/null +++ b/protobufs/integrity.proto @@ -0,0 +1,50 @@ +syntax = "proto3"; + +package arbiter.integrity; + +import "google/protobuf/timestamp.proto"; + +message IntegrityTransactionRateLimit { + uint32 count = 1; + int64 window_secs = 2; +} + +message IntegrityVolumeRateLimit { + bytes max_volume = 1; // U256 as fixed-width 32-byte little-endian bytes + int64 window_secs = 2; +} + +message IntegritySharedGrantSettings { + int32 wallet_access_id = 1; + uint64 chain_id = 2; + optional google.protobuf.Timestamp valid_from = 3; + optional google.protobuf.Timestamp valid_until = 4; + optional bytes max_gas_fee_per_gas = 5; // U256 as fixed-width 32-byte little-endian bytes + optional bytes max_priority_fee_per_gas = 6; // U256 as fixed-width 32-byte little-endian bytes + optional IntegrityTransactionRateLimit rate_limit = 7; +} + +message IntegrityEtherTransferSettings { + repeated bytes targets = 1; // sorted list of 20-byte addresses + IntegrityVolumeRateLimit limit = 2; +} + +message IntegrityTokenTransferSettings { + bytes token_contract = 1; // 20-byte address + optional bytes target = 2; // 20-byte address + repeated IntegrityVolumeRateLimit volume_limits = 3; // sorted by (window_secs, max_volume) +} + +message IntegritySpecificGrant { + oneof grant { + IntegrityEtherTransferSettings ether_transfer = 1; + IntegrityTokenTransferSettings token_transfer = 2; + } +} + +message IntegrityEvmGrantPayloadV1 { + int32 basic_grant_id = 1; + IntegritySharedGrantSettings shared = 2; + IntegritySpecificGrant specific = 3; + optional google.protobuf.Timestamp revoked_at = 4; +} diff --git a/server/Cargo.lock b/server/Cargo.lock index fc4628a..78bf627 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -744,6 +744,7 @@ dependencies = [ "kameo", "memsafe", "pem", + "prost", "prost-types", "rand 0.10.0", "rcgen", diff --git a/server/Cargo.toml b/server/Cargo.toml index 1e41511..27bbea8 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -42,4 +42,5 @@ k256 = { version = "0.13.4", features = ["ecdsa", "pkcs8"] } rsa = { version = "0.9", features = ["sha2"] } sha2 = "0.10" spki = "0.7" +prost = "0.14.3" miette = { version = "7.6.0", features = ["fancy", "serde"] } \ No newline at end of file diff --git a/server/crates/arbiter-proto/Cargo.toml b/server/crates/arbiter-proto/Cargo.toml index 8299691..e1651a7 100644 --- a/server/crates/arbiter-proto/Cargo.toml +++ b/server/crates/arbiter-proto/Cargo.toml @@ -11,7 +11,7 @@ tokio.workspace = true futures.workspace = true hex = "0.4.3" tonic-prost = "0.14.5" -prost = "0.14.3" +prost.workspace = true kameo.workspace = true url = "2.5.8" miette.workspace = true diff --git a/server/crates/arbiter-proto/build.rs b/server/crates/arbiter-proto/build.rs index 657066c..28a8048 100644 --- a/server/crates/arbiter-proto/build.rs +++ b/server/crates/arbiter-proto/build.rs @@ -13,6 +13,7 @@ fn main() -> Result<(), Box> { format!("{}/user_agent.proto", PROTOBUF_DIR), format!("{}/client.proto", PROTOBUF_DIR), format!("{}/evm.proto", PROTOBUF_DIR), + format!("{}/integrity.proto", PROTOBUF_DIR), ], &[PROTOBUF_DIR.to_string()], ) diff --git a/server/crates/arbiter-proto/src/lib.rs b/server/crates/arbiter-proto/src/lib.rs index 141b231..e83a5e4 100644 --- a/server/crates/arbiter-proto/src/lib.rs +++ b/server/crates/arbiter-proto/src/lib.rs @@ -61,6 +61,10 @@ pub mod proto { pub mod evm { tonic::include_proto!("arbiter.evm"); } + + pub mod integrity { + tonic::include_proto!("arbiter.integrity"); + } } #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/server/crates/arbiter-server/Cargo.toml b/server/crates/arbiter-server/Cargo.toml index ccff5ba..2fb667e 100644 --- a/server/crates/arbiter-server/Cargo.toml +++ b/server/crates/arbiter-server/Cargo.toml @@ -52,6 +52,7 @@ hmac = "0.12" spki.workspace = true alloy.workspace = true prost-types.workspace = true +prost.workspace = true arbiter-tokens-registry.path = "../arbiter-tokens-registry" anyhow = "1.0.102" diff --git a/server/crates/arbiter-server/migrations/2026-02-14-171124-0000_init/up.sql b/server/crates/arbiter-server/migrations/2026-02-14-171124-0000_init/up.sql index a34aa5a..78ef098 100644 --- a/server/crates/arbiter-server/migrations/2026-02-14-171124-0000_init/up.sql +++ b/server/crates/arbiter-server/migrations/2026-02-14-171124-0000_init/up.sql @@ -192,3 +192,19 @@ create table if not exists evm_ether_transfer_grant_target ( ) STRICT; create unique index if not exists uniq_ether_transfer_target on evm_ether_transfer_grant_target (grant_id, address); + +-- =============================== +-- Integrity Envelopes +-- =============================== +create table if not exists integrity_envelope ( + id integer not null primary key, + entity_kind text not null, + entity_id blob not null, + payload_version integer not null, + key_version integer not null, + mac blob not null, -- 20-byte recipient address + signed_at integer not null default(unixepoch ('now')), + created_at integer not null default(unixepoch ('now')) +) STRICT; + +create unique index if not exists uniq_integrity_envelope_entity on integrity_envelope (entity_kind, entity_id); diff --git a/server/crates/arbiter-server/src/actors/evm/mod.rs b/server/crates/arbiter-server/src/actors/evm/mod.rs index 5a3a2f7..0fc7ab8 100644 --- a/server/crates/arbiter-server/src/actors/evm/mod.rs +++ b/server/crates/arbiter-server/src/actors/evm/mod.rs @@ -7,7 +7,7 @@ use kameo::{Actor, actor::ActorRef, messages}; use rand::{SeedableRng, rng, rngs::StdRng}; use crate::{ - actors::keyholder::{CreateNew, Decrypt, KeyHolder}, + actors::keyholder::{CreateNew, Decrypt, GetState, KeyHolder, KeyHolderState}, db::{ DatabaseError, DatabasePool, models::{self, SqliteTimestamp}, @@ -20,6 +20,7 @@ use crate::{ ether_transfer::EtherTransfer, token_transfers::TokenTransfer, }, }, + integrity, safe_cell::{SafeCell, SafeCellHandle as _}, }; @@ -56,6 +57,10 @@ pub enum Error { #[error("Database error: {0}")] Database(#[from] DatabaseError), + + #[error("Vault is sealed")] + #[diagnostic(code(arbiter::evm::vault_sealed))] + VaultSealed, } #[derive(Actor)] @@ -71,7 +76,7 @@ impl EvmActor { // is it safe to seed rng from system once? // todo: audit let rng = StdRng::from_rng(&mut rng()); - let engine = evm::Engine::new(db.clone()); + let engine = evm::Engine::new(db.clone(), keyholder.clone()); Self { keyholder, db, @@ -79,6 +84,20 @@ impl EvmActor { engine, } } + + async fn ensure_unsealed(&self) -> Result<(), Error> { + let state = self + .keyholder + .ask(GetState) + .await + .map_err(|_| Error::KeyholderSend)?; + + if state != KeyHolderState::Unsealed { + return Err(Error::VaultSealed); + } + + Ok(()) + } } #[messages] @@ -132,7 +151,9 @@ impl EvmActor { &mut self, basic: SharedGrantSettings, grant: SpecificGrant, - ) -> Result { + ) -> Result { + self.ensure_unsealed().await?; + match grant { SpecificGrant::EtherTransfer(settings) => { self.engine @@ -141,6 +162,7 @@ impl EvmActor { specific: settings, }) .await + .map_err(Error::from) } SpecificGrant::TokenTransfer(settings) => { self.engine @@ -149,29 +171,43 @@ impl EvmActor { specific: settings, }) .await + .map_err(Error::from) } } } #[message] pub async fn useragent_delete_grant(&mut self, grant_id: i32) -> Result<(), Error> { + self.ensure_unsealed().await?; + let mut conn = self.db.get().await.map_err(DatabaseError::from)?; - 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(&mut conn) - .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?; + integrity::sign_entity(conn, &keyholder, &signed) + .await + .map_err(|_| diesel::result::Error::RollbackTransaction)?; + + diesel::result::QueryResult::Ok(()) + }) + }) + .await + .map_err(DatabaseError::from)?; + Ok(()) } #[message] pub async fn useragent_list_grants(&mut self) -> Result>, Error> { - Ok(self - .engine - .list_all_grants() - .await - .map_err(DatabaseError::from)?) + Ok(self.engine.list_all_grants().await?) } #[message] diff --git a/server/crates/arbiter-server/src/actors/keyholder/mod.rs b/server/crates/arbiter-server/src/actors/keyholder/mod.rs index 0ef0d82..407e122 100644 --- a/server/crates/arbiter-server/src/actors/keyholder/mod.rs +++ b/server/crates/arbiter-server/src/actors/keyholder/mod.rs @@ -4,7 +4,9 @@ use diesel::{ dsl::{insert_into, update}, }; use diesel_async::{AsyncConnection, RunQueryDsl}; +use hmac::{Hmac, Mac as _}; use kameo::{Actor, Reply, messages}; +use sha2::Sha256; use strum::{EnumDiscriminants, IntoDiscriminant}; use tracing::{error, info}; @@ -24,6 +26,13 @@ use crate::{ }, safe_cell::SafeCellHandle as _, }; +use encryption::v1::{self, KeyCell, Nonce}; + +type HmacSha256 = Hmac; + +const INTEGRITY_SUBKEY_TAG: &[u8] = b"arbiter/db-integrity-key/v1"; + +pub mod encryption; #[derive(Default, EnumDiscriminants)] #[strum_discriminants(derive(Reply), vis(pub), name(KeyHolderState))] @@ -133,6 +142,19 @@ impl KeyHolder { Ok(nonce) } + fn derive_integrity_key(root_key: &mut KeyCell) -> [u8; 32] { + root_key.0.read_inline(|root_key_bytes| { + let mut hmac = match HmacSha256::new_from_slice(root_key_bytes.as_slice()) { + Ok(v) => v, + Err(_) => unreachable!("HMAC accepts keys of any size"), + }; + hmac.update(INTEGRITY_SUBKEY_TAG); + let mut out = [0u8; 32]; + out.copy_from_slice(&hmac.finalize().into_bytes()); + out + }) + } + #[message] pub async fn bootstrap(&mut self, seal_key_raw: SafeCell>) -> Result<(), Error> { if !matches!(self.state, State::Unbootstrapped) { @@ -339,6 +361,59 @@ impl KeyHolder { self.state.discriminant() } + #[message] + pub fn sign_integrity(&mut self, mac_input: Vec) -> Result<(i32, Vec), Error> { + let State::Unsealed { + root_key, + root_key_history_id, + } = &mut self.state + else { + return Err(Error::NotBootstrapped); + }; + + let integrity_key = Self::derive_integrity_key(root_key); + + let mut hmac = match HmacSha256::new_from_slice(&integrity_key) { + Ok(v) => v, + Err(_) => unreachable!("HMAC accepts keys of any size"), + }; + hmac.update(&root_key_history_id.to_be_bytes()); + hmac.update(&mac_input); + + let mac = hmac.finalize().into_bytes().to_vec(); + Ok((*root_key_history_id, mac)) + } + + #[message] + pub fn verify_integrity( + &mut self, + mac_input: Vec, + expected_mac: Vec, + key_version: i32, + ) -> Result { + let State::Unsealed { + root_key, + root_key_history_id, + } = &mut self.state + else { + return Err(Error::NotBootstrapped); + }; + + if *root_key_history_id != key_version { + return Ok(false); + } + + let integrity_key = Self::derive_integrity_key(root_key); + let mut hmac = match HmacSha256::new_from_slice(&integrity_key) { + Ok(v) => v, + Err(_) => unreachable!("HMAC accepts keys of any size"), + }; + hmac.update(&key_version.to_be_bytes()); + hmac.update(&mac_input); + + Ok(hmac.verify_slice(&expected_mac).is_ok()) + } + #[message] pub fn seal(&mut self) -> Result<(), Error> { let State::Unsealed { 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 382dec5..f65a98d 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 @@ -120,6 +120,15 @@ pub enum SignTransactionError { Internal, } +#[derive(Debug, Error)] +pub enum GrantMutationError { + #[error("Vault is sealed")] + VaultSealed, + + #[error("Internal grant mutation error")] + Internal, +} + #[messages] impl UserAgentSession { #[message] @@ -331,7 +340,7 @@ impl UserAgentSession { &mut self, basic: crate::evm::policies::SharedGrantSettings, grant: crate::evm::policies::SpecificGrant, - ) -> Result { + ) -> Result { match self .props .actors @@ -340,15 +349,21 @@ impl UserAgentSession { .await { Ok(grant_id) => Ok(grant_id), + Err(SendError::HandlerError(crate::actors::evm::Error::VaultSealed)) => { + Err(GrantMutationError::VaultSealed) + } Err(err) => { error!(?err, "EVM grant create failed"); - Err(Error::internal("Failed to create EVM grant")) + Err(GrantMutationError::Internal) } } } #[message] - pub(crate) async fn handle_grant_delete(&mut self, grant_id: i32) -> Result<(), Error> { + pub(crate) async fn handle_grant_delete( + &mut self, + grant_id: i32, + ) -> Result<(), GrantMutationError> { match self .props .actors @@ -357,9 +372,12 @@ impl UserAgentSession { .await { Ok(()) => Ok(()), + Err(SendError::HandlerError(crate::actors::evm::Error::VaultSealed)) => { + Err(GrantMutationError::VaultSealed) + } Err(err) => { error!(?err, "EVM grant delete failed"); - Err(Error::internal("Failed to delete EVM grant")) + Err(GrantMutationError::Internal) } } } diff --git a/server/crates/arbiter-server/src/db/models.rs b/server/crates/arbiter-server/src/db/models.rs index 6fb171c..a67629a 100644 --- a/server/crates/arbiter-server/src/db/models.rs +++ b/server/crates/arbiter-server/src/db/models.rs @@ -5,7 +5,7 @@ use crate::db::schema::{ self, aead_encrypted, arbiter_settings, evm_basic_grant, evm_ether_transfer_grant, evm_ether_transfer_grant_target, evm_ether_transfer_limit, evm_token_transfer_grant, evm_token_transfer_log, evm_token_transfer_volume_limit, evm_transaction_log, evm_wallet, - root_key_history, tls_history, + integrity_envelope, root_key_history, tls_history, }; use chrono::{DateTime, Utc}; use diesel::{prelude::*, sqlite::Sqlite}; @@ -377,3 +377,22 @@ pub struct EvmTokenTransferLog { pub value: Vec, pub created_at: SqliteTimestamp, } + +#[derive(Models, Queryable, Debug, Insertable, Selectable)] +#[diesel(table_name = integrity_envelope, check_for_backend(Sqlite))] +#[view( + NewIntegrityEnvelope, + derive(Insertable), + omit(id, signed_at, created_at), + attributes_with = "deriveless" +)] +pub struct IntegrityEnvelope { + pub id: i32, + pub entity_kind: String, + pub entity_id: Vec, + pub payload_version: i32, + pub key_version: i32, + pub mac: Vec, + pub signed_at: SqliteTimestamp, + pub created_at: SqliteTimestamp, +} diff --git a/server/crates/arbiter-server/src/db/schema.rs b/server/crates/arbiter-server/src/db/schema.rs index 88bdef3..41a1fb9 100644 --- a/server/crates/arbiter-server/src/db/schema.rs +++ b/server/crates/arbiter-server/src/db/schema.rs @@ -139,6 +139,19 @@ diesel::table! { } } +diesel::table! { + integrity_envelope (id) { + id -> Integer, + entity_kind -> Text, + entity_id -> Binary, + payload_version -> Integer, + key_version -> Integer, + mac -> Binary, + signed_at -> Integer, + created_at -> Integer, + } +} + diesel::table! { program_client (id) { id -> Integer, @@ -220,6 +233,7 @@ diesel::allow_tables_to_appear_in_same_query!( evm_transaction_log, evm_wallet, evm_wallet_access, + integrity_envelope, program_client, root_key_history, tls_history, diff --git a/server/crates/arbiter-server/src/evm/mod.rs b/server/crates/arbiter-server/src/evm/mod.rs index d840d81..8d3ec9a 100644 --- a/server/crates/arbiter-server/src/evm/mod.rs +++ b/server/crates/arbiter-server/src/evm/mod.rs @@ -8,8 +8,10 @@ use alloy::{ use chrono::Utc; use diesel::{ExpressionMethods as _, QueryDsl as _, QueryResult, insert_into, sqlite::Sqlite}; use diesel_async::{AsyncConnection, RunQueryDsl}; +use kameo::actor::ActorRef; use crate::{ + actors::keyholder::KeyHolder, db::{ self, DatabaseError, models::{ @@ -22,6 +24,7 @@ use crate::{ SpecificGrant, SpecificMeaning, ether_transfer::EtherTransfer, token_transfers::TokenTransfer, }, + integrity, }; pub mod policies; @@ -36,6 +39,10 @@ pub enum PolicyError { Violations(Vec), #[error("No matching grant found")] NoMatchingGrant, + + #[error("Integrity error: {0}")] + #[diagnostic(code(arbiter_server::evm::policy_error::integrity))] + Integrity(#[from] integrity::Error), } #[derive(Debug, thiserror::Error)] @@ -115,6 +122,7 @@ async fn check_shared_constraints( // Supporting only EIP-1559 transactions for now, but we can easily extend this to support legacy transactions if needed pub struct Engine { db: db::DatabasePool, + keyholder: ActorRef, } impl Engine { @@ -123,7 +131,10 @@ impl Engine { context: EvalContext, meaning: &P::Meaning, run_kind: RunKind, - ) -> Result<(), PolicyError> { + ) -> Result<(), PolicyError> + where + P::Settings: Clone, + { let mut conn = self.db.get().await.map_err(DatabaseError::from)?; let grant = P::try_find_grant(&context, &mut conn) @@ -131,6 +142,14 @@ impl Engine { .map_err(DatabaseError::from)? .ok_or(PolicyError::NoMatchingGrant)?; + let signed_grant = integrity::evm::SignedEvmGrant::from_active_grant(&Grant { + id: grant.id, + shared_grant_id: grant.shared_grant_id, + shared: grant.shared.clone(), + settings: grant.settings.clone().into(), + }); + integrity::verify_entity(&mut conn, &self.keyholder, &signed_grant).await?; + let mut violations = check_shared_constraints(&context, &grant.shared, grant.shared_grant_id, &mut conn) .await @@ -143,7 +162,9 @@ impl Engine { if !violations.is_empty() { return Err(PolicyError::Violations(violations)); - } else if run_kind == RunKind::Execution { + } + + if run_kind == RunKind::Execution { conn.transaction(|conn| { Box::pin(async move { let log_id: i32 = insert_into(evm_transaction_log::table) @@ -172,15 +193,19 @@ impl Engine { } impl Engine { - pub fn new(db: db::DatabasePool) -> Self { - Self { db } + pub fn new(db: db::DatabasePool, keyholder: ActorRef) -> Self { + Self { db, keyholder } } pub async fn create_grant( &self, full_grant: FullGrant, - ) -> Result { + ) -> Result + where + P::Settings: Clone, + { let mut conn = self.db.get().await?; + let keyholder = self.keyholder.clone(); let id = conn .transaction(|conn| { @@ -217,7 +242,20 @@ impl Engine { .get_result(conn) .await?; - P::create_grant(&basic_grant, &full_grant.specific, conn).await + P::create_grant(&basic_grant, &full_grant.specific, conn).await?; + + let signed_grant = integrity::evm::SignedEvmGrant { + basic_grant_id: basic_grant.id, + shared: full_grant.basic.clone(), + specific: full_grant.specific.clone().into(), + revoked_at: basic_grant.revoked_at.map(Into::into), + }; + + integrity::sign_entity(conn, &keyholder, &signed_grant) + .await + .map_err(|_| diesel::result::Error::RollbackTransaction)?; + + QueryResult::Ok(basic_grant.id) }) }) .await?; @@ -253,6 +291,16 @@ impl Engine { }), ); + for grant in &grants { + let signed = integrity::evm::SignedEvmGrant::from_active_grant(grant); + integrity::verify_entity(&mut conn, &self.keyholder, &signed) + .await + .map_err(|err| match err { + integrity::Error::Database(db_err) => db_err, + _ => DatabaseError::Connection(diesel::result::Error::RollbackTransaction), + })?; + } + Ok(grants) } diff --git a/server/crates/arbiter-server/src/evm/policies.rs b/server/crates/arbiter-server/src/evm/policies.rs index f0e7796..10ab304 100644 --- a/server/crates/arbiter-server/src/evm/policies.rs +++ b/server/crates/arbiter-server/src/evm/policies.rs @@ -151,7 +151,7 @@ pub struct SharedGrantSettings { } impl SharedGrantSettings { - fn try_from_model(model: EvmBasicGrant) -> QueryResult { + pub(crate) fn try_from_model(model: EvmBasicGrant) -> QueryResult { Ok(Self { wallet_access_id: model.wallet_access_id, 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 diff --git a/server/crates/arbiter-server/src/grpc/common/outbound.rs b/server/crates/arbiter-server/src/grpc/common/outbound.rs index 6ca8916..1b500c9 100644 --- a/server/crates/arbiter-server/src/grpc/common/outbound.rs +++ b/server/crates/arbiter-server/src/grpc/common/outbound.rs @@ -108,12 +108,12 @@ impl Convert for VetError { violations: violations.into_iter().map(Convert::convert).collect(), }) } - PolicyError::Database(_) => { + PolicyError::Database(_)| PolicyError::Integrity(_) => { return EvmSignTransactionResult::Error(ProtoEvmError::Internal.into()); } }, }; - EvmSignTransactionResult::EvalError(ProtoTransactionEvalError { kind: Some(kind) }.into()) + EvmSignTransactionResult::EvalError(ProtoTransactionEvalError { kind: Some(kind) }) } } 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 a65b220..07b4b3c 100644 --- a/server/crates/arbiter-server/src/grpc/user_agent/evm.rs +++ b/server/crates/arbiter-server/src/grpc/user_agent/evm.rs @@ -26,8 +26,8 @@ use crate::{ actors::user_agent::{ UserAgentSession, session::connection::{ - HandleEvmWalletCreate, HandleEvmWalletList, HandleGrantCreate, HandleGrantDelete, - HandleGrantList, HandleSignTransaction, + GrantMutationError, HandleEvmWalletCreate, HandleEvmWalletList, HandleGrantCreate, + HandleGrantDelete, HandleGrantList, HandleSignTransaction, SignTransactionError as SessionSignTransactionError, }, }, @@ -114,7 +114,7 @@ async fn handle_grant_list( grants: grants .into_iter() .map(|grant| GrantEntry { - id: grant.id, + id: grant.shared_grant_id, wallet_access_id: grant.shared.wallet_access_id, shared: Some(grant.shared.convert()), specific: Some(grant.settings.convert()), @@ -148,6 +148,9 @@ async fn handle_grant_create( let result = match actor.ask(HandleGrantCreate { basic, grant }).await { Ok(grant_id) => EvmGrantCreateResult::GrantId(grant_id), + Err(kameo::error::SendError::HandlerError(GrantMutationError::VaultSealed)) => { + EvmGrantCreateResult::Error(ProtoEvmError::VaultSealed.into()) + } Err(err) => { warn!(error = ?err, "Failed to create EVM grant"); EvmGrantCreateResult::Error(ProtoEvmError::Internal.into()) @@ -171,6 +174,9 @@ async fn handle_grant_delete( .await { Ok(()) => EvmGrantDeleteResult::Ok(()), + Err(kameo::error::SendError::HandlerError(GrantMutationError::VaultSealed)) => { + EvmGrantDeleteResult::Error(ProtoEvmError::VaultSealed.into()) + } Err(err) => { warn!(error = ?err, "Failed to delete EVM grant"); EvmGrantDeleteResult::Error(ProtoEvmError::Internal.into()) diff --git a/server/crates/arbiter-server/src/integrity/evm.rs b/server/crates/arbiter-server/src/integrity/evm.rs new file mode 100644 index 0000000..4f8e44b --- /dev/null +++ b/server/crates/arbiter-server/src/integrity/evm.rs @@ -0,0 +1,259 @@ +use alloy::primitives::Address; +use arbiter_proto::proto::integrity::{ + IntegrityEtherTransferSettings, IntegrityEvmGrantPayloadV1, IntegritySharedGrantSettings, + IntegritySpecificGrant, IntegrityTokenTransferSettings, IntegrityTransactionRateLimit, + IntegrityVolumeRateLimit, integrity_specific_grant, +}; +use chrono::{DateTime, Utc}; +use diesel::sqlite::Sqlite; +use diesel::{ExpressionMethods as _, OptionalExtension as _, QueryDsl, SelectableHelper as _}; +use diesel_async::{AsyncConnection, RunQueryDsl}; +use prost::Message; +use prost_types::Timestamp; + +use crate::{ + db::{models, schema}, + evm::policies::{Grant, SharedGrantSettings, SpecificGrant, VolumeRateLimit}, + integrity::IntegrityEntity, +}; + +pub const EVM_GRANT_ENTITY_KIND: &str = "evm_grant"; + +#[derive(Debug, Clone)] +pub struct SignedEvmGrant { + pub basic_grant_id: i32, + pub shared: SharedGrantSettings, + pub specific: SpecificGrant, + pub revoked_at: Option>, +} + +impl SignedEvmGrant { + pub fn from_active_grant(grant: &Grant) -> Self { + Self { + basic_grant_id: grant.shared_grant_id, + shared: grant.shared.clone(), + specific: grant.settings.clone(), + revoked_at: None, + } + } +} + +fn timestamp(value: DateTime) -> Timestamp { + Timestamp { + seconds: value.timestamp(), + nanos: 0, + } +} + +fn encode_shared(shared: &SharedGrantSettings) -> IntegritySharedGrantSettings { + IntegritySharedGrantSettings { + wallet_access_id: shared.wallet_access_id, + chain_id: shared.chain, + valid_from: shared.valid_from.map(timestamp), + valid_until: shared.valid_until.map(timestamp), + max_gas_fee_per_gas: shared + .max_gas_fee_per_gas + .map(|v| v.to_le_bytes::<32>().to_vec()), + max_priority_fee_per_gas: shared + .max_priority_fee_per_gas + .map(|v| v.to_le_bytes::<32>().to_vec()), + rate_limit: shared + .rate_limit + .as_ref() + .map(|rl| IntegrityTransactionRateLimit { + count: rl.count, + window_secs: rl.window.num_seconds(), + }), + } +} + +fn encode_volume_limit(limit: &VolumeRateLimit) -> IntegrityVolumeRateLimit { + IntegrityVolumeRateLimit { + max_volume: limit.max_volume.to_le_bytes::<32>().to_vec(), + window_secs: limit.window.num_seconds(), + } +} + +fn try_bytes_to_u256(bytes: &[u8]) -> diesel::result::QueryResult { + let bytes: [u8; 32] = bytes.try_into().map_err(|_| { + diesel::result::Error::DeserializationError( + format!("Expected 32-byte U256 payload, got {}", bytes.len()).into(), + ) + })?; + Ok(alloy::primitives::U256::from_le_bytes(bytes)) +} + +fn encode_specific(specific: &SpecificGrant) -> IntegritySpecificGrant { + let grant = match specific { + SpecificGrant::EtherTransfer(settings) => { + let mut targets: Vec> = + settings.target.iter().map(|addr| addr.to_vec()).collect(); + targets.sort_unstable(); + + integrity_specific_grant::Grant::EtherTransfer(IntegrityEtherTransferSettings { + targets, + limit: Some(encode_volume_limit(&settings.limit)), + }) + } + SpecificGrant::TokenTransfer(settings) => { + let mut volume_limits: Vec = settings + .volume_limits + .iter() + .map(encode_volume_limit) + .collect(); + volume_limits.sort_by(|left, right| { + left.window_secs + .cmp(&right.window_secs) + .then_with(|| left.max_volume.cmp(&right.max_volume)) + }); + + integrity_specific_grant::Grant::TokenTransfer(IntegrityTokenTransferSettings { + token_contract: settings.token_contract.to_vec(), + target: settings.target.map(|a| a.to_vec()), + volume_limits, + }) + } + }; + + IntegritySpecificGrant { grant: Some(grant) } +} + +impl IntegrityEntity for SignedEvmGrant { + fn entity_kind(&self) -> &'static str { + EVM_GRANT_ENTITY_KIND + } + + fn entity_id_bytes(&self) -> Vec { + self.basic_grant_id.to_be_bytes().to_vec() + } + + fn payload_version(&self) -> i32 { + 1 + } + + fn canonical_payload_bytes(&self) -> Vec { + IntegrityEvmGrantPayloadV1 { + basic_grant_id: self.basic_grant_id, + shared: Some(encode_shared(&self.shared)), + specific: Some(encode_specific(&self.specific)), + revoked_at: self.revoked_at.map(timestamp), + } + .encode_to_vec() + } +} + +pub async fn load_signed_grant_by_basic_id( + conn: &mut impl AsyncConnection, + basic_grant_id: i32, +) -> diesel::result::QueryResult { + let basic: models::EvmBasicGrant = schema::evm_basic_grant::table + .filter(schema::evm_basic_grant::id.eq(basic_grant_id)) + .select(models::EvmBasicGrant::as_select()) + .first(conn) + .await?; + + let specific_token: Option = + schema::evm_token_transfer_grant::table + .filter(schema::evm_token_transfer_grant::basic_grant_id.eq(basic_grant_id)) + .select(models::EvmTokenTransferGrant::as_select()) + .first(conn) + .await + .optional()?; + + let revoked_at = basic.revoked_at.clone().map(Into::into); + let shared = SharedGrantSettings::try_from_model(basic)?; + + if let Some(token) = specific_token { + let limits: Vec = + schema::evm_token_transfer_volume_limit::table + .filter(schema::evm_token_transfer_volume_limit::grant_id.eq(token.id)) + .select(models::EvmTokenTransferVolumeLimit::as_select()) + .load(conn) + .await?; + + let token_contract: [u8; 20] = token.token_contract.try_into().map_err(|_| { + diesel::result::Error::DeserializationError( + "Invalid token contract address length".into(), + ) + })?; + + let target = match token.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 volume_limits = limits + .into_iter() + .map(|row| { + Ok(VolumeRateLimit { + max_volume: try_bytes_to_u256(&row.max_volume)?, + window: chrono::Duration::seconds(row.window_secs as i64), + }) + }) + .collect::>>()?; + + return Ok(SignedEvmGrant { + basic_grant_id, + shared, + specific: SpecificGrant::TokenTransfer( + crate::evm::policies::token_transfers::Settings { + token_contract: Address::from(token_contract), + target, + volume_limits, + }, + ), + revoked_at, + }); + } + + let ether: models::EvmEtherTransferGrant = schema::evm_ether_transfer_grant::table + .filter(schema::evm_ether_transfer_grant::basic_grant_id.eq(basic_grant_id)) + .select(models::EvmEtherTransferGrant::as_select()) + .first(conn) + .await?; + + let targets_rows: Vec = + schema::evm_ether_transfer_grant_target::table + .filter(schema::evm_ether_transfer_grant_target::grant_id.eq(ether.id)) + .select(models::EvmEtherTransferGrantTarget::as_select()) + .load(conn) + .await?; + + let limit: models::EvmEtherTransferLimit = schema::evm_ether_transfer_limit::table + .filter(schema::evm_ether_transfer_limit::id.eq(ether.limit_id)) + .select(models::EvmEtherTransferLimit::as_select()) + .first(conn) + .await?; + + let targets = targets_rows + .into_iter() + .map(|row| { + let arr: [u8; 20] = row.address.try_into().map_err(|_| { + diesel::result::Error::DeserializationError( + "Invalid ether target address length".into(), + ) + })?; + Ok(Address::from(arr)) + }) + .collect::>>()?; + + Ok(SignedEvmGrant { + basic_grant_id, + shared, + specific: SpecificGrant::EtherTransfer(crate::evm::policies::ether_transfer::Settings { + target: targets, + limit: VolumeRateLimit { + max_volume: try_bytes_to_u256(&limit.max_volume)?, + window: chrono::Duration::seconds(limit.window_secs as i64), + }, + }), + revoked_at, + }) +} diff --git a/server/crates/arbiter-server/src/integrity/mod.rs b/server/crates/arbiter-server/src/integrity/mod.rs new file mode 100644 index 0000000..ffdd132 --- /dev/null +++ b/server/crates/arbiter-server/src/integrity/mod.rs @@ -0,0 +1,307 @@ +use diesel::{ExpressionMethods as _, QueryDsl, dsl::insert_into, sqlite::Sqlite}; +use diesel_async::{AsyncConnection, RunQueryDsl}; +use kameo::actor::ActorRef; +use sha2::{Digest as _, Sha256}; + +use crate::{ + actors::keyholder::{KeyHolder, SignIntegrity, VerifyIntegrity}, + db::{ + self, + models::{IntegrityEnvelope, NewIntegrityEnvelope}, + schema::integrity_envelope, + }, +}; + +pub const CURRENT_PAYLOAD_VERSION: i32 = 1; + +pub mod evm; + +pub trait IntegrityEntity { + fn entity_kind(&self) -> &'static str; + fn entity_id_bytes(&self) -> Vec; + fn payload_version(&self) -> i32; + fn canonical_payload_bytes(&self) -> Vec; +} + +#[derive(Debug, thiserror::Error, miette::Diagnostic)] +pub enum Error { + #[error("Database error: {0}")] + #[diagnostic(code(arbiter::integrity::database))] + Database(#[from] db::DatabaseError), + + #[error("KeyHolder error: {0}")] + #[diagnostic(code(arbiter::integrity::keyholder))] + Keyholder(#[from] crate::actors::keyholder::Error), + + #[error("KeyHolder mailbox error")] + #[diagnostic(code(arbiter::integrity::keyholder_send))] + KeyholderSend, + + #[error("Integrity envelope is missing for entity {entity_kind}")] + #[diagnostic(code(arbiter::integrity::missing_envelope))] + MissingEnvelope { entity_kind: &'static str }, + + #[error( + "Integrity payload version mismatch for entity {entity_kind}: expected {expected}, found {found}" + )] + #[diagnostic(code(arbiter::integrity::payload_version_mismatch))] + PayloadVersionMismatch { + entity_kind: &'static str, + expected: i32, + found: i32, + }, + + #[error("Integrity MAC mismatch for entity {entity_kind}")] + #[diagnostic(code(arbiter::integrity::mac_mismatch))] + MacMismatch { entity_kind: &'static str }, +} + +fn payload_hash(payload: &[u8]) -> [u8; 32] { + Sha256::digest(payload).into() +} + +fn push_len_prefixed(out: &mut Vec, bytes: &[u8]) { + out.extend_from_slice(&(bytes.len() as u32).to_be_bytes()); + out.extend_from_slice(bytes); +} + +fn build_mac_input( + entity_kind: &str, + entity_id: &[u8], + payload_version: i32, + payload_hash: &[u8; 32], +) -> Vec { + let mut out = Vec::with_capacity(8 + entity_kind.len() + entity_id.len() + 32); + push_len_prefixed(&mut out, entity_kind.as_bytes()); + push_len_prefixed(&mut out, entity_id); + out.extend_from_slice(&payload_version.to_be_bytes()); + out.extend_from_slice(payload_hash); + out +} + +pub async fn sign_entity( + conn: &mut impl AsyncConnection, + keyholder: &ActorRef, + entity: &impl IntegrityEntity, +) -> Result<(), Error> { + let entity_kind = entity.entity_kind(); + let entity_id = entity.entity_id_bytes(); + let payload_version = entity.payload_version(); + let payload = entity.canonical_payload_bytes(); + let payload_hash = payload_hash(&payload); + let mac_input = build_mac_input(entity_kind, &entity_id, payload_version, &payload_hash); + + let (key_version, mac) = keyholder + .ask(SignIntegrity { mac_input }) + .await + .map_err(|err| match err { + kameo::error::SendError::HandlerError(inner) => Error::Keyholder(inner), + _ => Error::KeyholderSend, + })?; + + diesel::delete(integrity_envelope::table) + .filter(integrity_envelope::entity_kind.eq(entity_kind)) + .filter(integrity_envelope::entity_id.eq(&entity_id)) + .execute(conn) + .await + .map_err(db::DatabaseError::from)?; + + insert_into(integrity_envelope::table) + .values(NewIntegrityEnvelope { + entity_kind: entity_kind.to_string(), + entity_id, + payload_version, + key_version, + mac, + }) + .execute(conn) + .await + .map_err(db::DatabaseError::from)?; + + Ok(()) +} + +pub async fn verify_entity( + conn: &mut impl AsyncConnection, + keyholder: &ActorRef, + entity: &impl IntegrityEntity, +) -> Result<(), Error> { + let entity_kind = entity.entity_kind(); + let entity_id = entity.entity_id_bytes(); + let expected_payload_version = entity.payload_version(); + + let envelope: IntegrityEnvelope = integrity_envelope::table + .filter(integrity_envelope::entity_kind.eq(entity_kind)) + .filter(integrity_envelope::entity_id.eq(&entity_id)) + .first(conn) + .await + .map_err(|err| match err { + diesel::result::Error::NotFound => Error::MissingEnvelope { entity_kind }, + other => Error::Database(db::DatabaseError::from(other)), + })?; + + if envelope.payload_version != expected_payload_version { + return Err(Error::PayloadVersionMismatch { + entity_kind, + expected: expected_payload_version, + found: envelope.payload_version, + }); + } + + let payload = entity.canonical_payload_bytes(); + let payload_hash = payload_hash(&payload); + let mac_input = build_mac_input( + entity_kind, + &entity_id, + envelope.payload_version, + &payload_hash, + ); + + let ok = keyholder + .ask(VerifyIntegrity { + mac_input, + expected_mac: envelope.mac, + key_version: envelope.key_version, + }) + .await + .map_err(|err| match err { + kameo::error::SendError::HandlerError(inner) => Error::Keyholder(inner), + _ => Error::KeyholderSend, + })?; + + if !ok { + return Err(Error::MacMismatch { entity_kind }); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use diesel::{ExpressionMethods as _, QueryDsl}; + use diesel_async::RunQueryDsl; + use kameo::{actor::ActorRef, prelude::Spawn}; + + use crate::{ + actors::keyholder::{Bootstrap, KeyHolder}, + db::{self, schema}, + safe_cell::{SafeCell, SafeCellHandle as _}, + }; + + use super::{Error, IntegrityEntity, sign_entity, verify_entity}; + + #[derive(Clone)] + struct DummyEntity { + id: i32, + payload_version: i32, + payload: Vec, + } + + impl IntegrityEntity for DummyEntity { + fn entity_kind(&self) -> &'static str { + "dummy_entity" + } + + fn entity_id_bytes(&self) -> Vec { + self.id.to_be_bytes().to_vec() + } + + fn payload_version(&self) -> i32 { + self.payload_version + } + + fn canonical_payload_bytes(&self) -> Vec { + self.payload.clone() + } + } + + 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 sign_writes_envelope_and_verify_passes() { + let db = db::create_test_pool().await; + let keyholder = bootstrapped_keyholder(&db).await; + let mut conn = db.get().await.unwrap(); + + let entity = DummyEntity { + id: 7, + payload_version: 1, + payload: b"payload-v1".to_vec(), + }; + + sign_entity(&mut conn, &keyholder, &entity).await.unwrap(); + + let count: i64 = schema::integrity_envelope::table + .filter(schema::integrity_envelope::entity_kind.eq("dummy_entity")) + .filter(schema::integrity_envelope::entity_id.eq(entity.entity_id_bytes())) + .count() + .get_result(&mut conn) + .await + .unwrap(); + + assert_eq!(count, 1, "envelope row must be created exactly once"); + verify_entity(&mut conn, &keyholder, &entity).await.unwrap(); + } + + #[tokio::test] + async fn tampered_mac_fails_verification() { + let db = db::create_test_pool().await; + let keyholder = bootstrapped_keyholder(&db).await; + let mut conn = db.get().await.unwrap(); + + let entity = DummyEntity { + id: 11, + payload_version: 1, + payload: b"payload-v1".to_vec(), + }; + + sign_entity(&mut conn, &keyholder, &entity).await.unwrap(); + + diesel::update(schema::integrity_envelope::table) + .filter(schema::integrity_envelope::entity_kind.eq("dummy_entity")) + .filter(schema::integrity_envelope::entity_id.eq(entity.entity_id_bytes())) + .set(schema::integrity_envelope::mac.eq(vec![0u8; 32])) + .execute(&mut conn) + .await + .unwrap(); + + let err = verify_entity(&mut conn, &keyholder, &entity) + .await + .unwrap_err(); + assert!(matches!(err, Error::MacMismatch { .. })); + } + + #[tokio::test] + async fn changed_payload_fails_verification() { + let db = db::create_test_pool().await; + let keyholder = bootstrapped_keyholder(&db).await; + let mut conn = db.get().await.unwrap(); + + let entity = DummyEntity { + id: 21, + payload_version: 1, + payload: b"payload-v1".to_vec(), + }; + + sign_entity(&mut conn, &keyholder, &entity).await.unwrap(); + + let tampered = DummyEntity { + payload: b"payload-v1-but-tampered".to_vec(), + ..entity + }; + + let err = verify_entity(&mut conn, &keyholder, &tampered) + .await + .unwrap_err(); + assert!(matches!(err, Error::MacMismatch { .. })); + } +} diff --git a/server/crates/arbiter-server/src/lib.rs b/server/crates/arbiter-server/src/lib.rs index e551182..6baf5c4 100644 --- a/server/crates/arbiter-server/src/lib.rs +++ b/server/crates/arbiter-server/src/lib.rs @@ -7,6 +7,7 @@ pub mod crypto; pub mod db; pub mod evm; pub mod grpc; +pub mod integrity; pub mod safe_cell; pub mod utils;