feat(server): integrity envelope engine for EVM grants with HMAC verification
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-lint Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful

This commit is contained in:
CleverWild
2026-04-04 21:52:50 +02:00
parent c6f440fdad
commit 7f5393650b
20 changed files with 910 additions and 50 deletions

50
protobufs/integrity.proto Normal file
View File

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

2
server/Cargo.lock generated
View File

@@ -737,12 +737,14 @@ dependencies = [
"ed25519-dalek",
"fatality",
"futures",
"hmac",
"insta",
"k256",
"kameo",
"memsafe",
"miette",
"pem",
"prost",
"prost-types",
"rand 0.10.0",
"rcgen",

View File

@@ -43,3 +43,4 @@ k256 = { version = "0.13.4", features = ["ecdsa", "pkcs8"] }
rsa = { version = "0.9", features = ["sha2"] }
sha2 = "0.10"
spki = "0.7"
prost = "0.14.3"

View File

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

View File

@@ -13,6 +13,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
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()],
)

View File

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

View File

@@ -49,9 +49,11 @@ pem = "3.0.6"
k256.workspace = true
rsa.workspace = true
sha2.workspace = true
hmac = "0.12.1"
spki.workspace = true
alloy.workspace = true
prost-types.workspace = true
prost.workspace = true
arbiter-tokens-registry.path = "../arbiter-tokens-registry"
[dev-dependencies]

View File

@@ -191,3 +191,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);

View File

@@ -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 _},
};
@@ -65,6 +66,10 @@ pub enum Error {
#[error("Database error: {0}")]
#[diagnostic(code(arbiter::evm::database))]
Database(#[from] DatabaseError),
#[error("Vault is sealed")]
#[diagnostic(code(arbiter::evm::vault_sealed))]
VaultSealed,
}
#[derive(Actor)]
@@ -80,7 +85,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,
@@ -88,6 +93,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]
@@ -141,7 +160,9 @@ impl EvmActor {
&mut self,
basic: SharedGrantSettings,
grant: SpecificGrant,
) -> Result<i32, DatabaseError> {
) -> Result<i32, Error> {
self.ensure_unsealed().await?;
match grant {
SpecificGrant::EtherTransfer(settings) => {
self.engine
@@ -150,6 +171,7 @@ impl EvmActor {
specific: settings,
})
.await
.map_err(Error::from)
}
SpecificGrant::TokenTransfer(settings) => {
self.engine
@@ -158,29 +180,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<Vec<Grant<SpecificGrant>>, Error> {
Ok(self
.engine
.list_all_grants()
.await
.map_err(DatabaseError::from)?)
Ok(self.engine.list_all_grants().await?)
}
#[message]

View File

@@ -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};
@@ -19,6 +21,10 @@ use crate::{
};
use encryption::v1::{self, KeyCell, Nonce};
type HmacSha256 = Hmac<Sha256>;
const INTEGRITY_SUBKEY_TAG: &[u8] = b"arbiter/db-integrity-key/v1";
pub mod encryption;
#[derive(Default, EnumDiscriminants)]
@@ -138,6 +144,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<Vec<u8>>) -> Result<(), Error> {
if !matches!(self.state, State::Unbootstrapped) {
@@ -328,6 +347,59 @@ impl KeyHolder {
self.state.discriminant()
}
#[message]
pub fn sign_integrity(&mut self, mac_input: Vec<u8>) -> Result<(i32, Vec<u8>), 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<u8>,
expected_mac: Vec<u8>,
key_version: i32,
) -> Result<bool, Error> {
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 {

View File

@@ -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<i32, Error> {
) -> Result<i32, GrantMutationError> {
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)
}
}
}

View File

@@ -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};
@@ -376,3 +376,22 @@ pub struct EvmTokenTransferLog {
pub value: Vec<u8>,
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<u8>,
pub payload_version: i32,
pub key_version: i32,
pub mac: Vec<u8>,
pub signed_at: SqliteTimestamp,
pub created_at: SqliteTimestamp,
}

View File

@@ -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,
@@ -219,6 +232,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,

View File

