From 4057c1fc12076fd6980cdc761fe84c280b672add Mon Sep 17 00:00:00 2001 From: CleverWild Date: Sat, 4 Apr 2026 21:52:50 +0200 Subject: [PATCH 1/5] 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; From aeed664e9accade8d3ef6d17d96d1ae80043c660 Mon Sep 17 00:00:00 2001 From: CleverWild Date: Sun, 5 Apr 2026 00:53:36 +0200 Subject: [PATCH 2/5] chore: inline integrity proto types --- protobufs/integrity.proto | 50 ----------- server/crates/arbiter-proto/build.rs | 1 - .../arbiter-server/src/integrity/evm.rs | 87 +++++++++++++++++-- 3 files changed, 82 insertions(+), 56 deletions(-) delete mode 100644 protobufs/integrity.proto diff --git a/protobufs/integrity.proto b/protobufs/integrity.proto deleted file mode 100644 index fa2c2fc..0000000 --- a/protobufs/integrity.proto +++ /dev/null @@ -1,50 +0,0 @@ -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/crates/arbiter-proto/build.rs b/server/crates/arbiter-proto/build.rs index 28a8048..657066c 100644 --- a/server/crates/arbiter-proto/build.rs +++ b/server/crates/arbiter-proto/build.rs @@ -13,7 +13,6 @@ 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-server/src/integrity/evm.rs b/server/crates/arbiter-server/src/integrity/evm.rs index 4f8e44b..8a82c34 100644 --- a/server/crates/arbiter-server/src/integrity/evm.rs +++ b/server/crates/arbiter-server/src/integrity/evm.rs @@ -1,9 +1,4 @@ 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 _}; @@ -19,6 +14,88 @@ use crate::{ pub const EVM_GRANT_ENTITY_KIND: &str = "evm_grant"; +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct IntegrityVolumeRateLimit { + #[prost(bytes, tag = "1")] + pub max_volume: Vec, + #[prost(int64, tag = "2")] + pub window_secs: i64, +} + +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct IntegrityTransactionRateLimit { + #[prost(uint32, tag = "1")] + pub count: u32, + #[prost(int64, tag = "2")] + pub window_secs: i64, +} + +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct IntegritySharedGrantSettings { + #[prost(int32, tag = "1")] + pub wallet_access_id: i32, + #[prost(uint64, tag = "2")] + pub chain_id: u64, + #[prost(message, optional, tag = "3")] + pub valid_from: Option<::prost_types::Timestamp>, + #[prost(message, optional, tag = "4")] + pub valid_until: Option<::prost_types::Timestamp>, + #[prost(bytes, optional, tag = "5")] + pub max_gas_fee_per_gas: Option>, + #[prost(bytes, optional, tag = "6")] + pub max_priority_fee_per_gas: Option>, + #[prost(message, optional, tag = "7")] + pub rate_limit: Option, +} + +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct IntegrityEtherTransferSettings { + #[prost(bytes, repeated, tag = "1")] + pub targets: Vec>, + #[prost(message, optional, tag = "2")] + pub limit: Option, +} + +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct IntegrityTokenTransferSettings { + #[prost(bytes, tag = "1")] + pub token_contract: Vec, + #[prost(bytes, optional, tag = "2")] + pub target: Option>, + #[prost(message, repeated, tag = "3")] + pub volume_limits: Vec, +} + +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct IntegritySpecificGrant { + #[prost(oneof = "integrity_specific_grant::Grant", tags = "1, 2")] + pub grant: Option, +} + +pub mod integrity_specific_grant { + use super::*; + + #[derive(Clone, PartialEq, ::prost::Oneof)] + pub enum Grant { + #[prost(message, tag = "1")] + EtherTransfer(IntegrityEtherTransferSettings), + #[prost(message, tag = "2")] + TokenTransfer(IntegrityTokenTransferSettings), + } +} + +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct IntegrityEvmGrantPayloadV1 { + #[prost(int32, tag = "1")] + pub basic_grant_id: i32, + #[prost(message, optional, tag = "2")] + pub shared: Option, + #[prost(message, optional, tag = "3")] + pub specific: Option, + #[prost(message, optional, tag = "4")] + pub revoked_at: Option<::prost_types::Timestamp>, +} + #[derive(Debug, Clone)] pub struct SignedEvmGrant { pub basic_grant_id: i32, From 9fab945a000b83a1a4e46deef36afffd56739e35 Mon Sep 17 00:00:00 2001 From: hdbg Date: Sun, 5 Apr 2026 10:44:45 +0200 Subject: [PATCH 3/5] fix(server): remove stale mentions of miette --- server/crates/arbiter-server/src/actors/evm/mod.rs | 1 - server/crates/arbiter-server/src/evm/mod.rs | 1 - server/crates/arbiter-server/src/integrity/mod.rs | 8 +------- 3 files changed, 1 insertion(+), 9 deletions(-) diff --git a/server/crates/arbiter-server/src/actors/evm/mod.rs b/server/crates/arbiter-server/src/actors/evm/mod.rs index 0fc7ab8..23428f4 100644 --- a/server/crates/arbiter-server/src/actors/evm/mod.rs +++ b/server/crates/arbiter-server/src/actors/evm/mod.rs @@ -59,7 +59,6 @@ pub enum Error { Database(#[from] DatabaseError), #[error("Vault is sealed")] - #[diagnostic(code(arbiter::evm::vault_sealed))] VaultSealed, } diff --git a/server/crates/arbiter-server/src/evm/mod.rs b/server/crates/arbiter-server/src/evm/mod.rs index 8d3ec9a..bad0753 100644 --- a/server/crates/arbiter-server/src/evm/mod.rs +++ b/server/crates/arbiter-server/src/evm/mod.rs @@ -41,7 +41,6 @@ pub enum PolicyError { NoMatchingGrant, #[error("Integrity error: {0}")] - #[diagnostic(code(arbiter_server::evm::policy_error::integrity))] Integrity(#[from] integrity::Error), } diff --git a/server/crates/arbiter-server/src/integrity/mod.rs b/server/crates/arbiter-server/src/integrity/mod.rs index ffdd132..a8e8936 100644 --- a/server/crates/arbiter-server/src/integrity/mod.rs +++ b/server/crates/arbiter-server/src/integrity/mod.rs @@ -23,28 +23,23 @@ pub trait IntegrityEntity { fn canonical_payload_bytes(&self) -> Vec; } -#[derive(Debug, thiserror::Error, miette::Diagnostic)] +#[derive(Debug, thiserror::Error)] 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, @@ -52,7 +47,6 @@ pub enum Error { }, #[error("Integrity MAC mismatch for entity {entity_kind}")] - #[diagnostic(code(arbiter::integrity::mac_mismatch))] MacMismatch { entity_kind: &'static str }, } From b122aa464c2ee906bfe36295c767fd3554113eb6 Mon Sep 17 00:00:00 2001 From: hdbg Date: Sun, 5 Apr 2026 10:47:21 +0200 Subject: [PATCH 4/5] refactor(server): rework envelopes and integrity check --- server/Cargo.lock | 92 ++++- server/Cargo.toml | 2 +- server/crates/arbiter-proto/src/lib.rs | 4 - server/crates/arbiter-server/Cargo.toml | 6 + .../2026-02-14-171124-0000_init/up.sql | 1 - .../arbiter-server/src/actors/evm/mod.rs | 107 +++--- .../src/actors/keyholder/mod.rs | 64 +--- .../src/actors/user_agent/auth.rs | 7 + .../src/actors/user_agent/auth/state.rs | 322 ++++++++-------- .../src/actors/user_agent/mod.rs | 54 ++- .../actors/user_agent/session/connection.rs | 6 - .../src/crypto/encryption/mod.rs | 2 + .../src/crypto/integrity/mod.rs | 2 + .../arbiter-server/src/crypto/integrity/v1.rs | 362 +++++++++++++++--- server/crates/arbiter-server/src/db/models.rs | 1 - server/crates/arbiter-server/src/db/schema.rs | 1 - server/crates/arbiter-server/src/evm/mod.rs | 134 ++++--- .../crates/arbiter-server/src/evm/policies.rs | 39 +- .../src/evm/policies/ether_transfer/mod.rs | 44 ++- .../src/evm/policies/ether_transfer/tests.rs | 61 ++- .../src/evm/policies/token_transfers/mod.rs | 60 ++- .../src/evm/policies/token_transfers/tests.rs | 76 ++-- .../arbiter-server/src/grpc/user_agent/evm.rs | 8 +- .../arbiter-server/src/integrity/evm.rs | 336 ---------------- .../arbiter-server/src/integrity/mod.rs | 301 --------------- server/crates/arbiter-server/src/lib.rs | 1 - .../arbiter-server/tests/user_agent/auth.rs | 1 - .../arbiter-server/tests/user_agent/unseal.rs | 41 +- 28 files changed, 926 insertions(+), 1209 deletions(-) delete mode 100644 server/crates/arbiter-server/src/integrity/evm.rs delete mode 100644 server/crates/arbiter-server/src/integrity/mod.rs diff --git a/server/Cargo.lock b/server/Cargo.lock index 78bf627..dcd37c5 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -744,6 +744,7 @@ dependencies = [ "kameo", "memsafe", "pem", + "postcard", "prost", "prost-types", "rand 0.10.0", @@ -752,6 +753,8 @@ dependencies = [ "rsa", "rustls", "secrecy", + "serde", + "serde_with", "sha2 0.10.9", "smlang", "spki", @@ -1054,6 +1057,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "atomic-polyfill" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" +dependencies = [ + "critical-section", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -1444,6 +1456,15 @@ dependencies = [ "cc", ] +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.18", +] + [[package]] name = "console" version = "0.15.11" @@ -1551,6 +1572,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -1956,6 +1983,7 @@ version = "3.0.0-rc.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6e914c7c52decb085cea910552e24c63ac019e3ab8bf001ff736da9a9d9d890" dependencies = [ + "serde", "signature 3.0.0-rc.10", ] @@ -1968,6 +1996,7 @@ dependencies = [ "curve25519-dalek 5.0.0-pre.6", "ed25519", "rand_core 0.10.0", + "serde", "sha2 0.11.0-rc.5", "subtle", "zeroize", @@ -2014,6 +2043,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + [[package]] name = "encode_unicode" version = "1.0.0" @@ -2053,7 +2094,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2431,6 +2472,15 @@ dependencies = [ "tracing", ] +[[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -2465,6 +2515,20 @@ dependencies = [ "serde_core", ] +[[package]] +name = "heapless" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" +dependencies = [ + "atomic-polyfill", + "hash32", + "rustc_version 0.4.1", + "serde", + "spin", + "stable_deref_trait", +] + [[package]] name = "heck" version = "0.5.0" @@ -3188,7 +3252,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3213,6 +3277,7 @@ dependencies = [ "num-iter", "num-traits", "rand 0.8.5", + "serde", "smallvec", "zeroize", ] @@ -3524,6 +3589,19 @@ version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "heapless", + "serde", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -4152,6 +4230,7 @@ dependencies = [ "pkcs1", "pkcs8", "rand_core 0.6.4", + "serde", "sha2 0.10.9", "signature 2.2.0", "spki", @@ -4287,7 +4366,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4703,7 +4782,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4711,6 +4790,9 @@ name = "spin" version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] [[package]] name = "spki" @@ -4897,7 +4979,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/server/Cargo.toml b/server/Cargo.toml index 27bbea8..52d0794 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -43,4 +43,4 @@ 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 +miette = { version = "7.6.0", features = ["fancy", "serde"] } diff --git a/server/crates/arbiter-proto/src/lib.rs b/server/crates/arbiter-proto/src/lib.rs index e83a5e4..141b231 100644 --- a/server/crates/arbiter-proto/src/lib.rs +++ b/server/crates/arbiter-proto/src/lib.rs @@ -61,10 +61,6 @@ 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 2fb667e..a0bdae2 100644 --- a/server/crates/arbiter-server/Cargo.toml +++ b/server/crates/arbiter-server/Cargo.toml @@ -17,6 +17,7 @@ diesel-async = { version = "0.8.0", features = [ "tokio", ] } ed25519-dalek.workspace = true +ed25519-dalek.features = ["serde"] arbiter-proto.path = "../arbiter-proto" tracing.workspace = true tracing-subscriber = { version = "0.3", features = ["env-filter"] } @@ -46,7 +47,9 @@ restructed = "0.2.2" strum = { version = "0.28.0", features = ["derive"] } pem = "3.0.6" k256.workspace = true +k256.features = ["serde"] rsa.workspace = true +rsa.features = ["serde"] sha2.workspace = true hmac = "0.12" spki.workspace = true @@ -55,6 +58,9 @@ prost-types.workspace = true prost.workspace = true arbiter-tokens-registry.path = "../arbiter-tokens-registry" anyhow = "1.0.102" +postcard = { version = "1.1.3", features = ["use-std"] } +serde = { version = "1.0.228", features = ["derive"] } +serde_with = "3.18.0" [dev-dependencies] insta = "1.46.3" 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 78ef098..7ec6729 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 @@ -47,7 +47,6 @@ create table if not exists useragent_client ( id integer not null primary key, nonce integer not null default(1), -- used for auth challenge public_key blob not null, - pubkey_integrity_tag blob, key_type integer not null default(1), -- 1=Ed25519, 2=ECDSA(secp256k1) created_at integer not null default(unixepoch ('now')), updated_at integer not null default(unixepoch ('now')) diff --git a/server/crates/arbiter-server/src/actors/evm/mod.rs b/server/crates/arbiter-server/src/actors/evm/mod.rs index 23428f4..a615e19 100644 --- a/server/crates/arbiter-server/src/actors/evm/mod.rs +++ b/server/crates/arbiter-server/src/actors/evm/mod.rs @@ -8,19 +8,18 @@ use rand::{SeedableRng, rng, rngs::StdRng}; use crate::{ actors::keyholder::{CreateNew, Decrypt, GetState, KeyHolder, KeyHolderState}, + crypto::integrity, db::{ DatabaseError, DatabasePool, models::{self, SqliteTimestamp}, schema, }, evm::{ - self, RunKind, - policies::{ - FullGrant, Grant, SharedGrantSettings, SpecificGrant, SpecificMeaning, + self, ListError, RunKind, policies::{ + CombinedSettings, Grant, SharedGrantSettings, SpecificGrant, SpecificMeaning, ether_transfer::EtherTransfer, token_transfers::TokenTransfer, - }, + } }, - integrity, safe_cell::{SafeCell, SafeCellHandle as _}, }; @@ -58,8 +57,8 @@ pub enum Error { #[error("Database error: {0}")] Database(#[from] DatabaseError), - #[error("Vault is sealed")] - VaultSealed, + #[error("Integrity violation: {0}")] + Integrity(#[from] integrity::Error), } #[derive(Actor)] @@ -83,20 +82,6 @@ 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] @@ -151,62 +136,58 @@ impl EvmActor { basic: SharedGrantSettings, grant: SpecificGrant, ) -> Result { - self.ensure_unsealed().await?; - match grant { - SpecificGrant::EtherTransfer(settings) => { - self.engine - .create_grant::(FullGrant { - basic, - specific: settings, - }) - .await - .map_err(Error::from) - } - SpecificGrant::TokenTransfer(settings) => { - self.engine - .create_grant::(FullGrant { - basic, - specific: settings, - }) - .await - .map_err(Error::from) - } + SpecificGrant::EtherTransfer(settings) => self + .engine + .create_grant::(CombinedSettings { + shared: basic, + specific: settings, + }) + .await + .map_err(Error::from), + SpecificGrant::TokenTransfer(settings) => self + .engine + .create_grant::(CombinedSettings { + shared: basic, + 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)?; + // let keyholder = self.keyholder.clone(); - 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?; - 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?; - 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)?; - diesel::result::QueryResult::Ok(()) - }) - }) - .await - .map_err(DatabaseError::from)?; - - Ok(()) + // Ok(()) + todo!() } #[message] pub async fn useragent_list_grants(&mut self) -> Result>, Error> { - Ok(self.engine.list_all_grants().await?) + match self.engine.list_all_grants().await { + Ok(grants) => Ok(grants), + Err(ListError::Database(db_err)) => Err(Error::Database(db_err)), + Err(ListError::Integrity(integrity_err)) => Err(Error::Integrity(integrity_err)), + } } #[message] diff --git a/server/crates/arbiter-server/src/actors/keyholder/mod.rs b/server/crates/arbiter-server/src/actors/keyholder/mod.rs index 407e122..8e43129 100644 --- a/server/crates/arbiter-server/src/actors/keyholder/mod.rs +++ b/server/crates/arbiter-server/src/actors/keyholder/mod.rs @@ -4,9 +4,8 @@ use diesel::{ dsl::{insert_into, update}, }; use diesel_async::{AsyncConnection, RunQueryDsl}; -use hmac::{Hmac, Mac as _}; +use hmac::Mac as _; use kameo::{Actor, Reply, messages}; -use sha2::Sha256; use strum::{EnumDiscriminants, IntoDiscriminant}; use tracing::{error, info}; @@ -14,7 +13,7 @@ use crate::{ crypto::{ KeyCell, derive_key, encryption::v1::{self, Nonce}, - integrity::v1::compute_integrity_tag, + integrity::v1::HmacSha256, }, safe_cell::SafeCell, }; @@ -26,13 +25,6 @@ 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))] @@ -142,19 +134,6 @@ 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) { @@ -272,22 +251,6 @@ impl KeyHolder { Ok(()) } - // Signs a generic integrity payload using the vault-derived integrity key - #[message] - pub fn sign_integrity_tag( - &mut self, - purpose_tag: Vec, - data_parts: Vec>, - ) -> Result, Error> { - let State::Unsealed { root_key, .. } = &mut self.state else { - return Err(Error::NotBootstrapped); - }; - - let tag = - compute_integrity_tag(root_key, &purpose_tag, data_parts.iter().map(Vec::as_slice)); - Ok(tag.to_vec()) - } - #[message] pub async fn decrypt(&mut self, aead_id: i32) -> Result>, Error> { let State::Unsealed { root_key, .. } = &mut self.state else { @@ -371,12 +334,12 @@ impl KeyHolder { 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"), - }; + let mut hmac = root_key + .0 + .read_inline(|k| match HmacSha256::new_from_slice(k) { + Ok(v) => v, + Err(_) => unreachable!("HMAC accepts keys of any size"), + }); hmac.update(&root_key_history_id.to_be_bytes()); hmac.update(&mac_input); @@ -403,11 +366,12 @@ impl KeyHolder { 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"), - }; + let mut hmac = root_key + .0 + .read_inline(|k| match HmacSha256::new_from_slice(k) { + Ok(v) => v, + Err(_) => unreachable!("HMAC accepts keys of any size"), + }); hmac.update(&key_version.to_be_bytes()); hmac.update(&mac_input); diff --git a/server/crates/arbiter-server/src/actors/user_agent/auth.rs b/server/crates/arbiter-server/src/actors/user_agent/auth.rs index 7e2cf9c..83b0472 100644 --- a/server/crates/arbiter-server/src/actors/user_agent/auth.rs +++ b/server/crates/arbiter-server/src/actors/user_agent/auth.rs @@ -37,6 +37,13 @@ impl Error { } } +impl From for Error { + fn from(e: diesel::result::Error) -> Self { + error!(?e, "Database error"); + Self::internal("Database error") + } +} + #[derive(Debug, Clone)] pub enum Outbound { AuthChallenge { nonce: i32 }, diff --git a/server/crates/arbiter-server/src/actors/user_agent/auth/state.rs b/server/crates/arbiter-server/src/actors/user_agent/auth/state.rs index a9e0070..a5e212b 100644 --- a/server/crates/arbiter-server/src/actors/user_agent/auth/state.rs +++ b/server/crates/arbiter-server/src/actors/user_agent/auth/state.rs @@ -1,27 +1,20 @@ use arbiter_proto::transport::Bi; use diesel::{ExpressionMethods as _, OptionalExtension as _, QueryDsl, update}; -use diesel_async::RunQueryDsl; -use kameo::error::SendError; +use diesel_async::{AsyncConnection, RunQueryDsl}; +use kameo::{actor::ActorRef, error::SendError}; use tracing::error; use super::Error; use crate::{ actors::{ bootstrap::ConsumeToken, - keyholder::{self, SignIntegrityTag}, - user_agent::{AuthPublicKey, UserAgentConnection, auth::Outbound}, + keyholder::KeyHolder, + user_agent::{AuthPublicKey, UserAgentConnection, UserAgentCredentials, auth::Outbound}, }, - crypto::integrity::v1::USERAGENT_INTEGRITY_TAG, - db::schema, + crypto::integrity::{self, AttestationStatus}, + db::{DatabasePool, schema::useragent_client}, }; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum AttestationStatus { - Attested, - NotAttested, - Unavailable, -} - pub struct ChallengeRequest { pub pubkey: AuthPublicKey, } @@ -50,11 +43,11 @@ smlang::statemachine!( } ); -async fn create_nonce( - db: &crate::db::DatabasePool, - pubkey_bytes: &[u8], - key_type: crate::db::models::KeyType, -) -> Result { +/// Returns the current nonce, ready to use for the challenge nonce. +async fn get_current_nonce_and_id( + db: &DatabasePool, + key: &AuthPublicKey, +) -> Result<(i32, i32), Error> { let mut db_conn = db.get().await.map_err(|e| { error!(error = ?e, "Database pool error"); Error::internal("Database unavailable") @@ -62,21 +55,12 @@ async fn create_nonce( db_conn .exclusive_transaction(|conn| { Box::pin(async move { - let current_nonce = schema::useragent_client::table - .filter(schema::useragent_client::public_key.eq(pubkey_bytes.to_vec())) - .filter(schema::useragent_client::key_type.eq(key_type)) - .select(schema::useragent_client::nonce) - .first::(conn) - .await?; - - update(schema::useragent_client::table) - .filter(schema::useragent_client::public_key.eq(pubkey_bytes.to_vec())) - .filter(schema::useragent_client::key_type.eq(key_type)) - .set(schema::useragent_client::nonce.eq(current_nonce + 1)) - .execute(conn) - .await?; - - Result::<_, diesel::result::Error>::Ok(current_nonce) + useragent_client::table + .filter(useragent_client::public_key.eq(key.to_stored_bytes())) + .filter(useragent_client::key_type.eq(key.key_type())) + .select((useragent_client::id, useragent_client::nonce)) + .first::<(i32, i32)>(conn) + .await }) }) .await @@ -86,15 +70,98 @@ async fn create_nonce( Error::internal("Database operation failed") })? .ok_or_else(|| { - error!(?pubkey_bytes, "Public key not found in database"); + error!(?key, "Public key not found in database"); Error::UnregisteredPublicKey }) } -async fn register_key( - db: &crate::db::DatabasePool, +async fn verify_integrity( + db: &DatabasePool, + keyholder: &ActorRef, + pubkey: &AuthPublicKey, +) -> Result<(), Error> { + let mut db_conn = db.get().await.map_err(|e| { + error!(error = ?e, "Database pool error"); + Error::internal("Database unavailable") + })?; + + let (id, nonce) = get_current_nonce_and_id(db, pubkey).await?; + + let result = integrity::verify_entity( + &mut db_conn, + keyholder, + &UserAgentCredentials { + pubkey: pubkey.clone(), + nonce, + }, + id, + ) + .await + .map_err(|e| { + error!(?e, "Integrity verification failed"); + Error::internal("Integrity verification failed") + })?; + + match result { + AttestationStatus::Attested | AttestationStatus::Unavailable => Ok(()), + AttestationStatus::NotAttested => { + error!(?pubkey, "Integrity verification failed: not attested"); + Err(Error::internal("Database tampering detected")) + } + } + +} + +async fn create_nonce( + db: &DatabasePool, + keyholder: &ActorRef, + pubkey: &AuthPublicKey, +) -> Result { + let mut db_conn = db.get().await.map_err(|e| { + error!(error = ?e, "Database pool error"); + Error::internal("Database unavailable") + })?; + let new_nonce = db_conn + .exclusive_transaction(|conn| { + Box::pin(async move { + let (id, new_nonce): (i32, i32) = update(useragent_client::table) + .filter(useragent_client::public_key.eq(pubkey.to_stored_bytes())) + .filter(useragent_client::key_type.eq(pubkey.key_type())) + .set(useragent_client::nonce.eq(useragent_client::nonce + 1)) + .returning((useragent_client::id, useragent_client::nonce)) + .get_result(conn) + .await + .map_err(|e| { + error!(error = ?e, "Database error"); + Error::internal("Database operation failed") + })?; + + integrity::sign_entity( + conn, + keyholder, + &UserAgentCredentials { + pubkey: pubkey.clone(), + nonce: new_nonce, + }, + id, + ) + .await + .map_err(|e| { + error!(?e, "Integrity signature update failed"); + Error::internal("Database error") + })?; + + Result::<_, Error>::Ok(new_nonce) + }) + }) + .await?; + Ok(new_nonce) +} + +async fn register_key( + db: &DatabasePool, + keyholder: &ActorRef, pubkey: &AuthPublicKey, - integrity_tag: Option>, ) -> Result<(), Error> { let pubkey_bytes = pubkey.to_stored_bytes(); let key_type = pubkey.key_type(); @@ -103,19 +170,40 @@ async fn register_key( Error::internal("Database unavailable") })?; - diesel::insert_into(schema::useragent_client::table) - .values(( - schema::useragent_client::public_key.eq(pubkey_bytes), - schema::useragent_client::nonce.eq(1), - schema::useragent_client::key_type.eq(key_type), - schema::useragent_client::pubkey_integrity_tag.eq(integrity_tag), - )) - .execute(&mut conn) - .await - .map_err(|e| { - error!(error = ?e, "Database error"); - Error::internal("Database operation failed") - })?; + conn.transaction(|conn| { + Box::pin(async move { + const NONCE_START: i32 = 1; + + let id: i32 = diesel::insert_into(useragent_client::table) + .values(( + useragent_client::public_key.eq(pubkey_bytes), + useragent_client::nonce.eq(NONCE_START), + useragent_client::key_type.eq(key_type), + )) + .returning(useragent_client::id) + .get_result(conn) + .await + .map_err(|e| { + error!(error = ?e, "Database error"); + Error::internal("Database operation failed") + })?; + + let entity = UserAgentCredentials { + pubkey: pubkey.clone(), + nonce: NONCE_START, + }; + + integrity::sign_entity(conn, &keyholder, &entity, id) + .await + .map_err(|e| { + error!(error = ?e, "Failed to sign integrity tag for new user-agent key"); + Error::internal("Failed to register public key") + })?; + + Result::<_, Error>::Ok(()) + }) + }) + .await?; Ok(()) } @@ -141,15 +229,9 @@ where &mut self, ChallengeRequest { pubkey }: ChallengeRequest, ) -> Result { - match self.verify_pubkey_attestation_status(&pubkey).await? { - AttestationStatus::Attested | AttestationStatus::Unavailable => {} - AttestationStatus::NotAttested => { - return Err(Error::InvalidChallengeSolution); - } - } + verify_integrity(&self.conn.db, &self.conn.actors.key_holder, &pubkey).await?; - let stored_bytes = pubkey.to_stored_bytes(); - let nonce = create_nonce(&self.conn.db, &stored_bytes, pubkey.key_type()).await?; + let nonce = create_nonce(&self.conn.db, &self.conn.actors.key_holder, &pubkey).await?; self.transport .send(Ok(Outbound::AuthChallenge { nonce })) @@ -189,22 +271,24 @@ where return Err(Error::InvalidBootstrapToken); } - let integrity_tag = self - .try_sign_pubkey_integrity_tag(&pubkey) - .await - .map_err(|err| { - error!(?err, "Failed to sign user-agent pubkey integrity tag"); - Error::internal("Failed to sign user-agent pubkey integrity tag") - })?; - - register_key(&self.conn.db, &pubkey, integrity_tag).await?; - - self.transport - .send(Ok(Outbound::AuthSuccess)) - .await - .map_err(|_| Error::Transport)?; - - Ok(pubkey) + match token_ok { + true => { + register_key(&self.conn.db, &self.conn.actors.key_holder, &pubkey).await?; + self.transport + .send(Ok(Outbound::AuthSuccess)) + .await + .map_err(|_| Error::Transport)?; + Ok(pubkey) + } + false => { + error!("Invalid bootstrap token provided"); + self.transport + .send(Err(Error::InvalidBootstrapToken)) + .await + .map_err(|_| Error::Transport)?; + Err(Error::InvalidBootstrapToken) + } + } } #[allow(missing_docs)] @@ -264,93 +348,3 @@ where } } } - -impl AuthContext<'_, T> -where - T: Bi> + Send, -{ - async fn try_sign_pubkey_integrity_tag( - &self, - pubkey: &AuthPublicKey, - ) -> Result>, Error> { - let signed = self - .conn - .actors - .key_holder - .ask(SignIntegrityTag { - purpose_tag: USERAGENT_INTEGRITY_TAG.to_vec(), - data_parts: vec![ - (pubkey.key_type() as i32).to_be_bytes().to_vec(), - pubkey.to_stored_bytes(), - ], - }) - .await; - - match signed { - Ok(tag) => Ok(Some(tag)), - Err(SendError::HandlerError(keyholder::Error::NotBootstrapped)) => Ok(None), - Err(SendError::HandlerError(err)) => { - error!( - ?err, - "Keyholder failed to sign user-agent pubkey integrity tag" - ); - Err(Error::internal( - "Keyholder failed to sign user-agent pubkey integrity tag", - )) - } - Err(err) => { - error!( - ?err, - "Failed to contact keyholder for user-agent pubkey integrity tag" - ); - Err(Error::internal( - "Failed to contact keyholder for user-agent pubkey integrity tag", - )) - } - } - } - - async fn verify_pubkey_attestation_status( - &self, - pubkey: &AuthPublicKey, - ) -> Result { - let stored_tag: Option>> = { - let mut conn = self.conn.db.get().await.map_err(|e| { - error!(error = ?e, "Database pool error"); - Error::internal("Database unavailable") - })?; - - schema::useragent_client::table - .filter(schema::useragent_client::public_key.eq(pubkey.to_stored_bytes())) - .filter(schema::useragent_client::key_type.eq(pubkey.key_type())) - .select(schema::useragent_client::pubkey_integrity_tag) - .first::>>(&mut conn) - .await - .optional() - .map_err(|e| { - error!(error = ?e, "Database error"); - Error::internal("Database operation failed") - })? - }; - - let Some(stored_tag) = stored_tag else { - return Err(Error::UnregisteredPublicKey); - }; - - let Some(expected_tag) = self.try_sign_pubkey_integrity_tag(pubkey).await? else { - return Ok(AttestationStatus::Unavailable); - }; - - match stored_tag { - Some(stored_tag) if stored_tag == expected_tag => Ok(AttestationStatus::Attested), - Some(_) => { - error!("User-agent pubkey integrity tag mismatch"); - Ok(AttestationStatus::NotAttested) - } - None => { - error!("Missing pubkey integrity tag for registered key while vault is unsealed"); - Ok(AttestationStatus::NotAttested) - } - } - } -} diff --git a/server/crates/arbiter-server/src/actors/user_agent/mod.rs b/server/crates/arbiter-server/src/actors/user_agent/mod.rs index 3a45cc5..efe3adf 100644 --- a/server/crates/arbiter-server/src/actors/user_agent/mod.rs +++ b/server/crates/arbiter-server/src/actors/user_agent/mod.rs @@ -1,18 +1,65 @@ use crate::{ - actors::{GlobalActors, client::ClientProfile}, - db::{self, models::KeyType}, + actors::{GlobalActors, client::ClientProfile}, crypto::integrity::Integrable, db::{self, models::KeyType} }; +fn serialize_ecdsa(key: &k256::ecdsa::VerifyingKey, serializer: S) -> Result +where + S: serde::Serializer, +{ + // Serialize as hex string for easier debugging (33 bytes compressed SEC1 format) + let key = key.to_encoded_point(true); + let bytes = key.as_bytes(); + serializer.serialize_bytes(bytes) +} + +fn deserialize_ecdsa<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + struct EcdsaVisitor; + + impl<'de> serde::de::Visitor<'de> for EcdsaVisitor { + type Value = k256::ecdsa::VerifyingKey; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a compressed SEC1-encoded ECDSA public key") + } + + fn visit_bytes(self, v: &[u8]) -> Result + where + E: serde::de::Error, + { + let point = k256::EncodedPoint::from_bytes(v) + .map_err(|_| E::custom("invalid compressed SEC1 format"))?; + k256::ecdsa::VerifyingKey::from_encoded_point(&point) + .map_err(|_| E::custom("invalid ECDSA public key")) + } + } + + deserializer.deserialize_bytes(EcdsaVisitor) +} + /// Abstraction over Ed25519 / ECDSA-secp256k1 / RSA public keys used during the auth handshake. -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Serialize)] pub enum AuthPublicKey { Ed25519(ed25519_dalek::VerifyingKey), /// Compressed SEC1 public key; signature bytes are raw 64-byte (r||s). + #[serde(serialize_with = "serialize_ecdsa", deserialize_with = "deserialize_ecdsa")] EcdsaSecp256k1(k256::ecdsa::VerifyingKey), /// RSA-2048+ public key (Windows Hello / KeyCredentialManager); signature bytes are PSS+SHA-256. Rsa(rsa::RsaPublicKey), } +#[derive(Debug, Serialize)] +pub struct UserAgentCredentials { + pub pubkey: AuthPublicKey, + pub nonce: i32 +} + +impl Integrable for UserAgentCredentials { + const KIND: &'static str = "useragent_credentials"; +} + impl AuthPublicKey { /// Canonical bytes stored in DB and echoed back in the challenge. /// Ed25519: raw 32 bytes. ECDSA: SEC1 compressed 33 bytes. RSA: DER-encoded SPKI. @@ -91,4 +138,5 @@ pub mod auth; pub mod session; pub use auth::authenticate; +use serde::Serialize; pub use session::UserAgentSession; 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 f65a98d..3017819 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 @@ -349,9 +349,6 @@ 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(GrantMutationError::Internal) @@ -372,9 +369,6 @@ 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(GrantMutationError::Internal) diff --git a/server/crates/arbiter-server/src/crypto/encryption/mod.rs b/server/crates/arbiter-server/src/crypto/encryption/mod.rs index a3a6d96..22fb807 100644 --- a/server/crates/arbiter-server/src/crypto/encryption/mod.rs +++ b/server/crates/arbiter-server/src/crypto/encryption/mod.rs @@ -1 +1,3 @@ pub mod v1; + +pub use v1::*; \ No newline at end of file diff --git a/server/crates/arbiter-server/src/crypto/integrity/mod.rs b/server/crates/arbiter-server/src/crypto/integrity/mod.rs index a3a6d96..22fb807 100644 --- a/server/crates/arbiter-server/src/crypto/integrity/mod.rs +++ b/server/crates/arbiter-server/src/crypto/integrity/mod.rs @@ -1 +1,3 @@ pub mod v1; + +pub use v1::*; \ No newline at end of file diff --git a/server/crates/arbiter-server/src/crypto/integrity/v1.rs b/server/crates/arbiter-server/src/crypto/integrity/v1.rs index 2ff4cdd..9a7b923 100644 --- a/server/crates/arbiter-server/src/crypto/integrity/v1.rs +++ b/server/crates/arbiter-server/src/crypto/integrity/v1.rs @@ -1,78 +1,320 @@ -use crate::{crypto::KeyCell, safe_cell::SafeCellHandle as _}; +use crate::{actors::keyholder, crypto::KeyCell,safe_cell::SafeCellHandle as _}; use chacha20poly1305::Key; -use hmac::Mac as _; +use hmac::{Hmac, Mac as _}; +use serde::Serialize; +use sha2::Sha256; -pub const USERAGENT_INTEGRITY_DERIVE_TAG: &[u8] = "arbiter/useragent/integrity-key/v1".as_bytes(); -pub const USERAGENT_INTEGRITY_TAG: &[u8] = "arbiter/useragent/pubkey-entry/v1".as_bytes(); +use diesel::{ExpressionMethods as _, QueryDsl, dsl::insert_into, sqlite::Sqlite}; +use diesel_async::{AsyncConnection, RunQueryDsl}; +use kameo::{actor::ActorRef, error::SendError}; +use sha2::Digest as _; -/// Computes an integrity tag for a specific domain and payload shape. -pub fn compute_integrity_tag<'a, I>( - integrity_key: &mut KeyCell, - purpose_tag: &[u8], - data_parts: I, -) -> [u8; 32] -where - I: IntoIterator, -{ - type HmacSha256 = hmac::Hmac; +use crate::{ + actors::keyholder::{KeyHolder, SignIntegrity, VerifyIntegrity}, + db::{ + self, + models::{IntegrityEnvelope, NewIntegrityEnvelope}, + schema::integrity_envelope, + }, +}; - let mut output_tag = [0u8; 32]; - integrity_key.0.read_inline(|integrity_key_bytes: &Key| { - let mut mac = ::new_from_slice(integrity_key_bytes.as_ref()) - .expect("HMAC key initialization must not fail for 32-byte key"); - mac.update(purpose_tag); - for data_part in data_parts { - mac.update(data_part); - } - output_tag.copy_from_slice(&mac.finalize().into_bytes()); - }); +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("Database error: {0}")] + Database(#[from] db::DatabaseError), - output_tag + #[error("KeyHolder error: {0}")] + Keyholder(#[from] keyholder::Error), + + #[error("KeyHolder mailbox error")] + KeyholderSend, + + #[error("Integrity envelope is missing for entity {entity_kind}")] + MissingEnvelope { entity_kind: &'static str }, + + #[error( + "Integrity payload version mismatch for entity {entity_kind}: expected {expected}, found {found}" + )] + PayloadVersionMismatch { + entity_kind: &'static str, + expected: i32, + found: i32, + }, + + #[error("Integrity MAC mismatch for entity {entity_kind}")] + MacMismatch { entity_kind: &'static str }, + + #[error("Payload serialization error: {0}")] + PayloadSerialization(#[from] postcard::Error), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AttestationStatus { + Attested, + NotAttested, + Unavailable, +} + +pub const CURRENT_PAYLOAD_VERSION: i32 = 1; +pub const INTEGRITY_SUBKEY_TAG: &[u8] = b"arbiter/db-integrity-key/v1"; + +pub type HmacSha256 = Hmac; + +pub trait Integrable: Serialize { + const KIND: &'static str; + const VERSION: i32 = 1; +} + +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 trait IntoId { + fn into_id(self) -> Vec; +} + +impl IntoId for i32 { + fn into_id(self) -> Vec { + self.to_be_bytes().to_vec() + } +} + +impl IntoId for &'_ [u8] { + fn into_id(self) -> Vec { + self.to_vec() + } +} + +pub async fn sign_entity( + conn: &mut impl AsyncConnection, + keyholder: &ActorRef, + entity: &E, + entity_id: impl IntoId, +) -> Result<(), Error> { + let payload = postcard::to_stdvec(entity)?; + let payload_hash = payload_hash(&payload); + + let entity_id = entity_id.into_id(); + + let mac_input = build_mac_input(E::KIND, &entity_id, E::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, + })?; + + insert_into(integrity_envelope::table) + .values(NewIntegrityEnvelope { + entity_kind: E::KIND.to_owned(), + entity_id: entity_id, + payload_version: E::VERSION , + key_version, + mac: mac.to_vec(), + }) + .on_conflict(( + integrity_envelope::entity_id, + integrity_envelope::entity_kind, + )) + .do_update() + .set(( + integrity_envelope::payload_version.eq(E::VERSION), + integrity_envelope::key_version.eq(key_version), + integrity_envelope::mac.eq(mac), + )) + .execute(conn) + .await + .map_err(db::DatabaseError::from)?; + + Ok(()) +} + +pub async fn verify_entity( + conn: &mut impl AsyncConnection, + keyholder: &ActorRef, + entity: &E, + entity_id: impl IntoId, +) -> Result { + let entity_id = entity_id.into_id(); + let envelope: IntegrityEnvelope = integrity_envelope::table + .filter(integrity_envelope::entity_kind.eq(E::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: E::KIND }, + other => Error::Database(db::DatabaseError::from(other)), + })?; + + if envelope.payload_version != E::VERSION { + return Err(Error::PayloadVersionMismatch { + entity_kind: E::KIND, + expected: E::VERSION, + found: envelope.payload_version, + }); + } + + let payload = postcard::to_stdvec(entity)?; + let payload_hash = payload_hash(&payload); + let mac_input = build_mac_input( + E::KIND, + &entity_id, + envelope.payload_version, + &payload_hash, + ); + + let result = keyholder + .ask(VerifyIntegrity { + mac_input, + expected_mac: envelope.mac, + key_version: envelope.key_version, + }) + .await + ; + + match result { + Ok(true) => Ok(AttestationStatus::Attested), + Ok(false) => Ok(AttestationStatus::NotAttested), + Err(SendError::HandlerError(keyholder::Error::NotBootstrapped)) => Ok(AttestationStatus::Unavailable), + Err(_) => Err(Error::KeyholderSend), + } } #[cfg(test)] mod tests { + use diesel::{ExpressionMethods as _, QueryDsl}; + use diesel_async::RunQueryDsl; + use kameo::{actor::ActorRef, prelude::Spawn}; + use crate::{ - crypto::{derive_key, encryption::v1::generate_salt}, + actors::keyholder::{Bootstrap, KeyHolder}, + db::{self, schema}, safe_cell::{SafeCell, SafeCellHandle as _}, }; - use super::{USERAGENT_INTEGRITY_TAG, compute_integrity_tag}; + use super::{Error, Integrable, sign_entity, verify_entity}; - #[test] - pub fn integrity_tag_deterministic() { - let salt = generate_salt(); - let mut integrity_key = derive_key(SafeCell::new(b"password".to_vec()), &salt); - let key_type = 1i32.to_be_bytes(); - let t1 = compute_integrity_tag( - &mut integrity_key, - USERAGENT_INTEGRITY_TAG, - [key_type.as_slice(), b"pubkey".as_ref()], - ); - let t2 = compute_integrity_tag( - &mut integrity_key, - USERAGENT_INTEGRITY_TAG, - [key_type.as_slice(), b"pubkey".as_ref()], - ); - assert_eq!(t1, t2); + #[derive(Clone, serde::Serialize)] + struct DummyEntity { + payload_version: i32, + payload: Vec, } - #[test] - pub fn integrity_tag_changes_with_payload() { - let salt = generate_salt(); - let mut integrity_key = derive_key(SafeCell::new(b"password".to_vec()), &salt); - let key_type_1 = 1i32.to_be_bytes(); - let key_type_2 = 2i32.to_be_bytes(); - let t1 = compute_integrity_tag( - &mut integrity_key, - USERAGENT_INTEGRITY_TAG, - [key_type_1.as_slice(), b"pubkey".as_ref()], - ); - let t2 = compute_integrity_tag( - &mut integrity_key, - USERAGENT_INTEGRITY_TAG, - [key_type_2.as_slice(), b"pubkey".as_ref()], - ); - assert_ne!(t1, t2); + impl Integrable for DummyEntity { + const KIND: &'static str = "dummy_entity"; + } + + 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(); + + const ENTITY_ID: &[u8] = b"entity-id-7"; + + let entity = DummyEntity { + payload_version: 1, + payload: b"payload-v1".to_vec(), + }; + + sign_entity(&mut conn, &keyholder, &entity, ENTITY_ID).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_ID)) + .count() + .get_result(&mut conn) + .await + .unwrap(); + + assert_eq!(count, 1, "envelope row must be created exactly once"); + verify_entity(&mut conn, &keyholder, &entity, ENTITY_ID).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(); + + const ENTITY_ID: &[u8] = b"entity-id-11"; + + let entity = DummyEntity { + payload_version: 1, + payload: b"payload-v1".to_vec(), + }; + + sign_entity(&mut conn, &keyholder, &entity, ENTITY_ID).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_ID)) + .set(schema::integrity_envelope::mac.eq(vec![0u8; 32])) + .execute(&mut conn) + .await + .unwrap(); + + let err = verify_entity(&mut conn, &keyholder, &entity, ENTITY_ID) + .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(); + + const ENTITY_ID: &[u8] = b"entity-id-21"; + + let entity = DummyEntity { + payload_version: 1, + payload: b"payload-v1".to_vec(), + }; + + sign_entity(&mut conn, &keyholder, &entity, ENTITY_ID).await.unwrap(); + + let tampered = DummyEntity { + payload: b"payload-v1-but-tampered".to_vec(), + ..entity + }; + + let err = verify_entity(&mut conn, &keyholder, &tampered, ENTITY_ID) + .await + .unwrap_err(); + assert!(matches!(err, Error::MacMismatch { .. })); } } diff --git a/server/crates/arbiter-server/src/db/models.rs b/server/crates/arbiter-server/src/db/models.rs index a67629a..f558072 100644 --- a/server/crates/arbiter-server/src/db/models.rs +++ b/server/crates/arbiter-server/src/db/models.rs @@ -242,7 +242,6 @@ pub struct UseragentClient { pub id: i32, pub nonce: i32, pub public_key: Vec, - pub pubkey_integrity_tag: Option>, pub created_at: SqliteTimestamp, pub updated_at: SqliteTimestamp, pub key_type: KeyType, diff --git a/server/crates/arbiter-server/src/db/schema.rs b/server/crates/arbiter-server/src/db/schema.rs index 41a1fb9..c9b980c 100644 --- a/server/crates/arbiter-server/src/db/schema.rs +++ b/server/crates/arbiter-server/src/db/schema.rs @@ -191,7 +191,6 @@ diesel::table! { id -> Integer, nonce -> Integer, public_key -> Binary, - pubkey_integrity_tag -> Nullable, key_type -> Integer, created_at -> Integer, updated_at -> Integer, diff --git a/server/crates/arbiter-server/src/evm/mod.rs b/server/crates/arbiter-server/src/evm/mod.rs index bad0753..6727650 100644 --- a/server/crates/arbiter-server/src/evm/mod.rs +++ b/server/crates/arbiter-server/src/evm/mod.rs @@ -12,6 +12,7 @@ use kameo::actor::ActorRef; use crate::{ actors::keyholder::KeyHolder, + crypto::integrity, db::{ self, DatabaseError, models::{ @@ -20,11 +21,10 @@ use crate::{ schema::{self, evm_transaction_log}, }, evm::policies::{ - DatabaseID, EvalContext, EvalViolation, FullGrant, Grant, Policy, SharedGrantSettings, + DatabaseID, EvalContext, EvalViolation, Grant, Policy, CombinedSettings, SharedGrantSettings, SpecificGrant, SpecificMeaning, ether_transfer::EtherTransfer, token_transfers::TokenTransfer, }, - integrity, }; pub mod policies; @@ -63,6 +63,15 @@ pub enum AnalyzeError { UnsupportedTransactionType, } +#[derive(Debug, thiserror::Error)] +pub enum ListError { + #[error("Database error")] + Database(#[from] crate::db::DatabaseError), + + #[error("Integrity verification failed for grant")] + Integrity(#[from] integrity::Error), +} + /// Controls whether a transaction should be executed or only validated #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RunKind { @@ -141,18 +150,16 @@ 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?; + integrity::verify_entity(&mut conn, &self.keyholder, &grant.settings, grant.id).await?; - let mut violations = - check_shared_constraints(&context, &grant.shared, grant.shared_grant_id, &mut conn) - .await - .map_err(DatabaseError::from)?; + let mut violations = check_shared_constraints( + &context, + &grant.settings.shared, + grant.common_settings_id, + &mut conn, + ) + .await + .map_err(DatabaseError::from)?; violations.extend( P::evaluate(&context, meaning, &grant, &mut conn) .await @@ -162,13 +169,13 @@ impl Engine { if !violations.is_empty() { return Err(PolicyError::Violations(violations)); } - + if run_kind == RunKind::Execution { conn.transaction(|conn| { Box::pin(async move { let log_id: i32 = insert_into(evm_transaction_log::table) .values(&NewEvmTransactionLog { - grant_id: grant.shared_grant_id, + grant_id: grant.common_settings_id, wallet_access_id: context.target.id, chain_id: context.chain as i32, eth_value: utils::u256_to_bytes(context.value).to_vec(), @@ -198,7 +205,7 @@ impl Engine { pub async fn create_grant( &self, - full_grant: FullGrant, + full_grant: CombinedSettings, ) -> Result where P::Settings: Clone, @@ -213,25 +220,25 @@ impl Engine { let basic_grant: EvmBasicGrant = insert_into(evm_basic_grant::table) .values(&NewEvmBasicGrant { - chain_id: full_grant.basic.chain as i32, - wallet_access_id: full_grant.basic.wallet_access_id, - valid_from: full_grant.basic.valid_from.map(SqliteTimestamp), - valid_until: full_grant.basic.valid_until.map(SqliteTimestamp), + chain_id: full_grant.shared.chain as i32, + wallet_access_id: full_grant.shared.wallet_access_id, + valid_from: full_grant.shared.valid_from.map(SqliteTimestamp), + valid_until: full_grant.shared.valid_until.map(SqliteTimestamp), max_gas_fee_per_gas: full_grant - .basic + .shared .max_gas_fee_per_gas .map(|fee| utils::u256_to_bytes(fee).to_vec()), max_priority_fee_per_gas: full_grant - .basic + .shared .max_priority_fee_per_gas .map(|fee| utils::u256_to_bytes(fee).to_vec()), rate_limit_count: full_grant - .basic + .shared .rate_limit .as_ref() .map(|rl| rl.count as i32), rate_limit_window_secs: full_grant - .basic + .shared .rate_limit .as_ref() .map(|rl| rl.window.num_seconds() as i32), @@ -243,16 +250,14 @@ impl Engine { 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)?; + integrity::sign_entity( + conn, + &keyholder, + &full_grant, + basic_grant.id, + ) + .await + .map_err(|_| diesel::result::Error::RollbackTransaction)?; QueryResult::Ok(basic_grant.id) }) @@ -262,43 +267,36 @@ impl Engine { Ok(id) } - pub async fn list_all_grants(&self) -> Result>, DatabaseError> { - let mut conn = self.db.get().await?; + async fn list_one_kind( + &self, + conn: &mut impl AsyncConnection, + ) -> Result>, ListError> + where + Y: From, + { + let all_grants = Kind::find_all_grants(conn) + .await + .map_err(DatabaseError::from)?; + + // Verify integrity of all grants before returning any results + for grant in &all_grants { + integrity::verify_entity(conn, &self.keyholder, &grant.settings, grant.id).await?; + } + + Ok(all_grants.into_iter().map(|g| Grant { + id: g.id, + common_settings_id: g.common_settings_id, + settings: g.settings.generalize(), + })) + } + + pub async fn list_all_grants(&self) -> Result>, ListError> { + let mut conn = self.db.get().await.map_err(DatabaseError::from)?; let mut grants: Vec> = Vec::new(); - grants.extend( - EtherTransfer::find_all_grants(&mut conn) - .await? - .into_iter() - .map(|g| Grant { - id: g.id, - shared_grant_id: g.shared_grant_id, - shared: g.shared, - settings: SpecificGrant::EtherTransfer(g.settings), - }), - ); - grants.extend( - TokenTransfer::find_all_grants(&mut conn) - .await? - .into_iter() - .map(|g| Grant { - id: g.id, - shared_grant_id: g.shared_grant_id, - shared: g.shared, - settings: SpecificGrant::TokenTransfer(g.settings), - }), - ); - - 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), - })?; - } + grants.extend(self.list_one_kind::(&mut conn).await?); + grants.extend(self.list_one_kind::(&mut conn).await?); Ok(grants) } diff --git a/server/crates/arbiter-server/src/evm/policies.rs b/server/crates/arbiter-server/src/evm/policies.rs index 10ab304..d69af22 100644 --- a/server/crates/arbiter-server/src/evm/policies.rs +++ b/server/crates/arbiter-server/src/evm/policies.rs @@ -7,11 +7,11 @@ use diesel::{ }; use diesel_async::{AsyncConnection, RunQueryDsl}; +use serde::Serialize; use thiserror::Error; use crate::{ - db::models::{self, EvmBasicGrant, EvmWalletAccess}, - evm::utils, + crypto::integrity::v1::Integrable, db::models::{self, EvmBasicGrant, EvmWalletAccess}, evm::utils }; pub mod ether_transfer; @@ -59,16 +59,15 @@ pub enum EvalViolation { pub type DatabaseID = i32; -#[derive(Debug)] +#[derive(Debug, Serialize)] pub struct Grant { pub id: DatabaseID, - pub shared_grant_id: DatabaseID, // ID of the basic grant for shared-logic checks like rate limits and validity periods - pub shared: SharedGrantSettings, - pub settings: PolicySettings, + pub common_settings_id: DatabaseID, // ID of the basic grant for shared-logic checks like rate limits and validity periods + pub settings: CombinedSettings, } pub trait Policy: Sized { - type Settings: Send + Sync + 'static + Into; + type Settings: Send + Sync + 'static + Into + Integrable; type Meaning: Display + std::fmt::Debug + Send + Sync + 'static + Into; fn analyze(context: &EvalContext) -> Option; @@ -124,19 +123,19 @@ pub enum SpecificMeaning { TokenTransfer(token_transfers::Meaning), } -#[derive(Clone, Debug, PartialEq, Eq, Hash)] +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize)] pub struct TransactionRateLimit { pub count: u32, pub window: Duration, } -#[derive(Clone, Debug, PartialEq, Eq, Hash)] +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize)] pub struct VolumeRateLimit { pub max_volume: U256, pub window: Duration, } -#[derive(Clone, Debug, PartialEq, Eq, Hash)] +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize)] pub struct SharedGrantSettings { pub wallet_access_id: i32, pub chain: ChainId, @@ -197,7 +196,23 @@ pub enum SpecificGrant { TokenTransfer(token_transfers::Settings), } -pub struct FullGrant { - pub basic: SharedGrantSettings, +#[derive(Debug, Serialize)] +pub struct CombinedSettings { + pub shared: SharedGrantSettings, pub specific: PolicyGrant, } + +impl

CombinedSettings

{ + pub fn generalize>(self) -> CombinedSettings { + CombinedSettings { + shared: self.shared, + specific: self.specific.into(), + } + } +} + +impl Integrable for CombinedSettings

{ + const KIND: &'static str = P::KIND; + const VERSION: i32 = P::VERSION; +} + diff --git a/server/crates/arbiter-server/src/evm/policies/ether_transfer/mod.rs b/server/crates/arbiter-server/src/evm/policies/ether_transfer/mod.rs index d419b59..57a38dd 100644 --- a/server/crates/arbiter-server/src/evm/policies/ether_transfer/mod.rs +++ b/server/crates/arbiter-server/src/evm/policies/ether_transfer/mod.rs @@ -8,13 +8,14 @@ use diesel::sqlite::Sqlite; use diesel::{ExpressionMethods, JoinOnDsl, prelude::*}; use diesel_async::{AsyncConnection, RunQueryDsl}; +use crate::crypto::integrity::v1::Integrable; use crate::db::models::{ EvmBasicGrant, EvmEtherTransferGrant, EvmEtherTransferGrantTarget, EvmEtherTransferLimit, NewEvmEtherTransferLimit, SqliteTimestamp, }; use crate::db::schema::{evm_basic_grant, evm_ether_transfer_limit, evm_transaction_log}; use crate::evm::policies::{ - Grant, SharedGrantSettings, SpecificGrant, SpecificMeaning, VolumeRateLimit, + CombinedSettings, Grant, SharedGrantSettings, SpecificGrant, SpecificMeaning, VolumeRateLimit, }; use crate::{ db::{ @@ -51,11 +52,14 @@ impl From for SpecificMeaning { } // A grant for ether transfers, which can be scoped to specific target addresses and volume limits -#[derive(Debug, Clone)] +#[derive(Debug, Clone, serde::Serialize)] pub struct Settings { pub target: Vec

, pub limit: VolumeRateLimit, } +impl Integrable for Settings { + const KIND: &'static str = "EtherTransfer"; +} impl From for SpecificGrant { fn from(val: Settings) -> SpecificGrant { @@ -95,17 +99,17 @@ async fn check_rate_limits( db: &mut impl AsyncConnection, ) -> QueryResult> { let mut violations = Vec::new(); - let window = grant.settings.limit.window; + let window = grant.settings.specific.limit.window; let past_transaction = query_relevant_past_transaction(grant.id, window, db).await?; - let window_start = chrono::Utc::now() - grant.settings.limit.window; + let window_start = chrono::Utc::now() - grant.settings.specific.limit.window; let prospective_cumulative_volume: U256 = past_transaction .iter() .filter(|(_, timestamp)| timestamp >= &window_start) .fold(current_transfer_value, |acc, (value, _)| acc + *value); - if prospective_cumulative_volume > grant.settings.limit.max_volume { + if prospective_cumulative_volume > grant.settings.specific.limit.max_volume { violations.push(EvalViolation::VolumetricLimitExceeded); } @@ -138,7 +142,7 @@ impl Policy for EtherTransfer { let mut violations = Vec::new(); // Check if the target address is within the grant's allowed targets - if !grant.settings.target.contains(&meaning.to) { + if !grant.settings.specific.target.contains(&meaning.to) { violations.push(EvalViolation::InvalidTarget { target: meaning.to }); } @@ -247,9 +251,11 @@ impl Policy for EtherTransfer { Ok(Some(Grant { id: grant.id, - shared_grant_id: grant.basic_grant_id, - shared: SharedGrantSettings::try_from_model(basic_grant)?, - settings, + common_settings_id: grant.basic_grant_id, + settings: CombinedSettings { + shared: SharedGrantSettings::try_from_model(basic_grant)?, + specific: settings, + }, })) } @@ -327,15 +333,17 @@ impl Policy for EtherTransfer { Ok(Grant { id: specific.id, - shared_grant_id: specific.basic_grant_id, - shared: SharedGrantSettings::try_from_model(basic)?, - settings: Settings { - target: targets, - limit: VolumeRateLimit { - max_volume: utils::try_bytes_to_u256(&limit.max_volume).map_err( - |e| diesel::result::Error::DeserializationError(Box::new(e)), - )?, - window: Duration::seconds(limit.window_secs as i64), + common_settings_id: specific.basic_grant_id, + settings: CombinedSettings { + shared: SharedGrantSettings::try_from_model(basic)?, + specific: Settings { + target: targets, + limit: VolumeRateLimit { + max_volume: utils::try_bytes_to_u256(&limit.max_volume).map_err( + |e| diesel::result::Error::DeserializationError(Box::new(e)), + )?, + window: Duration::seconds(limit.window_secs as i64), + }, }, }, }) 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 9ba48be..ab4e8f0 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 @@ -11,7 +11,10 @@ use crate::db::{ schema::{evm_basic_grant, evm_transaction_log}, }; use crate::evm::{ - policies::{EvalContext, EvalViolation, Grant, Policy, SharedGrantSettings, VolumeRateLimit}, + policies::{ + CombinedSettings, EvalContext, EvalViolation, Grant, Policy, SharedGrantSettings, + VolumeRateLimit, + }, utils, }; @@ -108,9 +111,11 @@ async fn evaluate_passes_for_allowed_target() { let grant = Grant { id: 999, - shared_grant_id: 999, - shared: shared(), - settings: make_settings(vec![ALLOWED], 1_000_000), + common_settings_id: 999, + settings: CombinedSettings { + shared: shared(), + specific: make_settings(vec![ALLOWED], 1_000_000), + }, }; let context = ctx(ALLOWED, U256::from(100u64)); let m = EtherTransfer::analyze(&context).unwrap(); @@ -127,9 +132,11 @@ async fn evaluate_rejects_disallowed_target() { let grant = Grant { id: 999, - shared_grant_id: 999, - shared: shared(), - settings: make_settings(vec![ALLOWED], 1_000_000), + common_settings_id: 999, + settings: CombinedSettings { + shared: shared(), + specific: make_settings(vec![ALLOWED], 1_000_000), + }, }; let context = ctx(OTHER, U256::from(100u64)); let m = EtherTransfer::analyze(&context).unwrap(); @@ -167,9 +174,11 @@ async fn evaluate_passes_when_volume_within_limit() { let grant = Grant { id: grant_id, - shared_grant_id: basic.id, - shared: shared(), - settings, + common_settings_id: basic.id, + settings: CombinedSettings { + shared: shared(), + specific: settings, + }, }; let context = ctx(ALLOWED, U256::from(100u64)); let m = EtherTransfer::analyze(&context).unwrap(); @@ -207,9 +216,11 @@ async fn evaluate_rejects_volume_over_limit() { let grant = Grant { id: grant_id, - shared_grant_id: basic.id, - shared: shared(), - settings, + common_settings_id: basic.id, + settings: CombinedSettings { + shared: shared(), + specific: settings, + }, }; let context = ctx(ALLOWED, U256::from(1u64)); let m = EtherTransfer::analyze(&context).unwrap(); @@ -248,9 +259,11 @@ async fn evaluate_passes_at_exactly_volume_limit() { let grant = Grant { id: grant_id, - shared_grant_id: basic.id, - shared: shared(), - settings, + common_settings_id: basic.id, + settings: CombinedSettings { + shared: shared(), + specific: settings, + }, }; let context = ctx(ALLOWED, U256::from(100u64)); let m = EtherTransfer::analyze(&context).unwrap(); @@ -282,8 +295,11 @@ async fn try_find_grant_roundtrip() { assert!(found.is_some()); let g = found.unwrap(); - assert_eq!(g.settings.target, vec![ALLOWED]); - assert_eq!(g.settings.limit.max_volume, U256::from(1_000_000u64)); + assert_eq!(g.settings.specific.target, vec![ALLOWED]); + assert_eq!( + g.settings.specific.limit.max_volume, + U256::from(1_000_000u64) + ); } #[tokio::test] @@ -347,7 +363,7 @@ async fn find_all_grants_excludes_revoked() { let all = EtherTransfer::find_all_grants(&mut *conn).await.unwrap(); assert_eq!(all.len(), 1); - assert_eq!(all[0].settings.target, vec![ALLOWED]); + assert_eq!(all[0].settings.specific.target, vec![ALLOWED]); } #[tokio::test] @@ -363,8 +379,11 @@ async fn find_all_grants_multiple_targets() { let all = EtherTransfer::find_all_grants(&mut *conn).await.unwrap(); assert_eq!(all.len(), 1); - assert_eq!(all[0].settings.target.len(), 2); - assert_eq!(all[0].settings.limit.max_volume, U256::from(1_000_000u64)); + assert_eq!(all[0].settings.specific.target.len(), 2); + assert_eq!( + all[0].settings.specific.limit.max_volume, + U256::from(1_000_000u64) + ); } #[tokio::test] diff --git a/server/crates/arbiter-server/src/evm/policies/token_transfers/mod.rs b/server/crates/arbiter-server/src/evm/policies/token_transfers/mod.rs index cef49d9..0a1253c 100644 --- a/server/crates/arbiter-server/src/evm/policies/token_transfers/mod.rs +++ b/server/crates/arbiter-server/src/evm/policies/token_transfers/mod.rs @@ -10,11 +10,8 @@ use diesel::dsl::{auto_type, insert_into}; use diesel::sqlite::Sqlite; use diesel::{ExpressionMethods, prelude::*}; use diesel_async::{AsyncConnection, RunQueryDsl}; +use serde::Serialize; -use crate::db::models::{ - EvmBasicGrant, EvmTokenTransferGrant, EvmTokenTransferVolumeLimit, NewEvmTokenTransferGrant, - NewEvmTokenTransferLog, NewEvmTokenTransferVolumeLimit, SqliteTimestamp, -}; use crate::db::schema::{ evm_basic_grant, evm_token_transfer_grant, evm_token_transfer_log, evm_token_transfer_volume_limit, @@ -26,6 +23,15 @@ use crate::evm::{ }, utils, }; +use crate::{ + crypto::integrity::Integrable, + db::models::{ + EvmBasicGrant, EvmTokenTransferGrant, EvmTokenTransferVolumeLimit, + NewEvmTokenTransferGrant, NewEvmTokenTransferLog, NewEvmTokenTransferVolumeLimit, + SqliteTimestamp, + }, + evm::policies::CombinedSettings, +}; use super::{DatabaseID, EvalContext, EvalViolation}; @@ -38,9 +44,9 @@ fn grant_join() -> _ { #[derive(Clone, Debug, PartialEq, Eq, Hash)] pub struct Meaning { - pub(crate) token: &'static TokenInfo, - pub(crate) to: Address, - pub(crate) value: U256, + pub token: &'static TokenInfo, + pub to: Address, + pub value: U256, } impl std::fmt::Display for Meaning { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { @@ -58,12 +64,15 @@ impl From for SpecificMeaning { } // A grant for token transfers, which can be scoped to specific target addresses and volume limits -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize)] pub struct Settings { pub token_contract: Address, pub target: Option
, pub volume_limits: Vec, } +impl Integrable for Settings { + const KIND: &'static str = "TokenTransfer"; +} impl From for SpecificGrant { fn from(val: Settings) -> SpecificGrant { SpecificGrant::TokenTransfer(val) @@ -106,13 +115,20 @@ async fn check_volume_rate_limits( ) -> QueryResult> { let mut violations = Vec::new(); - let Some(longest_window) = grant.settings.volume_limits.iter().map(|l| l.window).max() else { + let Some(longest_window) = grant + .settings + .specific + .volume_limits + .iter() + .map(|l| l.window) + .max() + else { return Ok(violations); }; let past_transfers = query_relevant_past_transfers(grant.id, longest_window, db).await?; - for limit in &grant.settings.volume_limits { + for limit in &grant.settings.specific.volume_limits { let window_start = chrono::Utc::now() - limit.window; let prospective_cumulative_volume: U256 = past_transfers .iter() @@ -158,7 +174,7 @@ impl Policy for TokenTransfer { return Ok(violations); } - if let Some(allowed) = grant.settings.target + if let Some(allowed) = grant.settings.specific.target && allowed != meaning.to { violations.push(EvalViolation::InvalidTarget { target: meaning.to }); @@ -269,9 +285,11 @@ impl Policy for TokenTransfer { Ok(Some(Grant { id: token_grant.id, - shared_grant_id: token_grant.basic_grant_id, - shared: SharedGrantSettings::try_from_model(basic_grant)?, - settings, + common_settings_id: token_grant.basic_grant_id, + settings: CombinedSettings { + shared: SharedGrantSettings::try_from_model(basic_grant)?, + specific: settings, + }, })) } @@ -369,12 +387,14 @@ impl Policy for TokenTransfer { Ok(Grant { id: specific.id, - shared_grant_id: specific.basic_grant_id, - shared: SharedGrantSettings::try_from_model(basic)?, - settings: Settings { - token_contract: Address::from(token_contract), - target, - volume_limits, + common_settings_id: specific.basic_grant_id, + settings: CombinedSettings { + shared: SharedGrantSettings::try_from_model(basic)?, + specific: Settings { + token_contract: Address::from(token_contract), + target, + volume_limits, + }, }, }) }) 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 2f1b72f..c714afc 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 @@ -11,7 +11,10 @@ use crate::db::{ }; use crate::evm::{ abi::IERC20::transferCall, - policies::{EvalContext, EvalViolation, Grant, Policy, SharedGrantSettings, VolumeRateLimit}, + policies::{ + CombinedSettings, EvalContext, EvalViolation, Grant, Policy, SharedGrantSettings, + VolumeRateLimit, + }, utils, }; @@ -134,9 +137,11 @@ async fn evaluate_rejects_nonzero_eth_value() { let grant = Grant { id: 999, - shared_grant_id: 999, - shared: shared(), - settings: make_settings(None, None), + common_settings_id: 999, + settings: CombinedSettings { + shared: shared(), + specific: make_settings(None, None), + }, }; let calldata = transfer_calldata(RECIPIENT, U256::from(100u64)); let mut context = ctx(DAI, calldata); @@ -163,9 +168,11 @@ async fn evaluate_passes_any_recipient_when_no_restriction() { let grant = Grant { id: 999, - shared_grant_id: 999, - shared: shared(), - settings: make_settings(None, None), + common_settings_id: 999, + settings: CombinedSettings { + shared: shared(), + specific: make_settings(None, None), + }, }; let calldata = transfer_calldata(RECIPIENT, U256::from(100u64)); let context = ctx(DAI, calldata); @@ -183,9 +190,11 @@ async fn evaluate_passes_matching_restricted_recipient() { let grant = Grant { id: 999, - shared_grant_id: 999, - shared: shared(), - settings: make_settings(Some(RECIPIENT), None), + common_settings_id: 999, + settings: CombinedSettings { + shared: shared(), + specific: make_settings(Some(RECIPIENT), None), + }, }; let calldata = transfer_calldata(RECIPIENT, U256::from(100u64)); let context = ctx(DAI, calldata); @@ -203,9 +212,11 @@ async fn evaluate_rejects_wrong_restricted_recipient() { let grant = Grant { id: 999, - shared_grant_id: 999, - shared: shared(), - settings: make_settings(Some(RECIPIENT), None), + common_settings_id: 999, + settings: CombinedSettings { + shared: shared(), + specific: make_settings(Some(RECIPIENT), None), + }, }; let calldata = transfer_calldata(OTHER, U256::from(100u64)); let context = ctx(DAI, calldata); @@ -247,9 +258,11 @@ async fn evaluate_passes_volume_at_exact_limit() { let grant = Grant { id: grant_id, - shared_grant_id: basic.id, - shared: shared(), - settings, + common_settings_id: basic.id, + settings: CombinedSettings { + shared: shared(), + specific: settings, + }, }; let calldata = transfer_calldata(RECIPIENT, U256::from(100u64)); let context = ctx(DAI, calldata); @@ -290,9 +303,11 @@ async fn evaluate_rejects_volume_over_limit() { let grant = Grant { id: grant_id, - shared_grant_id: basic.id, - shared: shared(), - settings, + common_settings_id: basic.id, + settings: CombinedSettings { + shared: shared(), + specific: settings, + }, }; let calldata = transfer_calldata(RECIPIENT, U256::from(1u64)); let context = ctx(DAI, calldata); @@ -313,9 +328,11 @@ async fn evaluate_no_volume_limits_always_passes() { let grant = Grant { id: 999, - shared_grant_id: 999, - shared: shared(), - settings: make_settings(None, None), // no volume limits + common_settings_id: 999, + settings: CombinedSettings { + shared: shared(), + specific: make_settings(None, None), // no volume limits + }, }; let calldata = transfer_calldata(RECIPIENT, U256::from(u64::MAX)); let context = ctx(DAI, calldata); @@ -349,10 +366,13 @@ async fn try_find_grant_roundtrip() { assert!(found.is_some()); let g = found.unwrap(); - assert_eq!(g.settings.token_contract, DAI); - assert_eq!(g.settings.target, Some(RECIPIENT)); - assert_eq!(g.settings.volume_limits.len(), 1); - assert_eq!(g.settings.volume_limits[0].max_volume, U256::from(5_000u64)); + assert_eq!(g.settings.specific.token_contract, DAI); + assert_eq!(g.settings.specific.target, Some(RECIPIENT)); + assert_eq!(g.settings.specific.volume_limits.len(), 1); + assert_eq!( + g.settings.specific.volume_limits[0].max_volume, + U256::from(5_000u64) + ); } #[tokio::test] @@ -434,9 +454,9 @@ async fn find_all_grants_loads_volume_limits() { let all = TokenTransfer::find_all_grants(&mut *conn).await.unwrap(); assert_eq!(all.len(), 1); - assert_eq!(all[0].settings.volume_limits.len(), 1); + assert_eq!(all[0].settings.specific.volume_limits.len(), 1); assert_eq!( - all[0].settings.volume_limits[0].max_volume, + all[0].settings.specific.volume_limits[0].max_volume, U256::from(9_999u64) ); } 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 07b4b3c..28725c2 100644 --- a/server/crates/arbiter-server/src/grpc/user_agent/evm.rs +++ b/server/crates/arbiter-server/src/grpc/user_agent/evm.rs @@ -114,10 +114,10 @@ async fn handle_grant_list( grants: grants .into_iter() .map(|grant| GrantEntry { - id: grant.shared_grant_id, - wallet_access_id: grant.shared.wallet_access_id, - shared: Some(grant.shared.convert()), - specific: Some(grant.settings.convert()), + id: grant.common_settings_id, + wallet_access_id: grant.settings.shared.wallet_access_id, + shared: Some(grant.settings.shared.convert()), + specific: Some(grant.settings.specific.convert()), }) .collect(), }), diff --git a/server/crates/arbiter-server/src/integrity/evm.rs b/server/crates/arbiter-server/src/integrity/evm.rs deleted file mode 100644 index 8a82c34..0000000 --- a/server/crates/arbiter-server/src/integrity/evm.rs +++ /dev/null @@ -1,336 +0,0 @@ -use alloy::primitives::Address; -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(Clone, PartialEq, ::prost::Message)] -pub struct IntegrityVolumeRateLimit { - #[prost(bytes, tag = "1")] - pub max_volume: Vec, - #[prost(int64, tag = "2")] - pub window_secs: i64, -} - -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct IntegrityTransactionRateLimit { - #[prost(uint32, tag = "1")] - pub count: u32, - #[prost(int64, tag = "2")] - pub window_secs: i64, -} - -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct IntegritySharedGrantSettings { - #[prost(int32, tag = "1")] - pub wallet_access_id: i32, - #[prost(uint64, tag = "2")] - pub chain_id: u64, - #[prost(message, optional, tag = "3")] - pub valid_from: Option<::prost_types::Timestamp>, - #[prost(message, optional, tag = "4")] - pub valid_until: Option<::prost_types::Timestamp>, - #[prost(bytes, optional, tag = "5")] - pub max_gas_fee_per_gas: Option>, - #[prost(bytes, optional, tag = "6")] - pub max_priority_fee_per_gas: Option>, - #[prost(message, optional, tag = "7")] - pub rate_limit: Option, -} - -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct IntegrityEtherTransferSettings { - #[prost(bytes, repeated, tag = "1")] - pub targets: Vec>, - #[prost(message, optional, tag = "2")] - pub limit: Option, -} - -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct IntegrityTokenTransferSettings { - #[prost(bytes, tag = "1")] - pub token_contract: Vec, - #[prost(bytes, optional, tag = "2")] - pub target: Option>, - #[prost(message, repeated, tag = "3")] - pub volume_limits: Vec, -} - -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct IntegritySpecificGrant { - #[prost(oneof = "integrity_specific_grant::Grant", tags = "1, 2")] - pub grant: Option, -} - -pub mod integrity_specific_grant { - use super::*; - - #[derive(Clone, PartialEq, ::prost::Oneof)] - pub enum Grant { - #[prost(message, tag = "1")] - EtherTransfer(IntegrityEtherTransferSettings), - #[prost(message, tag = "2")] - TokenTransfer(IntegrityTokenTransferSettings), - } -} - -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct IntegrityEvmGrantPayloadV1 { - #[prost(int32, tag = "1")] - pub basic_grant_id: i32, - #[prost(message, optional, tag = "2")] - pub shared: Option, - #[prost(message, optional, tag = "3")] - pub specific: Option, - #[prost(message, optional, tag = "4")] - pub revoked_at: Option<::prost_types::Timestamp>, -} - -#[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 deleted file mode 100644 index a8e8936..0000000 --- a/server/crates/arbiter-server/src/integrity/mod.rs +++ /dev/null @@ -1,301 +0,0 @@ -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)] -pub enum Error { - #[error("Database error: {0}")] - Database(#[from] db::DatabaseError), - - #[error("KeyHolder error: {0}")] - Keyholder(#[from] crate::actors::keyholder::Error), - - #[error("KeyHolder mailbox error")] - KeyholderSend, - - #[error("Integrity envelope is missing for entity {entity_kind}")] - MissingEnvelope { entity_kind: &'static str }, - - #[error( - "Integrity payload version mismatch for entity {entity_kind}: expected {expected}, found {found}" - )] - PayloadVersionMismatch { - entity_kind: &'static str, - expected: i32, - found: i32, - }, - - #[error("Integrity MAC mismatch for entity {entity_kind}")] - 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 6baf5c4..e551182 100644 --- a/server/crates/arbiter-server/src/lib.rs +++ b/server/crates/arbiter-server/src/lib.rs @@ -7,7 +7,6 @@ pub mod crypto; pub mod db; pub mod evm; pub mod grpc; -pub mod integrity; pub mod safe_cell; pub mod utils; diff --git a/server/crates/arbiter-server/tests/user_agent/auth.rs b/server/crates/arbiter-server/tests/user_agent/auth.rs index 0006343..1812785 100644 --- a/server/crates/arbiter-server/tests/user_agent/auth.rs +++ b/server/crates/arbiter-server/tests/user_agent/auth.rs @@ -187,7 +187,6 @@ pub async fn test_challenge_auth_rejects_integrity_tag_mismatch_when_unsealed() .values(( schema::useragent_client::public_key.eq(pubkey_bytes.clone()), schema::useragent_client::key_type.eq(1i32), - schema::useragent_client::pubkey_integrity_tag.eq(Some(vec![0u8; 32])), )) .execute(&mut conn) .await diff --git a/server/crates/arbiter-server/tests/user_agent/unseal.rs b/server/crates/arbiter-server/tests/user_agent/unseal.rs index 16bb257..57cd1ee 100644 --- a/server/crates/arbiter-server/tests/user_agent/unseal.rs +++ b/server/crates/arbiter-server/tests/user_agent/unseal.rs @@ -151,43 +151,4 @@ pub async fn test_unseal_retry_after_invalid_key() { let response = user_agent.ask(encrypted_key).await; assert!(matches!(response, Ok(()))); } -} - -#[tokio::test] -#[test_log::test] -pub async fn test_unseal_backfills_missing_pubkey_integrity_tags() { - let seal_key = b"test-seal-key"; - let (db, user_agent) = setup_sealed_user_agent(seal_key).await; - - { - let mut conn = db.get().await.unwrap(); - insert_into(arbiter_server::db::schema::useragent_client::table) - .values(( - arbiter_server::db::schema::useragent_client::public_key - .eq(vec![1u8, 2u8, 3u8, 4u8]), - arbiter_server::db::schema::useragent_client::key_type.eq(1i32), - arbiter_server::db::schema::useragent_client::pubkey_integrity_tag - .eq(Option::>::None), - )) - .execute(&mut conn) - .await - .unwrap(); - } - - let encrypted_key = client_dh_encrypt(&user_agent, seal_key).await; - let response = user_agent.ask(encrypted_key).await; - assert!(matches!(response, Ok(()))); - - { - let mut conn = db.get().await.unwrap(); - let tags: Vec>> = arbiter_server::db::schema::useragent_client::table - .select(arbiter_server::db::schema::useragent_client::pubkey_integrity_tag) - .load(&mut conn) - .await - .unwrap(); - assert!( - tags.iter() - .all(|tag| matches!(tag, Some(v) if v.len() == 32)) - ); - } -} +} \ No newline at end of file From 00745bb381133e8430a1d939d787919c807f891c Mon Sep 17 00:00:00 2001 From: hdbg Date: Sun, 5 Apr 2026 14:35:41 +0200 Subject: [PATCH 5/5] tests(server): fixed for new integrity checks --- .../src/actors/user_agent/auth/state.rs | 8 +-- .../arbiter-server/src/crypto/integrity/v1.rs | 3 +- .../arbiter-server/tests/user_agent/auth.rs | 58 +++++++++++++++++-- 3 files changed, 54 insertions(+), 15 deletions(-) diff --git a/server/crates/arbiter-server/src/actors/user_agent/auth/state.rs b/server/crates/arbiter-server/src/actors/user_agent/auth/state.rs index a5e212b..5ce6374 100644 --- a/server/crates/arbiter-server/src/actors/user_agent/auth/state.rs +++ b/server/crates/arbiter-server/src/actors/user_agent/auth/state.rs @@ -102,13 +102,7 @@ async fn verify_integrity( Error::internal("Integrity verification failed") })?; - match result { - AttestationStatus::Attested | AttestationStatus::Unavailable => Ok(()), - AttestationStatus::NotAttested => { - error!(?pubkey, "Integrity verification failed: not attested"); - Err(Error::internal("Database tampering detected")) - } - } + Ok(()) } diff --git a/server/crates/arbiter-server/src/crypto/integrity/v1.rs b/server/crates/arbiter-server/src/crypto/integrity/v1.rs index 9a7b923..3fa7d17 100644 --- a/server/crates/arbiter-server/src/crypto/integrity/v1.rs +++ b/server/crates/arbiter-server/src/crypto/integrity/v1.rs @@ -51,7 +51,6 @@ pub enum Error { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AttestationStatus { Attested, - NotAttested, Unavailable, } @@ -195,7 +194,7 @@ pub async fn verify_entity( match result { Ok(true) => Ok(AttestationStatus::Attested), - Ok(false) => Ok(AttestationStatus::NotAttested), + Ok(false) => Err(Error::MacMismatch { entity_kind: E::KIND }), Err(SendError::HandlerError(keyholder::Error::NotBootstrapped)) => Ok(AttestationStatus::Unavailable), Err(_) => Err(Error::KeyholderSend), } diff --git a/server/crates/arbiter-server/tests/user_agent/auth.rs b/server/crates/arbiter-server/tests/user_agent/auth.rs index 1812785..660fae4 100644 --- a/server/crates/arbiter-server/tests/user_agent/auth.rs +++ b/server/crates/arbiter-server/tests/user_agent/auth.rs @@ -4,8 +4,9 @@ use arbiter_server::{ GlobalActors, bootstrap::GetToken, keyholder::Bootstrap, - user_agent::{AuthPublicKey, UserAgentConnection, auth}, + user_agent::{AuthPublicKey, UserAgentConnection, UserAgentCredentials, auth}, }, + crypto::integrity, db::{self, schema}, safe_cell::{SafeCell, SafeCellHandle as _}, }; @@ -20,6 +21,13 @@ use super::common::ChannelTransport; pub async fn test_bootstrap_token_auth() { let db = db::create_test_pool().await; let actors = GlobalActors::spawn(db.clone()).await.unwrap(); + actors + .key_holder + .ask(Bootstrap { + seal_key_raw: SafeCell::new(b"test-seal-key".to_vec()), + }) + .await + .unwrap(); let token = actors.bootstrapper.ask(GetToken).await.unwrap().unwrap(); let (server_transport, mut test_transport) = ChannelTransport::new(); @@ -99,20 +107,39 @@ pub async fn test_bootstrap_invalid_token_auth() { pub async fn test_challenge_auth() { let db = db::create_test_pool().await; let actors = GlobalActors::spawn(db.clone()).await.unwrap(); + actors + .key_holder + .ask(Bootstrap { + seal_key_raw: SafeCell::new(b"test-seal-key".to_vec()), + }) + .await + .unwrap(); let new_key = ed25519_dalek::SigningKey::generate(&mut rand::rng()); let pubkey_bytes = new_key.verifying_key().to_bytes().to_vec(); { let mut conn = db.get().await.unwrap(); - insert_into(schema::useragent_client::table) + let id: i32 = insert_into(schema::useragent_client::table) .values(( schema::useragent_client::public_key.eq(pubkey_bytes.clone()), schema::useragent_client::key_type.eq(1i32), )) - .execute(&mut conn) + .returning(schema::useragent_client::id) + .get_result(&mut conn) .await .unwrap(); + integrity::sign_entity( + &mut conn, + &actors.key_holder, + &UserAgentCredentials { + pubkey: AuthPublicKey::Ed25519(new_key.verifying_key()), + nonce: 1, + }, + id, + ) + .await + .unwrap(); } let (server_transport, mut test_transport) = ChannelTransport::new(); @@ -210,7 +237,7 @@ pub async fn test_challenge_auth_rejects_integrity_tag_mismatch_when_unsealed() assert!(matches!( task.await.unwrap(), - Err(auth::Error::InvalidChallengeSolution) + Err(auth::Error::Internal { .. }) )); } @@ -219,20 +246,39 @@ pub async fn test_challenge_auth_rejects_integrity_tag_mismatch_when_unsealed() pub async fn test_challenge_auth_rejects_invalid_signature() { let db = db::create_test_pool().await; let actors = GlobalActors::spawn(db.clone()).await.unwrap(); + actors + .key_holder + .ask(Bootstrap { + seal_key_raw: SafeCell::new(b"test-seal-key".to_vec()), + }) + .await + .unwrap(); let new_key = ed25519_dalek::SigningKey::generate(&mut rand::rng()); let pubkey_bytes = new_key.verifying_key().to_bytes().to_vec(); { let mut conn = db.get().await.unwrap(); - insert_into(schema::useragent_client::table) + let id: i32 = insert_into(schema::useragent_client::table) .values(( schema::useragent_client::public_key.eq(pubkey_bytes.clone()), schema::useragent_client::key_type.eq(1i32), )) - .execute(&mut conn) + .returning(schema::useragent_client::id) + .get_result(&mut conn) .await .unwrap(); + integrity::sign_entity( + &mut conn, + &actors.key_holder, + &UserAgentCredentials { + pubkey: AuthPublicKey::Ed25519(new_key.verifying_key()), + nonce: 1, + }, + id, + ) + .await + .unwrap(); } let (server_transport, mut test_transport) = ChannelTransport::new();