From 5bc0c42cc7fbda5bd7499be9662deee82f4d6695 Mon Sep 17 00:00:00 2001 From: hdbg Date: Mon, 6 Apr 2026 15:45:46 +0200 Subject: [PATCH] fix(server): replaced `postcard`-based integrity fingerprint with custom trait providing order-independent hashing --- server/Cargo.lock | 77 ------------- server/crates/arbiter-server/Cargo.toml | 2 - .../src/actors/user_agent/mod.rs | 61 +++------- .../arbiter-server/src/crypto/integrity/v1.rs | 35 +++--- .../src/crypto/integrity/v1/hashing.rs | 107 ++++++++++++++++++ .../crates/arbiter-server/src/evm/policies.rs | 46 +++++++- .../src/evm/policies/ether_transfer/mod.rs | 11 +- .../src/evm/policies/token_transfers/mod.rs | 15 ++- 8 files changed, 205 insertions(+), 149 deletions(-) create mode 100644 server/crates/arbiter-server/src/crypto/integrity/v1/hashing.rs diff --git a/server/Cargo.lock b/server/Cargo.lock index e94a9b8..25de024 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -745,7 +745,6 @@ dependencies = [ "memsafe", "mutants", "pem", - "postcard", "prost", "prost-types", "rand 0.10.0", @@ -755,7 +754,6 @@ dependencies = [ "rstest", "rustls", "secrecy", - "serde", "serde_with", "sha2 0.10.9", "smlang", @@ -1059,15 +1057,6 @@ 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" @@ -1458,15 +1447,6 @@ 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" @@ -1574,12 +1554,6 @@ 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" @@ -2045,18 +2019,6 @@ 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" @@ -2474,15 +2436,6 @@ 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" @@ -2517,20 +2470,6 @@ 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" @@ -3597,19 +3536,6 @@ 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" @@ -4798,9 +4724,6 @@ name = "spin" version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" -dependencies = [ - "lock_api", -] [[package]] name = "spki" diff --git a/server/crates/arbiter-server/Cargo.toml b/server/crates/arbiter-server/Cargo.toml index dff79e9..1c7050a 100644 --- a/server/crates/arbiter-server/Cargo.toml +++ b/server/crates/arbiter-server/Cargo.toml @@ -58,8 +58,6 @@ 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" mutants.workspace = true 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 dd65fc5..2451e49 100644 --- a/server/crates/arbiter-server/src/actors/user_agent/mod.rs +++ b/server/crates/arbiter-server/src/actors/user_agent/mod.rs @@ -4,58 +4,17 @@ use crate::{ 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, Serialize)] +#[derive(Clone, Debug)] 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)] +#[derive(Debug)] pub struct UserAgentCredentials { pub pubkey: AuthPublicKey, pub nonce: i32, @@ -143,5 +102,19 @@ pub mod auth; pub mod session; pub use auth::authenticate; -use serde::Serialize; pub use session::UserAgentSession; + +use crate::crypto::integrity::hashing::Hashable; + +impl Hashable for AuthPublicKey { + fn hash(&self, hasher: &mut H) { + hasher.update(&self.to_stored_bytes()); + } +} + +impl Hashable for UserAgentCredentials { + fn hash(&self, hasher: &mut H) { + self.pubkey.hash(hasher); + self.nonce.hash(hasher); + } +} diff --git a/server/crates/arbiter-server/src/crypto/integrity/v1.rs b/server/crates/arbiter-server/src/crypto/integrity/v1.rs index fb0fd7e..08bc3e7 100644 --- a/server/crates/arbiter-server/src/crypto/integrity/v1.rs +++ b/server/crates/arbiter-server/src/crypto/integrity/v1.rs @@ -1,7 +1,5 @@ -use crate::{actors::keyholder, crypto::KeyCell, safe_cell::SafeCellHandle as _}; -use chacha20poly1305::Key; +use crate::{actors::keyholder, crypto::integrity::hashing::Hashable, safe_cell::SafeCellHandle as _}; use hmac::{Hmac, Mac as _}; -use serde::Serialize; use sha2::Sha256; use diesel::{ExpressionMethods as _, QueryDsl, dsl::insert_into, sqlite::Sqlite}; @@ -9,6 +7,8 @@ use diesel_async::{AsyncConnection, RunQueryDsl}; use kameo::{actor::ActorRef, error::SendError}; use sha2::Digest as _; +pub mod hashing; + use crate::{ actors::keyholder::{KeyHolder, SignIntegrity, VerifyIntegrity}, db::{ @@ -44,8 +44,6 @@ pub enum Error { #[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)] @@ -59,13 +57,15 @@ pub const INTEGRITY_SUBKEY_TAG: &[u8] = b"arbiter/db-integrity-key/v1"; pub type HmacSha256 = Hmac; -pub trait Integrable: Serialize { +pub trait Integrable: Hashable { const KIND: &'static str; const VERSION: i32 = 1; } -fn payload_hash(payload: &[u8]) -> [u8; 32] { - Sha256::digest(payload).into() +fn payload_hash(payload: &impl Hashable) -> [u8; 32] { + let mut hasher = Sha256::new(); + payload.hash(&mut hasher); + hasher.finalize().into() } fn push_len_prefixed(out: &mut Vec, bytes: &[u8]) { @@ -109,8 +109,7 @@ pub async fn sign_entity( entity: &E, entity_id: impl IntoId, ) -> Result<(), Error> { - let payload = postcard::to_stdvec(entity)?; - let payload_hash = payload_hash(&payload); + let payload_hash = payload_hash(&entity); let entity_id = entity_id.into_id(); @@ -176,8 +175,7 @@ pub async fn verify_entity( }); } - let payload = postcard::to_stdvec(entity)?; - let payload_hash = payload_hash(&payload); + let payload_hash = payload_hash(&entity); let mac_input = build_mac_input(E::KIND, &entity_id, envelope.payload_version, &payload_hash); let result = keyholder @@ -205,21 +203,26 @@ mod tests { use diesel::{ExpressionMethods as _, QueryDsl}; use diesel_async::RunQueryDsl; use kameo::{actor::ActorRef, prelude::Spawn}; + use sha2::Digest; use crate::{ - actors::keyholder::{Bootstrap, KeyHolder}, - db::{self, schema}, - safe_cell::{SafeCell, SafeCellHandle as _}, + actors::keyholder::{Bootstrap, KeyHolder}, crypto::integrity::hashing::Hashable, db::{self, schema}, safe_cell::{SafeCell, SafeCellHandle as _} }; use super::{Error, Integrable, sign_entity, verify_entity}; - #[derive(Clone, serde::Serialize)] + #[derive(Clone)] struct DummyEntity { payload_version: i32, payload: Vec, } + impl Hashable for DummyEntity { + fn hash(&self, hasher: &mut H) { + self.payload_version.hash(hasher); + self.payload.hash(hasher); + } + } impl Integrable for DummyEntity { const KIND: &'static str = "dummy_entity"; } diff --git a/server/crates/arbiter-server/src/crypto/integrity/v1/hashing.rs b/server/crates/arbiter-server/src/crypto/integrity/v1/hashing.rs new file mode 100644 index 0000000..5a0e7da --- /dev/null +++ b/server/crates/arbiter-server/src/crypto/integrity/v1/hashing.rs @@ -0,0 +1,107 @@ +use hmac::digest::Digest; +use std::collections::HashSet; + +/// Deterministically hash a value by feeding its fields into the hasher in a consistent order. +pub trait Hashable { + fn hash(&self, hasher: &mut H); +} + +macro_rules! impl_numeric { +($($t:ty),*) => { + $( + impl Hashable for $t { + fn hash(&self, hasher: &mut H) { + hasher.update(&self.to_be_bytes()); + } + } + )* +}; +} + +impl_numeric!(u8, u16, u32, u64, i8, i16, i32, i64); + +impl Hashable for &[u8] { + fn hash(&self, hasher: &mut H) { + hasher.update(self); + } +} + +impl Hashable for String { + fn hash(&self, hasher: &mut H) { + hasher.update(self.as_bytes()); + } +} + +impl Hashable for Vec { + fn hash(&self, hasher: &mut H) { + let ref_sorted = { + let mut sorted = self.iter().collect::>(); + sorted.sort_by(|a, b| a.partial_cmp(b).unwrap()); + sorted + }; + for item in ref_sorted { + item.hash(hasher); + } + } +} + +impl Hashable for HashSet { + fn hash(&self, hasher: &mut H) { + let ref_sorted = { + let mut sorted = self.iter().collect::>(); + sorted.sort_by(|a, b| a.partial_cmp(b).unwrap()); + sorted + }; + for item in ref_sorted { + item.hash(hasher); + } + } +} + +impl Hashable for Option { + fn hash(&self, hasher: &mut H) { + match self { + Some(value) => { + hasher.update(&[1]); + value.hash(hasher); + } + None => hasher.update(&[0]), + } + } +} + +impl Hashable for Box { + fn hash(&self, hasher: &mut H) { + self.as_ref().hash(hasher); + } +} + +impl Hashable for &T { + fn hash(&self, hasher: &mut H) { + (*self).hash(hasher); + } +} + +impl Hashable for alloy::primitives::Address { + fn hash(&self, hasher: &mut H) { + hasher.update(self.as_slice()); + } +} + +impl Hashable for alloy::primitives::U256 { + fn hash(&self, hasher: &mut H) { + hasher.update(self.to_be_bytes::<32>()); + } +} + +impl Hashable for chrono::Duration { + fn hash(&self, hasher: &mut H) { + hasher.update(&self.num_seconds().to_be_bytes()); + } +} + +impl Hashable for chrono::DateTime { + fn hash(&self, hasher: &mut H) { + hasher.update(&self.timestamp_millis().to_be_bytes()); + } +} \ No newline at end of file diff --git a/server/crates/arbiter-server/src/evm/policies.rs b/server/crates/arbiter-server/src/evm/policies.rs index 7d73d75..2ce22e2 100644 --- a/server/crates/arbiter-server/src/evm/policies.rs +++ b/server/crates/arbiter-server/src/evm/policies.rs @@ -7,7 +7,6 @@ use diesel::{ }; use diesel_async::{AsyncConnection, RunQueryDsl}; -use serde::Serialize; use thiserror::Error; use crate::{ @@ -64,7 +63,7 @@ pub enum EvalViolation { pub type DatabaseID = i32; -#[derive(Debug, Serialize)] +#[derive(Debug)] pub struct Grant { pub id: DatabaseID, pub common_settings_id: DatabaseID, // ID of the basic grant for shared-logic checks like rate limits and validity periods @@ -128,19 +127,19 @@ pub enum SpecificMeaning { TokenTransfer(token_transfers::Meaning), } -#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize)] +#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct TransactionRateLimit { pub count: u32, pub window: Duration, } -#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize)] +#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct VolumeRateLimit { pub max_volume: U256, pub window: Duration, } -#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize)] +#[derive(Clone, Debug, PartialEq, Eq, Hash)] pub struct SharedGrantSettings { pub wallet_access_id: i32, pub chain: ChainId, @@ -201,7 +200,7 @@ pub enum SpecificGrant { TokenTransfer(token_transfers::Settings), } -#[derive(Debug, Serialize)] +#[derive(Debug)] pub struct CombinedSettings { pub shared: SharedGrantSettings, pub specific: PolicyGrant, @@ -220,3 +219,38 @@ impl Integrable for CombinedSettings

{ const KIND: &'static str = P::KIND; const VERSION: i32 = P::VERSION; } + +use crate::crypto::integrity::hashing::Hashable; + +impl Hashable for TransactionRateLimit { + fn hash(&self, hasher: &mut H) { + self.count.hash(hasher); + self.window.hash(hasher); + } +} + +impl Hashable for VolumeRateLimit { + fn hash(&self, hasher: &mut H) { + self.max_volume.hash(hasher); + self.window.hash(hasher); + } +} + +impl Hashable for SharedGrantSettings { + fn hash(&self, hasher: &mut H) { + self.wallet_access_id.hash(hasher); + self.chain.hash(hasher); + self.valid_from.hash(hasher); + self.valid_until.hash(hasher); + self.max_gas_fee_per_gas.hash(hasher); + self.max_priority_fee_per_gas.hash(hasher); + self.rate_limit.hash(hasher); + } +} + +impl Hashable for CombinedSettings

{ + fn hash(&self, hasher: &mut H) { + self.shared.hash(hasher); + self.specific.hash(hasher); + } +} 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 57a38dd..a641403 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 @@ -52,7 +52,7 @@ impl From for SpecificMeaning { } // A grant for ether transfers, which can be scoped to specific target addresses and volume limits -#[derive(Debug, Clone, serde::Serialize)] +#[derive(Debug, Clone)] pub struct Settings { pub target: Vec

, pub limit: VolumeRateLimit, @@ -61,6 +61,15 @@ impl Integrable for Settings { const KIND: &'static str = "EtherTransfer"; } +use crate::crypto::integrity::hashing::Hashable; + +impl Hashable for Settings { + fn hash(&self, hasher: &mut H) { + self.target.hash(hasher); + self.limit.hash(hasher); + } +} + impl From for SpecificGrant { fn from(val: Settings) -> SpecificGrant { SpecificGrant::EtherTransfer(val) 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 0a1253c..04920b2 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,8 +10,6 @@ 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::schema::{ evm_basic_grant, evm_token_transfer_grant, evm_token_transfer_log, evm_token_transfer_volume_limit, @@ -64,7 +62,7 @@ impl From for SpecificMeaning { } // A grant for token transfers, which can be scoped to specific target addresses and volume limits -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone)] pub struct Settings { pub token_contract: Address, pub target: Option
, @@ -73,6 +71,17 @@ pub struct Settings { impl Integrable for Settings { const KIND: &'static str = "TokenTransfer"; } + +use crate::crypto::integrity::hashing::Hashable; + +impl Hashable for Settings { + fn hash(&self, hasher: &mut H) { + self.token_contract.hash(hasher); + self.target.hash(hasher); + self.volume_limits.hash(hasher); + } +} + impl From for SpecificGrant { fn from(val: Settings) -> SpecificGrant { SpecificGrant::TokenTransfer(val)