fix(server): replaced postcard-based integrity fingerprint with custom trait providing order-independent hashing

This commit is contained in:
hdbg
2026-04-06 15:45:46 +02:00
parent f6b62ab884
commit 5bc0c42cc7
8 changed files with 205 additions and 149 deletions

77
server/Cargo.lock generated
View File

@@ -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"

View File

@@ -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

View File

@@ -4,58 +4,17 @@ use crate::{
db::{self, models::KeyType},
};
fn serialize_ecdsa<S>(key: &k256::ecdsa::VerifyingKey, serializer: S) -> Result<S::Ok, S::Error>
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<k256::ecdsa::VerifyingKey, D::Error>
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<E>(self, v: &[u8]) -> Result<Self::Value, E>
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<H: sha2::Digest>(&self, hasher: &mut H) {
hasher.update(&self.to_stored_bytes());
}
}
impl Hashable for UserAgentCredentials {
fn hash<H: sha2::Digest>(&self, hasher: &mut H) {
self.pubkey.hash(hasher);
self.nonce.hash(hasher);
}
}

View File

@@ -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<Sha256>;
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<u8>, bytes: &[u8]) {
@@ -109,8 +109,7 @@ pub async fn sign_entity<E: Integrable>(
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<E: Integrable>(
});
}
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<u8>,
}
impl Hashable for DummyEntity {
fn hash<H: Digest>(&self, hasher: &mut H) {
self.payload_version.hash(hasher);
self.payload.hash(hasher);
}
}
impl Integrable for DummyEntity {
const KIND: &'static str = "dummy_entity";
}

View File

@@ -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<H: Digest>(&self, hasher: &mut H);
}
macro_rules! impl_numeric {
($($t:ty),*) => {
$(
impl Hashable for $t {
fn hash<H: Digest>(&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<H: Digest>(&self, hasher: &mut H) {
hasher.update(self);
}
}
impl Hashable for String {
fn hash<H: Digest>(&self, hasher: &mut H) {
hasher.update(self.as_bytes());
}
}
impl<T: Hashable + PartialOrd> Hashable for Vec<T> {
fn hash<H: Digest>(&self, hasher: &mut H) {
let ref_sorted = {
let mut sorted = self.iter().collect::<Vec<_>>();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
sorted
};
for item in ref_sorted {
item.hash(hasher);
}
}
}
impl<T: Hashable + PartialOrd> Hashable for HashSet<T> {
fn hash<H: Digest>(&self, hasher: &mut H) {
let ref_sorted = {
let mut sorted = self.iter().collect::<Vec<_>>();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
sorted
};
for item in ref_sorted {
item.hash(hasher);
}
}
}
impl<T: Hashable> Hashable for Option<T> {
fn hash<H: Digest>(&self, hasher: &mut H) {
match self {
Some(value) => {
hasher.update(&[1]);
value.hash(hasher);
}
None => hasher.update(&[0]),
}
}
}
impl<T: Hashable> Hashable for Box<T> {
fn hash<H: Digest>(&self, hasher: &mut H) {
self.as_ref().hash(hasher);
}
}
impl<T: Hashable> Hashable for &T {
fn hash<H: Digest>(&self, hasher: &mut H) {
(*self).hash(hasher);
}
}
impl Hashable for alloy::primitives::Address {
fn hash<H: Digest>(&self, hasher: &mut H) {
hasher.update(self.as_slice());
}
}
impl Hashable for alloy::primitives::U256 {
fn hash<H: Digest>(&self, hasher: &mut H) {
hasher.update(self.to_be_bytes::<32>());
}
}
impl Hashable for chrono::Duration {
fn hash<H: Digest>(&self, hasher: &mut H) {
hasher.update(&self.num_seconds().to_be_bytes());
}
}
impl Hashable for chrono::DateTime<chrono::Utc> {
fn hash<H: Digest>(&self, hasher: &mut H) {
hasher.update(&self.timestamp_millis().to_be_bytes());
}
}

View File

@@ -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<PolicySettings> {
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<PolicyGrant> {
pub shared: SharedGrantSettings,
pub specific: PolicyGrant,
@@ -220,3 +219,38 @@ impl<P: Integrable> Integrable for CombinedSettings<P> {
const KIND: &'static str = P::KIND;
const VERSION: i32 = P::VERSION;
}
use crate::crypto::integrity::hashing::Hashable;
impl Hashable for TransactionRateLimit {
fn hash<H: sha2::Digest>(&self, hasher: &mut H) {
self.count.hash(hasher);
self.window.hash(hasher);
}
}
impl Hashable for VolumeRateLimit {
fn hash<H: sha2::Digest>(&self, hasher: &mut H) {
self.max_volume.hash(hasher);
self.window.hash(hasher);
}
}
impl Hashable for SharedGrantSettings {
fn hash<H: sha2::Digest>(&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<P: Hashable> Hashable for CombinedSettings<P> {
fn hash<H: sha2::Digest>(&self, hasher: &mut H) {
self.shared.hash(hasher);
self.specific.hash(hasher);
}
}

View File

@@ -52,7 +52,7 @@ impl From<Meaning> 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<Address>,
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<H: sha2::Digest>(&self, hasher: &mut H) {
self.target.hash(hasher);
self.limit.hash(hasher);
}
}
impl From<Settings> for SpecificGrant {
fn from(val: Settings) -> SpecificGrant {
SpecificGrant::EtherTransfer(val)

View File

@@ -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<Meaning> 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<Address>,
@@ -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<H: sha2::Digest>(&self, hasher: &mut H) {
self.token_contract.hash(hasher);
self.target.hash(hasher);
self.volume_limits.hash(hasher);
}
}
impl From<Settings> for SpecificGrant {
fn from(val: Settings) -> SpecificGrant {
SpecificGrant::TokenTransfer(val)