fix(server): replaced postcard-based integrity fingerprint with custom trait providing order-independent hashing
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
107
server/crates/arbiter-server/src/crypto/integrity/v1/hashing.rs
Normal file
107
server/crates/arbiter-server/src/crypto/integrity/v1/hashing.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user