feat(server): integrity envelope engine for EVM grants with HMAC verification
This commit is contained in:
50
protobufs/integrity.proto
Normal file
50
protobufs/integrity.proto
Normal 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
2
server/Cargo.lock
generated
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
))))
|
)))
|
||||||
}
|
}
|
||||||
|
|||||||
259
server/crates/arbiter-server/src/integrity/evm.rs
Normal file
259
server/crates/arbiter-server/src/integrity/evm.rs
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
307
server/crates/arbiter-server/src/integrity/mod.rs
Normal file
307
server/crates/arbiter-server/src/integrity/mod.rs
Normal 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 { .. }));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user