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", "ed25519-dalek",
"fatality", "fatality",
"futures", "futures",
"hmac",
"insta", "insta",
"k256", "k256",
"kameo", "kameo",
"memsafe", "memsafe",
"miette", "miette",
"pem", "pem",
"prost",
"prost-types", "prost-types",
"rand 0.10.0", "rand 0.10.0",
"rcgen", "rcgen",

View File

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

View File

@@ -11,7 +11,7 @@ tokio.workspace = true
futures.workspace = true futures.workspace = true
hex = "0.4.3" hex = "0.4.3"
tonic-prost = "0.14.5" tonic-prost = "0.14.5"
prost = "0.14.3" prost.workspace = true
kameo.workspace = true kameo.workspace = true
url = "2.5.8" url = "2.5.8"
miette.workspace = true miette.workspace = true

View File

@@ -13,6 +13,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
format!("{}/user_agent.proto", PROTOBUF_DIR), format!("{}/user_agent.proto", PROTOBUF_DIR),
format!("{}/client.proto", PROTOBUF_DIR), format!("{}/client.proto", PROTOBUF_DIR),
format!("{}/evm.proto", PROTOBUF_DIR), format!("{}/evm.proto", PROTOBUF_DIR),
format!("{}/integrity.proto", PROTOBUF_DIR),
], ],
&[PROTOBUF_DIR.to_string()], &[PROTOBUF_DIR.to_string()],
) )

View File

@@ -61,6 +61,10 @@ pub mod proto {
pub mod evm { pub mod evm {
tonic::include_proto!("arbiter.evm"); tonic::include_proto!("arbiter.evm");
} }
pub mod integrity {
tonic::include_proto!("arbiter.integrity");
}
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]

View File

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

View File