@@ -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;
@@ -38,6 +41,10 @@ pub enum PolicyError {
#[error("No matching grant found")]
#[diagnostic(code(arbiter_server::evm::policy_error::no_matching_grant))]
NoMatchingGrant,
#[error("Integrity error: {0}")]
#[diagnostic(code(arbiter_server::evm::policy_error::integrity))]
Integrity(#[from] integrity::Error),
}
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
@@ -122,6 +129,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<KeyHolder>,
}
impl Engine {
@@ -130,7 +138,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)
@@ -138,6 +149,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
@@ -150,7 +169,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)
@@ -179,15 +200,19 @@ impl Engine {
}
impl Engine {
pub fn new(db: db::DatabasePool) -> Self {
Self { db }
pub fn new(db: db::DatabasePool, keyholder: ActorRef<KeyHolder>) -> Self {
Self { db, keyholder }
}
pub async fn create_grant<P: Policy>(
&self,
full_grant: FullGrant<P::Settings>,
) -> Result<i32, DatabaseError> {
) -> Result<i32, DatabaseError>
where
P::Settings: Clone,
{
let mut conn = self.db.get().await?;
let keyholder = self.keyholder.clone();
let id = conn
.transaction(|conn| {
@@ -224,7 +249,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?;
@@ -260,6 +298,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)
}

View File

@@ -157,7 +157,7 @@ pub struct SharedGrantSettings {
}
impl SharedGrantSettings {
fn try_from_model(model: EvmBasicGrant) -> QueryResult<Self> {
pub(crate) fn try_from_model(model: EvmBasicGrant) -> QueryResult<Self> {
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

View File

@@ -105,12 +105,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) })
}
}

View File

@@ -3,8 +3,7 @@ use arbiter_proto::proto::{
EvmError as ProtoEvmError, EvmGrantCreateRequest, EvmGrantCreateResponse,
EvmGrantDeleteRequest, EvmGrantDeleteResponse, EvmGrantList, EvmGrantListResponse,
EvmSignTransactionResponse, GrantEntry, WalletCreateResponse, WalletEntry, WalletList,
WalletListResponse,
evm_grant_create_response::Result as EvmGrantCreateResult,
WalletListResponse, evm_grant_create_response::Result as EvmGrantCreateResult,
evm_grant_delete_response::Result as EvmGrantDeleteResult,
evm_grant_list_response::Result as EvmGrantListResult,
evm_sign_transaction_response::Result as EvmSignTransactionResult,
@@ -27,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,
},
},
@@ -115,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()),
@@ -149,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())
@@ -165,8 +167,16 @@ async fn handle_grant_delete(
actor: &ActorRef<UserAgentSession>,
req: EvmGrantDeleteRequest,
) -> Result<Option<UserAgentResponsePayload>, Status> {
let result = match actor.ask(HandleGrantDelete { grant_id: req.grant_id }).await {
let result = match actor
.ask(HandleGrantDelete {
grant_id: req.grant_id,
})
.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())
@@ -202,18 +212,18 @@ async fn handle_sign_transaction(
signature.as_bytes().to_vec(),
)),
},
Err(kameo::error::SendError::HandlerError(
SessionSignTransactionError::Vet(vet_error),
)) => EvmSignTransactionResponse {
result: Some(vet_error.convert()),
},
Err(kameo::error::SendError::HandlerError(
SessionSignTransactionError::Internal,
)) => EvmSignTransactionResponse {
result: Some(EvmSignTransactionResult::Error(
ProtoEvmError::Internal.into(),
)),
},
Err(kameo::error::SendError::HandlerError(SessionSignTransactionError::Vet(vet_error))) => {
EvmSignTransactionResponse {
result: Some(vet_error.convert()),
}
}
Err(kameo::error::SendError::HandlerError(SessionSignTransactionError::Internal)) => {
EvmSignTransactionResponse {
result: Some(EvmSignTransactionResult::Error(
ProtoEvmError::Internal.into(),
)),
}
}
Err(err) => {
warn!(error = ?err, "Failed to sign EVM transaction");
EvmSignTransactionResponse {
@@ -224,7 +234,7 @@ async fn handle_sign_transaction(
}
};
Ok(Some(wrap_evm_response(EvmResponsePayload::SignTransaction(
response,
))))
Ok(Some(wrap_evm_response(
EvmResponsePayload::SignTransaction(response),
)))
}

View File

@@ -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<DateTime<Utc>>,
}
impl SignedEvmGrant {
pub fn from_active_grant(grant: &Grant<SpecificGrant>) -> Self {
Self {
basic_grant_id: grant.shared_grant_id,
shared: grant.shared.clone(),
specific: grant.settings.clone(),
revoked_at: None,
}
}
}
fn timestamp(value: DateTime<Utc>) -> 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<alloy::primitives::U256> {
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<Vec<u8>> =
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<IntegrityVolumeRateLimit> = 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<u8> {
self.basic_grant_id.to_be_bytes().to_vec()
}
fn payload_version(&self) -> i32 {
1
}
fn canonical_payload_bytes(&self) -> Vec<u8> {
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<Backend = Sqlite>,
basic_grant_id: i32,
) -> diesel::result::QueryResult<SignedEvmGrant> {
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<models::EvmTokenTransferGrant> =
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<models::EvmTokenTransferVolumeLimit> =
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::<diesel::result::QueryResult<Vec<_>>>()?;
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<models::EvmEtherTransferGrantTarget> =
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::<diesel::result::QueryResult<Vec<_>>>()?;
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,
})
}

View File

@@ -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<u8>;
fn payload_version(&self) -> i32;
fn canonical_payload_bytes(&self) -> Vec<u8>;
}
#[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<u8>, 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<u8> {
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<Backend = Sqlite>,
keyholder: &ActorRef<KeyHolder>,
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<Backend = Sqlite>,
keyholder: &ActorRef<KeyHolder>,
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<u8>,
}
impl IntegrityEntity for DummyEntity {
fn entity_kind(&self) -> &'static str {
"dummy_entity"
}
fn entity_id_bytes(&self) -> Vec<u8> {
self.id.to_be_bytes().to_vec()
}
fn payload_version(&self) -> i32 {
self.payload_version
}
fn canonical_payload_bytes(&self) -> Vec<u8> {
self.payload.clone()
}
}
async fn bootstrapped_keyholder(db: &db::DatabasePool) -> ActorRef<KeyHolder> {
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 { .. }));
}
}

View File

@@ -6,6 +6,7 @@ pub mod context;
pub mod db;
pub mod evm;
pub mod grpc;
pub mod integrity;
pub mod safe_cell;
pub mod utils;