@@ -191,3 +191,19 @@ create table if not exists evm_ether_transfer_grant_target (
) STRICT; ) STRICT;
create unique index if not exists uniq_ether_transfer_target on evm_ether_transfer_grant_target (grant_id, address); 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 rand::{SeedableRng, rng, rngs::StdRng};
use crate::{ use crate::{
actors::keyholder::{CreateNew, Decrypt, KeyHolder}, actors::keyholder::{CreateNew, Decrypt, GetState, KeyHolder, KeyHolderState},
db::{ db::{
DatabaseError, DatabasePool, DatabaseError, DatabasePool,
models::{self, SqliteTimestamp}, models::{self, SqliteTimestamp},
@@ -20,6 +20,7 @@ use crate::{
ether_transfer::EtherTransfer, token_transfers::TokenTransfer, ether_transfer::EtherTransfer, token_transfers::TokenTransfer,
}, },
}, },
integrity,
safe_cell::{SafeCell, SafeCellHandle as _}, safe_cell::{SafeCell, SafeCellHandle as _},
}; };
@@ -65,6 +66,10 @@ pub enum Error {
#[error("Database error: {0}")] #[error("Database error: {0}")]
#[diagnostic(code(arbiter::evm::database))] #[diagnostic(code(arbiter::evm::database))]
Database(#[from] DatabaseError), Database(#[from] DatabaseError),
#[error("Vault is sealed")]
#[diagnostic(code(arbiter::evm::vault_sealed))]
VaultSealed,
} }
#[derive(Actor)] #[derive(Actor)]
@@ -80,7 +85,7 @@ impl EvmActor {
// is it safe to seed rng from system once? // is it safe to seed rng from system once?
// todo: audit // todo: audit
let rng = StdRng::from_rng(&mut rng()); let rng = StdRng::from_rng(&mut rng());
let engine = evm::Engine::new(db.clone()); let engine = evm::Engine::new(db.clone(), keyholder.clone());
Self { Self {
keyholder, keyholder,
db, db,
@@ -88,6 +93,20 @@ impl EvmActor {
engine, 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] #[messages]
@@ -141,7 +160,9 @@ impl EvmActor {
&mut self, &mut self,
basic: SharedGrantSettings, basic: SharedGrantSettings,
grant: SpecificGrant, grant: SpecificGrant,
) -> Result<i32, DatabaseError> { ) -> Result<i32, Error> {
self.ensure_unsealed().await?;
match grant { match grant {
SpecificGrant::EtherTransfer(settings) => { SpecificGrant::EtherTransfer(settings) => {
self.engine self.engine
@@ -150,6 +171,7 @@ impl EvmActor {
specific: settings, specific: settings,
}) })
.await .await
.map_err(Error::from)
} }
SpecificGrant::TokenTransfer(settings) => { SpecificGrant::TokenTransfer(settings) => {
self.engine self.engine
@@ -158,29 +180,43 @@ impl EvmActor {
specific: settings, specific: settings,
}) })
.await .await
.map_err(Error::from)
} }
} }
} }
#[message] #[message]
pub async fn useragent_delete_grant(&mut self, grant_id: i32) -> Result<(), Error> { 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 mut conn = self.db.get().await.map_err(DatabaseError::from)?;
diesel::update(schema::evm_basic_grant::table) let keyholder = self.keyholder.clone();
.filter(schema::evm_basic_grant::id.eq(grant_id))
.set(schema::evm_basic_grant::revoked_at.eq(SqliteTimestamp::now())) diesel_async::AsyncConnection::transaction(&mut conn, |conn| {
.execute(&mut conn) Box::pin(async move {
.await diesel::update(schema::evm_basic_grant::table)
.map_err(DatabaseError::from)?; .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(()) Ok(())
} }
#[message] #[message]
pub async fn useragent_list_grants(&mut self) -> Result<Vec<Grant<SpecificGrant>>, Error> { pub async fn useragent_list_grants(&mut self) -> Result<Vec<Grant<SpecificGrant>>, Error> {
Ok(self Ok(self.engine.list_all_grants().await?)
.engine
.list_all_grants()
.await
.map_err(DatabaseError::from)?)
} }
#[message] #[message]

View File

@@ -4,7 +4,9 @@ use diesel::{
dsl::{insert_into, update}, dsl::{insert_into, update},
}; };
use diesel_async::{AsyncConnection, RunQueryDsl}; use diesel_async::{AsyncConnection, RunQueryDsl};
use hmac::{Hmac, Mac as _};
use kameo::{Actor, Reply, messages}; use kameo::{Actor, Reply, messages};
use sha2::Sha256;
use strum::{EnumDiscriminants, IntoDiscriminant}; use strum::{EnumDiscriminants, IntoDiscriminant};
use tracing::{error, info}; use tracing::{error, info};
@@ -19,6 +21,10 @@ use crate::{
}; };
use encryption::v1::{self, KeyCell, Nonce}; use encryption::v1::{self, KeyCell, Nonce};
type HmacSha256 = Hmac<Sha256>;
const INTEGRITY_SUBKEY_TAG: &[u8] = b"arbiter/db-integrity-key/v1";
pub mod encryption; pub mod encryption;
#[derive(Default, EnumDiscriminants)] #[derive(Default, EnumDiscriminants)]
@@ -138,6 +144,19 @@ impl KeyHolder {
Ok(nonce) 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] #[message]
pub async fn bootstrap(&mut self, seal_key_raw: SafeCell<Vec<u8>>) -> Result<(), Error> { pub async fn bootstrap(&mut self, seal_key_raw: SafeCell<Vec<u8>>) -> Result<(), Error> {
if !matches!(self.state, State::Unbootstrapped) { if !matches!(self.state, State::Unbootstrapped) {
@@ -328,6 +347,59 @@ impl KeyHolder {
self.state.discriminant() 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] #[message]
pub fn seal(&mut self) -> Result<(), Error> { pub fn seal(&mut self) -> Result<(), Error> {
let State::Unsealed { let State::Unsealed {

View File

@@ -120,6 +120,15 @@ pub enum SignTransactionError {
Internal, Internal,
} }
#[derive(Debug, Error)]
pub enum GrantMutationError {
#[error("Vault is sealed")]
VaultSealed,
#[error("Internal grant mutation error")]
Internal,
}
#[messages] #[messages]
impl UserAgentSession { impl UserAgentSession {
#[message] #[message]
@@ -331,7 +340,7 @@ impl UserAgentSession {
&mut self, &mut self,
basic: crate::evm::policies::SharedGrantSettings, basic: crate::evm::policies::SharedGrantSettings,
grant: crate::evm::policies::SpecificGrant, grant: crate::evm::policies::SpecificGrant,
) -> Result<i32, Error> { ) -> Result<i32, GrantMutationError> {
match self match self
.props .props
.actors .actors
@@ -340,15 +349,21 @@ impl UserAgentSession {
.await .await
{ {
Ok(grant_id) => Ok(grant_id), Ok(grant_id) => Ok(grant_id),
Err(SendError::HandlerError(crate::actors::evm::Error::VaultSealed)) => {
Err(GrantMutationError::VaultSealed)
}
Err(err) => { Err(err) => {
error!(?err, "EVM grant create failed"); error!(?err, "EVM grant create failed");
Err(Error::internal("Failed to create EVM grant")) Err(GrantMutationError::Internal)
} }
} }
} }
#[message] #[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 match self
.props .props
.actors .actors
@@ -357,9 +372,12 @@ impl UserAgentSession {
.await .await
{ {
Ok(()) => Ok(()), Ok(()) => Ok(()),
Err(SendError::HandlerError(crate::actors::evm::Error::VaultSealed)) => {
Err(GrantMutationError::VaultSealed)
}
Err(err) => { Err(err) => {
error!(?err, "EVM grant delete failed"); 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, 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_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, 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 chrono::{DateTime, Utc};
use diesel::{prelude::*, sqlite::Sqlite}; use diesel::{prelude::*, sqlite::Sqlite};
@@ -376,3 +376,22 @@ pub struct EvmTokenTransferLog {
pub value: Vec<u8>, pub value: Vec<u8>,
pub created_at: SqliteTimestamp, 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! { diesel::table! {
program_client (id) { program_client (id) {
id -> Integer, id -> Integer,
@@ -219,6 +232,7 @@ diesel::allow_tables_to_appear_in_same_query!(
evm_transaction_log, evm_transaction_log,
evm_wallet, evm_wallet,
evm_wallet_access, evm_wallet_access,
integrity_envelope,
program_client, program_client,
root_key_history, root_key_history,
tls_history, tls_history,

View File

@@ -8,8 +8,10 @@ use alloy::{
use chrono::Utc; use chrono::Utc;
use diesel::{ExpressionMethods as _, QueryDsl as _, QueryResult, insert_into, sqlite::Sqlite}; use diesel::{ExpressionMethods as _, QueryDsl as _, QueryResult, insert_into, sqlite::Sqlite};
use diesel_async::{AsyncConnection, RunQueryDsl}; use diesel_async::{AsyncConnection, RunQueryDsl};
use kameo::actor::ActorRef;
use crate::{ use crate::{
actors::keyholder::KeyHolder,
db::{ db::{
self, DatabaseError, self, DatabaseError,
models::{ models::{
@@ -22,6 +24,7 @@ use crate::{
SpecificGrant, SpecificMeaning, ether_transfer::EtherTransfer, SpecificGrant, SpecificMeaning, ether_transfer::EtherTransfer,
token_transfers::TokenTransfer, token_transfers::TokenTransfer,
}, },
integrity,
}; };
pub mod policies; pub mod policies;
@@ -38,6 +41,10 @@ pub enum PolicyError {
#[error("No matching grant found")] #[error("No matching grant found")]
#[diagnostic(code(arbiter_server::evm::policy_error::no_matching_grant))] #[diagnostic(code(arbiter_server::evm::policy_error::no_matching_grant))]
NoMatchingGrant, NoMatchingGrant,
#[error("Integrity error: {0}")]
#[diagnostic(code(arbiter_server::evm::policy_error::integrity))]
Integrity(#[from] integrity::Error),
} }
#[derive(Debug, thiserror::Error, miette::Diagnostic)] #[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 // Supporting only EIP-1559 transactions for now, but we can easily extend this to support legacy transactions if needed
pub struct Engine { pub struct Engine {
db: db::DatabasePool, db: db::DatabasePool,
keyholder: ActorRef<KeyHolder>,
} }
impl Engine { impl Engine {
@@ -130,7 +138,10 @@ impl Engine {
context: EvalContext, context: EvalContext,
meaning: &P::Meaning, meaning: &P::Meaning,
run_kind: RunKind, run_kind: RunKind,
) -> Result<(), PolicyError> { ) -> Result<(), PolicyError>
where
P::Settings: Clone,
{
let mut conn = self.db.get().await.map_err(DatabaseError::from)?; let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
let grant = P::try_find_grant(&context, &mut conn) let grant = P::try_find_grant(&context, &mut conn)
@@ -138,6 +149,14 @@ impl Engine {
.map_err(DatabaseError::from)? .map_err(DatabaseError::from)?
.ok_or(PolicyError::NoMatchingGrant)?; .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 = let mut violations =
check_shared_constraints(&context, &grant.shared, grant.shared_grant_id, &mut conn) check_shared_constraints(&context, &grant.shared, grant.shared_grant_id, &mut conn)
.await .await
@@ -150,7 +169,9 @@ impl Engine {
if !violations.is_empty() { if !violations.is_empty() {
return Err(PolicyError::Violations(violations)); return Err(PolicyError::Violations(violations));
} else if run_kind == RunKind::Execution { }
if run_kind == RunKind::Execution {
conn.transaction(|conn| { conn.transaction(|conn| {
Box::pin(async move { Box::pin(async move {
let log_id: i32 = insert_into(evm_transaction_log::table) let log_id: i32 = insert_into(evm_transaction_log::table)
@@ -179,15 +200,19 @@ impl Engine {
} }
impl Engine { impl Engine {
pub fn new(db: db::DatabasePool) -> Self { pub fn new(db: db::DatabasePool, keyholder: ActorRef<KeyHolder>) -> Self {
Self { db } Self { db, keyholder }
} }
pub async fn create_grant<P: Policy>( pub async fn create_grant<P: Policy>(
&self, &self,
full_grant: FullGrant<P::Settings>, full_grant: FullGrant<P::Settings>,
) -> Result<i32, DatabaseError> { ) -> Result<i32, DatabaseError>
where
P::Settings: Clone,
{
let mut conn = self.db.get().await?; let mut conn = self.db.get().await?;
let keyholder = self.keyholder.clone();
let id = conn let id = conn
.transaction(|conn| { .transaction(|conn| {
@@ -224,7 +249,20 @@ impl Engine {
.get_result(conn) .get_result(conn)
.await?; .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?; .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) Ok(grants)
} }

View File

@@ -157,7 +157,7 @@ pub struct SharedGrantSettings {
} }
impl SharedGrantSettings { impl SharedGrantSettings {
fn try_from_model(model: EvmBasicGrant) -> QueryResult<Self> { pub(crate) fn try_from_model(model: EvmBasicGrant) -> QueryResult<Self> {
Ok(Self { Ok(Self {
wallet_access_id: model.wallet_access_id, 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 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(), violations: violations.into_iter().map(Convert::convert).collect(),
}) })
} }
PolicyError::Database(_) => { PolicyError::Database(_)| PolicyError::Integrity(_) => {
return EvmSignTransactionResult::Error(ProtoEvmError::Internal.into()); 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, EvmError as ProtoEvmError, EvmGrantCreateRequest, EvmGrantCreateResponse,
EvmGrantDeleteRequest, EvmGrantDeleteResponse, EvmGrantList, EvmGrantListResponse, EvmGrantDeleteRequest, EvmGrantDeleteResponse, EvmGrantList, EvmGrantListResponse,
EvmSignTransactionResponse, GrantEntry, WalletCreateResponse, WalletEntry, WalletList, EvmSignTransactionResponse, GrantEntry, WalletCreateResponse, WalletEntry, WalletList,
WalletListResponse, WalletListResponse, evm_grant_create_response::Result as EvmGrantCreateResult,
evm_grant_create_response::Result as EvmGrantCreateResult,
evm_grant_delete_response::Result as EvmGrantDeleteResult, evm_grant_delete_response::Result as EvmGrantDeleteResult,
evm_grant_list_response::Result as EvmGrantListResult, evm_grant_list_response::Result as EvmGrantListResult,
evm_sign_transaction_response::Result as EvmSignTransactionResult, evm_sign_transaction_response::Result as EvmSignTransactionResult,
@@ -27,8 +26,8 @@ use crate::{
actors::user_agent::{ actors::user_agent::{
UserAgentSession, UserAgentSession,
session::connection::{ session::connection::{
HandleEvmWalletCreate, HandleEvmWalletList, HandleGrantCreate, HandleGrantDelete, GrantMutationError, HandleEvmWalletCreate, HandleEvmWalletList, HandleGrantCreate,
HandleGrantList, HandleSignTransaction, HandleGrantDelete, HandleGrantList, HandleSignTransaction,
SignTransactionError as SessionSignTransactionError, SignTransactionError as SessionSignTransactionError,
}, },
}, },
@@ -115,7 +114,7 @@ async fn handle_grant_list(
grants: grants grants: grants
.into_iter() .into_iter()
.map(|grant| GrantEntry { .map(|grant| GrantEntry {
id: grant.id, id: grant.shared_grant_id,
wallet_access_id: grant.shared.wallet_access_id, wallet_access_id: grant.shared.wallet_access_id,
shared: Some(grant.shared.convert()), shared: Some(grant.shared.convert()),
specific: Some(grant.settings.convert()), specific: Some(grant.settings.convert()),
@@ -149,6 +148,9 @@ async fn handle_grant_create(
let result = match actor.ask(HandleGrantCreate { basic, grant }).await { let result = match actor.ask(HandleGrantCreate { basic, grant }).await {
Ok(grant_id) => EvmGrantCreateResult::GrantId(grant_id), Ok(grant_id) => EvmGrantCreateResult::GrantId(grant_id),
Err(kameo::error::SendError::HandlerError(GrantMutationError::VaultSealed)) => {
EvmGrantCreateResult::Error(ProtoEvmError::VaultSealed.into())
}
Err(err) => { Err(err) => {
warn!(error = ?err, "Failed to create EVM grant"); warn!(error = ?err, "Failed to create EVM grant");
EvmGrantCreateResult::Error(ProtoEvmError::Internal.into()) EvmGrantCreateResult::Error(ProtoEvmError::Internal.into())
@@ -165,8 +167,16 @@ async fn handle_grant_delete(
actor: &ActorRef<UserAgentSession>, actor: &ActorRef<UserAgentSession>,
req: EvmGrantDeleteRequest, req: EvmGrantDeleteRequest,
) -> Result<Option<UserAgentResponsePayload>, Status> { ) -> 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(()), Ok(()) => EvmGrantDeleteResult::Ok(()),
Err(kameo::error::SendError::HandlerError(GrantMutationError::VaultSealed)) => {
EvmGrantDeleteResult::Error(ProtoEvmError::VaultSealed.into())
}
Err(err) => { Err(err) => {
warn!(error = ?err, "Failed to delete EVM grant"); warn!(error = ?err, "Failed to delete EVM grant");
EvmGrantDeleteResult::Error(ProtoEvmError::Internal.into()) EvmGrantDeleteResult::Error(ProtoEvmError::Internal.into())
@@ -202,18 +212,18 @@ async fn handle_sign_transaction(
signature.as_bytes().to_vec(), signature.as_bytes().to_vec(),
)), )),
}, },
Err(kameo::error::SendError::HandlerError( Err(kameo::error::SendError::HandlerError(SessionSignTransactionError::Vet(vet_error))) => {
SessionSignTransactionError::Vet(vet_error), EvmSignTransactionResponse {
)) => EvmSignTransactionResponse { result: Some(vet_error.convert()),
result: Some(vet_error.convert()), }
}, }
Err(kameo::error::SendError::HandlerError( Err(kameo::error::SendError::HandlerError(SessionSignTransactionError::Internal)) => {
SessionSignTransactionError::Internal, EvmSignTransactionResponse {
)) => EvmSignTransactionResponse { result: Some(EvmSignTransactionResult::Error(
result: Some(EvmSignTransactionResult::Error( ProtoEvmError::Internal.into(),
ProtoEvmError::Internal.into(), )),
)), }
}, }
Err(err) => { Err(err) => {
warn!(error = ?err, "Failed to sign EVM transaction"); warn!(error = ?err, "Failed to sign EVM transaction");
EvmSignTransactionResponse { EvmSignTransactionResponse {
@@ -224,7 +234,7 @@ async fn handle_sign_transaction(
} }
}; };
Ok(Some(wrap_evm_response(EvmResponsePayload::SignTransaction( Ok(Some(wrap_evm_response(
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 db;
pub mod evm; pub mod evm;
pub mod grpc; pub mod grpc;
pub mod integrity;
pub mod safe_cell; pub mod safe_cell;
pub mod utils; pub mod utils;