1 Commits

Author SHA1 Message Date
CleverWild
3aae3e1d83 feat(server): implement useragent_delete_grant hard delete cleanup
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
2026-04-05 17:52:44 +02:00
22 changed files with 435 additions and 382 deletions

View File

@@ -48,10 +48,6 @@ backend = "cargo:cargo-features-manager"
version = "1.46.3" version = "1.46.3"
backend = "cargo:cargo-insta" backend = "cargo:cargo-insta"
[[tools."cargo:cargo-mutants"]]
version = "27.0.0"
backend = "cargo:cargo-mutants"
[[tools."cargo:cargo-nextest"]] [[tools."cargo:cargo-nextest"]]
version = "0.9.126" version = "0.9.126"
backend = "cargo:cargo-nextest" backend = "cargo:cargo-nextest"
@@ -115,37 +111,30 @@ backend = "core:python"
[tools.python."platforms.linux-arm64"] [tools.python."platforms.linux-arm64"]
checksum = "sha256:53700338695e402a1a1fe22be4a41fbdacc70e22bb308a48eca8ed67cb7992be" checksum = "sha256:53700338695e402a1a1fe22be4a41fbdacc70e22bb308a48eca8ed67cb7992be"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz" url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz"
provenance = "github-attestations"
[tools.python."platforms.linux-arm64-musl"] [tools.python."platforms.linux-arm64-musl"]
checksum = "sha256:53700338695e402a1a1fe22be4a41fbdacc70e22bb308a48eca8ed67cb7992be" checksum = "sha256:53700338695e402a1a1fe22be4a41fbdacc70e22bb308a48eca8ed67cb7992be"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz" url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz"
provenance = "github-attestations"
[tools.python."platforms.linux-x64"] [tools.python."platforms.linux-x64"]
checksum = "sha256:d7a9f970914bb4c88756fe3bdcc186d4feb90e9500e54f1db47dae4dc9687e39" checksum = "sha256:d7a9f970914bb4c88756fe3bdcc186d4feb90e9500e54f1db47dae4dc9687e39"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz" url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz"
provenance = "github-attestations"
[tools.python."platforms.linux-x64-musl"] [tools.python."platforms.linux-x64-musl"]
checksum = "sha256:d7a9f970914bb4c88756fe3bdcc186d4feb90e9500e54f1db47dae4dc9687e39" checksum = "sha256:d7a9f970914bb4c88756fe3bdcc186d4feb90e9500e54f1db47dae4dc9687e39"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz" url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz"
provenance = "github-attestations"
[tools.python."platforms.macos-arm64"] [tools.python."platforms.macos-arm64"]
checksum = "sha256:c43aecde4a663aebff99b9b83da0efec506479f1c3f98331442f33d2c43501f9" checksum = "sha256:c43aecde4a663aebff99b9b83da0efec506479f1c3f98331442f33d2c43501f9"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-aarch64-apple-darwin-install_only_stripped.tar.gz" url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-aarch64-apple-darwin-install_only_stripped.tar.gz"
provenance = "github-attestations"
[tools.python."platforms.macos-x64"] [tools.python."platforms.macos-x64"]
checksum = "sha256:9ab41dbc2f100a2a45d1833b9c11165f51051c558b5213eda9a9731d5948a0c0" checksum = "sha256:9ab41dbc2f100a2a45d1833b9c11165f51051c558b5213eda9a9731d5948a0c0"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-x86_64-apple-darwin-install_only_stripped.tar.gz" url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-x86_64-apple-darwin-install_only_stripped.tar.gz"
provenance = "github-attestations"
[tools.python."platforms.windows-x64"] [tools.python."platforms.windows-x64"]
checksum = "sha256:bbe19034b35b0267176a7442575ae7dc6343480fd4d35598cb7700173d431e09" checksum = "sha256:bbe19034b35b0267176a7442575ae7dc6343480fd4d35598cb7700173d431e09"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-x86_64-pc-windows-msvc-install_only_stripped.tar.gz" url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-x86_64-pc-windows-msvc-install_only_stripped.tar.gz"
provenance = "github-attestations"
[[tools.rust]] [[tools.rust]]
version = "1.93.0" version = "1.93.0"

View File

@@ -12,7 +12,6 @@ protoc = "29.6"
python = "3.14.3" python = "3.14.3"
ast-grep = "0.42.0" ast-grep = "0.42.0"
"cargo:cargo-edit" = "0.13.9" "cargo:cargo-edit" = "0.13.9"
"cargo:cargo-mutants" = "27.0.0"
[tasks.codegen] [tasks.codegen]
sources = ['protobufs/*.proto', 'protobufs/**/*.proto'] sources = ['protobufs/*.proto', 'protobufs/**/*.proto']

View File

@@ -36,10 +36,6 @@ message GasLimitExceededViolation {
} }
message EvalViolation { message EvalViolation {
message ChainIdMismatch {
uint64 expected = 1;
uint64 actual = 2;
}
oneof kind { oneof kind {
bytes invalid_target = 1; // 20-byte Ethereum address bytes invalid_target = 1; // 20-byte Ethereum address
GasLimitExceededViolation gas_limit_exceeded = 2; GasLimitExceededViolation gas_limit_exceeded = 2;
@@ -47,8 +43,6 @@ message EvalViolation {
google.protobuf.Empty volumetric_limit_exceeded = 4; google.protobuf.Empty volumetric_limit_exceeded = 4;
google.protobuf.Empty invalid_time = 5; google.protobuf.Empty invalid_time = 5;
google.protobuf.Empty invalid_transaction_type = 6; google.protobuf.Empty invalid_transaction_type = 6;
ChainIdMismatch chain_id_mismatch = 7;
} }
} }

View File

@@ -1 +0,0 @@
test_tool = "nextest"

2
server/.gitignore vendored
View File

@@ -1,2 +0,0 @@
mutants.out/
mutants.out.old/

8
server/Cargo.lock generated
View File

@@ -743,7 +743,6 @@ dependencies = [
"k256", "k256",
"kameo", "kameo",
"memsafe", "memsafe",
"mutants",
"pem", "pem",
"postcard", "postcard",
"prost", "prost",
@@ -752,7 +751,6 @@ dependencies = [
"rcgen", "rcgen",
"restructed", "restructed",
"rsa", "rsa",
"rstest",
"rustls", "rustls",
"secrecy", "secrecy",
"serde", "serde",
@@ -3238,12 +3236,6 @@ version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084"
[[package]]
name = "mutants"
version = "0.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "add0ac067452ff1aca8c5002111bd6b1c895baee6e45fcbc44e0193aea17be56"
[[package]] [[package]]
name = "nom" name = "nom"
version = "7.1.3" version = "7.1.3"

View File

@@ -44,4 +44,3 @@ sha2 = "0.10"
spki = "0.7" spki = "0.7"
prost = "0.14.3" prost = "0.14.3"
miette = { version = "7.6.0", features = ["fancy", "serde"] } miette = { version = "7.6.0", features = ["fancy", "serde"] }
mutants = "0.0.4"

View File

@@ -61,9 +61,7 @@ anyhow = "1.0.102"
postcard = { version = "1.1.3", features = ["use-std"] } postcard = { version = "1.1.3", features = ["use-std"] }
serde = { version = "1.0.228", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }
serde_with = "3.18.0" serde_with = "3.18.0"
mutants.workspace = true
[dev-dependencies] [dev-dependencies]
insta = "1.46.3" insta = "1.46.3"
rstest.workspace = true
test-log = { version = "0.2", default-features = false, features = ["trace"] } test-log = { version = "0.2", default-features = false, features = ["trace"] }

View File

@@ -1,17 +1,18 @@
use alloy::{consensus::TxEip1559, primitives::Address, signers::Signature}; use alloy::{consensus::TxEip1559, primitives::Address, signers::Signature};
use diesel::{ use diesel::{
ExpressionMethods, OptionalExtension as _, QueryDsl, SelectableHelper as _, dsl::insert_into, BoolExpressionMethods as _, ExpressionMethods, OptionalExtension as _, QueryDsl,
SelectableHelper as _, dsl::insert_into,
}; };
use diesel_async::RunQueryDsl; use diesel_async::{AsyncConnection as _, RunQueryDsl};
use kameo::{Actor, actor::ActorRef, messages}; 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, GetState, KeyHolder, KeyHolderState}, actors::keyholder::{CreateNew, Decrypt, KeyHolder},
crypto::integrity, crypto::integrity,
db::{ db::{
DatabaseError, DatabasePool, DatabaseError, DatabasePool,
models::{self, SqliteTimestamp}, models::{self},
schema, schema,
}, },
evm::{ evm::{
@@ -159,27 +160,114 @@ impl EvmActor {
#[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> {
// let mut conn = self.db.get().await.map_err(DatabaseError::from)?; let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
// let keyholder = self.keyholder.clone();
// diesel_async::AsyncConnection::transaction(&mut conn, |conn| { // We intentionally perform a hard delete here to avoid leaving revoked grants and their
// Box::pin(async move { // related rows as long-lived DB garbage. We also don't rely on SQLite FK cascades because
// diesel::update(schema::evm_basic_grant::table) // they can be disabled per-connection.
// .filter(schema::evm_basic_grant::id.eq(grant_id)) conn.transaction(|conn| {
// .set(schema::evm_basic_grant::revoked_at.eq(SqliteTimestamp::now())) Box::pin(async move {
// .execute(conn) // First, resolve policy-specific rows by basic grant id.
// .await?; let token_grant_id: Option<i32> = schema::evm_token_transfer_grant::table
.select(schema::evm_token_transfer_grant::id)
.filter(schema::evm_token_transfer_grant::basic_grant_id.eq(grant_id))
.first::<i32>(conn)
.await
.optional()?;
// let signed = integrity::evm::load_signed_grant_by_basic_id(conn, grant_id).await?; let ether_grant: Option<(i32, i32)> = schema::evm_ether_transfer_grant::table
.select((
schema::evm_ether_transfer_grant::id,
schema::evm_ether_transfer_grant::limit_id,
))
.filter(schema::evm_ether_transfer_grant::basic_grant_id.eq(grant_id))
.first::<(i32, i32)>(conn)
.await
.optional()?;
// diesel::result::QueryResult::Ok(()) // Token-transfer: logs must be deleted before transaction logs (FK restrict).
// }) if let Some(token_grant_id) = token_grant_id {
// }) diesel::delete(
// .await schema::evm_token_transfer_log::table
// .map_err(DatabaseError::from)?; .filter(schema::evm_token_transfer_log::grant_id.eq(token_grant_id)),
)
.execute(conn)
.await?;
// Ok(()) diesel::delete(schema::evm_token_transfer_volume_limit::table.filter(
todo!() schema::evm_token_transfer_volume_limit::grant_id.eq(token_grant_id),
))
.execute(conn)
.await?;
diesel::delete(
schema::evm_token_transfer_grant::table
.filter(schema::evm_token_transfer_grant::id.eq(token_grant_id)),
)
.execute(conn)
.await?;
}
// Shared transaction logs for any grant kind.
diesel::delete(
schema::evm_transaction_log::table
.filter(schema::evm_transaction_log::grant_id.eq(grant_id)),
)
.execute(conn)
.await?;
// Ether-transfer: delete targets, grant row, then its limit row.
if let Some((ether_grant_id, limit_id)) = ether_grant {
diesel::delete(schema::evm_ether_transfer_grant_target::table.filter(
schema::evm_ether_transfer_grant_target::grant_id.eq(ether_grant_id),
))
.execute(conn)
.await?;
diesel::delete(
schema::evm_ether_transfer_grant::table
.filter(schema::evm_ether_transfer_grant::id.eq(ether_grant_id)),
)
.execute(conn)
.await?;
diesel::delete(
schema::evm_ether_transfer_limit::table
.filter(schema::evm_ether_transfer_limit::id.eq(limit_id)),
)
.execute(conn)
.await?;
}
// Integrity envelopes are not FK-constrained; delete only grant-related kinds to
// avoid accidentally deleting other entities that share the same integer ID.
let entity_id = grant_id.to_be_bytes().to_vec();
diesel::delete(
schema::integrity_envelope::table
.filter(schema::integrity_envelope::entity_id.eq(entity_id))
.filter(
schema::integrity_envelope::entity_kind
.eq("EtherTransfer")
.or(schema::integrity_envelope::entity_kind.eq("TokenTransfer")),
),
)
.execute(conn)
.await?;
// Finally remove the basic grant row itself (idempotent if it doesn't exist).
diesel::delete(
schema::evm_basic_grant::table.filter(schema::evm_basic_grant::id.eq(grant_id)),
)
.execute(conn)
.await?;
diesel::result::QueryResult::Ok(())
})
})
.await
.map_err(DatabaseError::from)?;
Ok(())
} }
#[message] #[message]
@@ -271,3 +359,6 @@ impl EvmActor {
Ok(signer.sign_transaction_sync(&mut transaction)?) Ok(signer.sign_transaction_sync(&mut transaction)?)
} }
} }
#[cfg(test)]
mod tests;

View File

@@ -0,0 +1,283 @@
use diesel::{ExpressionMethods as _, QueryDsl as _, dsl::insert_into};
use diesel_async::RunQueryDsl;
use kameo::actor::Spawn as _;
use crate::{
actors::{evm::EvmActor, keyholder::KeyHolder},
db::{self, models, schema},
};
#[tokio::test]
async fn delete_ether_grant_cleans_related_tables() {
let db = db::create_test_pool().await;
let keyholder = KeyHolder::spawn(KeyHolder::new(db.clone()).await.unwrap());
let mut actor = EvmActor::new(keyholder, db.clone());
let mut conn = db.get().await.unwrap();
let basic_id: i32 = insert_into(schema::evm_basic_grant::table)
.values(&models::NewEvmBasicGrant {
wallet_access_id: 1,
chain_id: 1,
valid_from: None,
valid_until: None,
max_gas_fee_per_gas: None,
max_priority_fee_per_gas: None,
rate_limit_count: None,
rate_limit_window_secs: None,
revoked_at: None,
})
.returning(schema::evm_basic_grant::id)
.get_result(&mut conn)
.await
.unwrap();
let limit_id: i32 = insert_into(schema::evm_ether_transfer_limit::table)
.values(&models::NewEvmEtherTransferLimit {
window_secs: 60,
max_volume: vec![1],
})
.returning(schema::evm_ether_transfer_limit::id)
.get_result(&mut conn)
.await
.unwrap();
let ether_grant_id: i32 = insert_into(schema::evm_ether_transfer_grant::table)
.values(&models::NewEvmEtherTransferGrant {
basic_grant_id: basic_id,
limit_id,
})
.returning(schema::evm_ether_transfer_grant::id)
.get_result(&mut conn)
.await
.unwrap();
insert_into(schema::evm_ether_transfer_grant_target::table)
.values(&models::NewEvmEtherTransferGrantTarget {
grant_id: ether_grant_id,
address: vec![0u8; 20],
})
.execute(&mut conn)
.await
.unwrap();
insert_into(schema::evm_transaction_log::table)
.values(&models::NewEvmTransactionLog {
grant_id: basic_id,
wallet_access_id: 1,
chain_id: 1,
eth_value: vec![0],
signed_at: models::SqliteTimestamp::now(),
})
.execute(&mut conn)
.await
.unwrap();
insert_into(schema::integrity_envelope::table)
.values(&models::NewIntegrityEnvelope {
entity_kind: "EtherTransfer".to_owned(),
entity_id: basic_id.to_be_bytes().to_vec(),
payload_version: 1,
key_version: 1,
mac: vec![0u8; 32],
})
.execute(&mut conn)
.await
.unwrap();
drop(conn);
actor.useragent_delete_grant(basic_id).await.unwrap();
// Idempotency: second delete should be a no-op.
actor.useragent_delete_grant(basic_id).await.unwrap();
let mut conn = db.get().await.unwrap();
let basic_count: i64 = schema::evm_basic_grant::table
.count()
.get_result(&mut conn)
.await
.unwrap();
assert_eq!(basic_count, 0);
let ether_grant_count: i64 = schema::evm_ether_transfer_grant::table
.count()
.get_result(&mut conn)
.await
.unwrap();
assert_eq!(ether_grant_count, 0);
let target_count: i64 = schema::evm_ether_transfer_grant_target::table
.count()
.get_result(&mut conn)
.await
.unwrap();
assert_eq!(target_count, 0);
let limit_count: i64 = schema::evm_ether_transfer_limit::table
.count()
.get_result(&mut conn)
.await
.unwrap();
assert_eq!(limit_count, 0);
let log_count: i64 = schema::evm_transaction_log::table
.count()
.get_result(&mut conn)
.await
.unwrap();
assert_eq!(log_count, 0);
let envelope_count: i64 = schema::integrity_envelope::table
.filter(schema::integrity_envelope::entity_kind.eq("EtherTransfer"))
.count()
.get_result(&mut conn)
.await
.unwrap();
assert_eq!(envelope_count, 0);
}
#[tokio::test]
async fn delete_token_grant_cleans_related_tables() {
let db = db::create_test_pool().await;
let keyholder = KeyHolder::spawn(KeyHolder::new(db.clone()).await.unwrap());
let mut actor = EvmActor::new(keyholder, db.clone());
let mut conn = db.get().await.unwrap();
let basic_id: i32 = insert_into(schema::evm_basic_grant::table)
.values(&models::NewEvmBasicGrant {
wallet_access_id: 1,
chain_id: 1,
valid_from: None,
valid_until: None,
max_gas_fee_per_gas: None,
max_priority_fee_per_gas: None,
rate_limit_count: None,
rate_limit_window_secs: None,
revoked_at: None,
})
.returning(schema::evm_basic_grant::id)
.get_result(&mut conn)
.await
.unwrap();
let token_grant_id: i32 = insert_into(schema::evm_token_transfer_grant::table)
.values(&models::NewEvmTokenTransferGrant {
basic_grant_id: basic_id,
token_contract: vec![1u8; 20],
receiver: None,
})
.returning(schema::evm_token_transfer_grant::id)
.get_result(&mut conn)
.await
.unwrap();
insert_into(schema::evm_token_transfer_volume_limit::table)
.values(&models::NewEvmTokenTransferVolumeLimit {
grant_id: token_grant_id,
window_secs: 60,
max_volume: vec![1],
})
.execute(&mut conn)
.await
.unwrap();
insert_into(schema::evm_token_transfer_volume_limit::table)
.values(&models::NewEvmTokenTransferVolumeLimit {
grant_id: token_grant_id,
window_secs: 3600,
max_volume: vec![2],
})
.execute(&mut conn)
.await
.unwrap();
let tx_log_id: i32 = insert_into(schema::evm_transaction_log::table)
.values(&models::NewEvmTransactionLog {
grant_id: basic_id,
wallet_access_id: 1,
chain_id: 1,
eth_value: vec![0],
signed_at: models::SqliteTimestamp::now(),
})
.returning(schema::evm_transaction_log::id)
.get_result(&mut conn)
.await
.unwrap();
insert_into(schema::evm_token_transfer_log::table)
.values(&models::NewEvmTokenTransferLog {
grant_id: token_grant_id,
log_id: tx_log_id,
chain_id: 1,
token_contract: vec![1u8; 20],
recipient_address: vec![2u8; 20],
value: vec![3],
})
.execute(&mut conn)
.await
.unwrap();
insert_into(schema::integrity_envelope::table)
.values(&models::NewIntegrityEnvelope {
entity_kind: "TokenTransfer".to_owned(),
entity_id: basic_id.to_be_bytes().to_vec(),
payload_version: 1,
key_version: 1,
mac: vec![0u8; 32],
})
.execute(&mut conn)
.await
.unwrap();
drop(conn);
actor.useragent_delete_grant(basic_id).await.unwrap();
let mut conn = db.get().await.unwrap();
let basic_count: i64 = schema::evm_basic_grant::table
.count()
.get_result(&mut conn)
.await
.unwrap();
assert_eq!(basic_count, 0);
let token_grant_count: i64 = schema::evm_token_transfer_grant::table
.count()
.get_result(&mut conn)
.await
.unwrap();
assert_eq!(token_grant_count, 0);
let token_limits_count: i64 = schema::evm_token_transfer_volume_limit::table
.count()
.get_result(&mut conn)
.await
.unwrap();
assert_eq!(token_limits_count, 0);
let token_logs_count: i64 = schema::evm_token_transfer_log::table
.count()
.get_result(&mut conn)
.await
.unwrap();
assert_eq!(token_logs_count, 0);
let tx_logs_count: i64 = schema::evm_transaction_log::table
.count()
.get_result(&mut conn)
.await
.unwrap();
assert_eq!(tx_logs_count, 0);
let envelope_count: i64 = schema::integrity_envelope::table
.filter(schema::integrity_envelope::entity_kind.eq("TokenTransfer"))
.count()
.get_result(&mut conn)
.await
.unwrap();
assert_eq!(envelope_count, 0);
}

View File

@@ -103,6 +103,7 @@ async fn verify_integrity(
})?; })?;
Ok(()) Ok(())
} }
async fn create_nonce( async fn create_nonce(

View File

@@ -1,7 +1,5 @@
use crate::{ use crate::{
actors::{GlobalActors, client::ClientProfile}, actors::{GlobalActors, client::ClientProfile}, crypto::integrity::Integrable, db::{self, models::KeyType}
crypto::integrity::Integrable,
db::{self, models::KeyType},
}; };
fn serialize_ecdsa<S>(key: &k256::ecdsa::VerifyingKey, serializer: S) -> Result<S::Ok, S::Error> fn serialize_ecdsa<S>(key: &k256::ecdsa::VerifyingKey, serializer: S) -> Result<S::Ok, S::Error>
@@ -46,10 +44,7 @@ where
pub enum AuthPublicKey { pub enum AuthPublicKey {
Ed25519(ed25519_dalek::VerifyingKey), Ed25519(ed25519_dalek::VerifyingKey),
/// Compressed SEC1 public key; signature bytes are raw 64-byte (r||s). /// Compressed SEC1 public key; signature bytes are raw 64-byte (r||s).
#[serde( #[serde(serialize_with = "serialize_ecdsa", deserialize_with = "deserialize_ecdsa")]
serialize_with = "serialize_ecdsa",
deserialize_with = "deserialize_ecdsa"
)]
EcdsaSecp256k1(k256::ecdsa::VerifyingKey), EcdsaSecp256k1(k256::ecdsa::VerifyingKey),
/// RSA-2048+ public key (Windows Hello / KeyCredentialManager); signature bytes are PSS+SHA-256. /// RSA-2048+ public key (Windows Hello / KeyCredentialManager); signature bytes are PSS+SHA-256.
Rsa(rsa::RsaPublicKey), Rsa(rsa::RsaPublicKey),
@@ -58,7 +53,7 @@ pub enum AuthPublicKey {
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
pub struct UserAgentCredentials { pub struct UserAgentCredentials {
pub pubkey: AuthPublicKey, pub pubkey: AuthPublicKey,
pub nonce: i32, pub nonce: i32
} }
impl Integrable for UserAgentCredentials { impl Integrable for UserAgentCredentials {

View File

@@ -162,9 +162,7 @@ pub async fn verify_entity<E: Integrable>(
.first(conn) .first(conn)
.await .await
.map_err(|err| match err { .map_err(|err| match err {
diesel::result::Error::NotFound => Error::MissingEnvelope { diesel::result::Error::NotFound => Error::MissingEnvelope { entity_kind: E::KIND },
entity_kind: E::KIND,
},
other => Error::Database(db::DatabaseError::from(other)), other => Error::Database(db::DatabaseError::from(other)),
})?; })?;
@@ -178,7 +176,12 @@ pub async fn verify_entity<E: Integrable>(
let payload = postcard::to_stdvec(entity)?; let payload = postcard::to_stdvec(entity)?;
let payload_hash = payload_hash(&payload); let payload_hash = payload_hash(&payload);
let mac_input = build_mac_input(E::KIND, &entity_id, envelope.payload_version, &payload_hash); let mac_input = build_mac_input(
E::KIND,
&entity_id,
envelope.payload_version,
&payload_hash,
);
let result = keyholder let result = keyholder
.ask(VerifyIntegrity { .ask(VerifyIntegrity {
@@ -186,16 +189,13 @@ pub async fn verify_entity<E: Integrable>(
expected_mac: envelope.mac, expected_mac: envelope.mac,
key_version: envelope.key_version, key_version: envelope.key_version,
}) })
.await; .await
;
match result { match result {
Ok(true) => Ok(AttestationStatus::Attested), Ok(true) => Ok(AttestationStatus::Attested),
Ok(false) => Err(Error::MacMismatch { Ok(false) => Err(Error::MacMismatch { entity_kind: E::KIND }),
entity_kind: E::KIND, Err(SendError::HandlerError(keyholder::Error::NotBootstrapped)) => Ok(AttestationStatus::Unavailable),
}),
Err(SendError::HandlerError(keyholder::Error::NotBootstrapped)) => {
Ok(AttestationStatus::Unavailable)
}
Err(_) => Err(Error::KeyholderSend), Err(_) => Err(Error::KeyholderSend),
} }
} }
@@ -248,9 +248,7 @@ mod tests {
payload: b"payload-v1".to_vec(), payload: b"payload-v1".to_vec(),
}; };
sign_entity(&mut conn, &keyholder, &entity, ENTITY_ID) sign_entity(&mut conn, &keyholder, &entity, ENTITY_ID).await.unwrap();
.await
.unwrap();
let count: i64 = schema::integrity_envelope::table let count: i64 = schema::integrity_envelope::table
.filter(schema::integrity_envelope::entity_kind.eq("dummy_entity")) .filter(schema::integrity_envelope::entity_kind.eq("dummy_entity"))
@@ -261,9 +259,7 @@ mod tests {
.unwrap(); .unwrap();
assert_eq!(count, 1, "envelope row must be created exactly once"); assert_eq!(count, 1, "envelope row must be created exactly once");
verify_entity(&mut conn, &keyholder, &entity, ENTITY_ID) verify_entity(&mut conn, &keyholder, &entity, ENTITY_ID).await.unwrap();
.await
.unwrap();
} }
#[tokio::test] #[tokio::test]
@@ -279,9 +275,7 @@ mod tests {
payload: b"payload-v1".to_vec(), payload: b"payload-v1".to_vec(),
}; };
sign_entity(&mut conn, &keyholder, &entity, ENTITY_ID) sign_entity(&mut conn, &keyholder, &entity, ENTITY_ID).await.unwrap();
.await
.unwrap();
diesel::update(schema::integrity_envelope::table) diesel::update(schema::integrity_envelope::table)
.filter(schema::integrity_envelope::entity_kind.eq("dummy_entity")) .filter(schema::integrity_envelope::entity_kind.eq("dummy_entity"))
@@ -310,9 +304,7 @@ mod tests {
payload: b"payload-v1".to_vec(), payload: b"payload-v1".to_vec(),
}; };
sign_entity(&mut conn, &keyholder, &entity, ENTITY_ID) sign_entity(&mut conn, &keyholder, &entity, ENTITY_ID).await.unwrap();
.await
.unwrap();
let tampered = DummyEntity { let tampered = DummyEntity {
payload: b"payload-v1-but-tampered".to_vec(), payload: b"payload-v1-but-tampered".to_vec(),

View File

@@ -102,21 +102,11 @@ impl KeyCell {
} }
} }
/// User password might be of different length, have not enough entropy, etc...
/// Derive a fixed-length key from the password using Argon2id, which is designed for password hashing and key derivation. /// Derive a fixed-length key from the password using Argon2id, which is designed for password hashing and key derivation.
pub fn derive_key(mut password: SafeCell<Vec<u8>>, salt: &Salt) -> KeyCell { pub fn derive_key(mut password: SafeCell<Vec<u8>>, salt: &Salt) -> KeyCell {
let params = {
#[cfg(debug_assertions)]
{
argon2::Params::new(8, 1, 1, None).unwrap()
}
#[cfg(not(debug_assertions))]
{
argon2::Params::new(262_144, 3, 4, None).unwrap()
}
};
#[allow(clippy::unwrap_used)] #[allow(clippy::unwrap_used)]
let params = argon2::Params::new(262_144, 3, 4, None).unwrap();
let hasher = Argon2::new(Algorithm::Argon2id, argon2::Version::V0x13, params); let hasher = Argon2::new(Algorithm::Argon2id, argon2::Version::V0x13, params);
let mut key = SafeCell::new(Key::default()); let mut key = SafeCell::new(Key::default());
password.read_inline(|password_source| { password.read_inline(|password_source| {

View File

@@ -133,7 +133,6 @@ pub async fn create_pool(url: Option<&str>) -> Result<DatabasePool, DatabaseSetu
Ok(pool) Ok(pool)
} }
#[mutants::skip]
pub async fn create_test_pool() -> DatabasePool { pub async fn create_test_pool() -> DatabasePool {
use rand::distr::{Alphanumeric, SampleString as _}; use rand::distr::{Alphanumeric, SampleString as _};

View File

@@ -21,8 +21,8 @@ use crate::{
schema::{self, evm_transaction_log}, schema::{self, evm_transaction_log},
}, },
evm::policies::{ evm::policies::{
CombinedSettings, DatabaseID, EvalContext, EvalViolation, Grant, Policy, DatabaseID, EvalContext, EvalViolation, Grant, Policy, CombinedSettings, SharedGrantSettings,
SharedGrantSettings, SpecificGrant, SpecificMeaning, ether_transfer::EtherTransfer, SpecificGrant, SpecificMeaning, ether_transfer::EtherTransfer,
token_transfers::TokenTransfer, token_transfers::TokenTransfer,
}, },
}; };
@@ -90,14 +90,6 @@ async fn check_shared_constraints(
let mut violations = Vec::new(); let mut violations = Vec::new();
let now = Utc::now(); let now = Utc::now();
if shared.chain != context.chain {
violations.push(EvalViolation::MismatchingChainId {
expected: shared.chain,
actual: context.chain,
});
return Ok(violations);
}
// Validity window // Validity window
if shared.valid_from.is_some_and(|t| now < t) || shared.valid_until.is_some_and(|t| now > t) { if shared.valid_from.is_some_and(|t| now < t) || shared.valid_until.is_some_and(|t| now > t) {
violations.push(EvalViolation::InvalidTime); violations.push(EvalViolation::InvalidTime);
@@ -258,7 +250,12 @@ impl Engine {
P::create_grant(&basic_grant, &full_grant.specific, conn).await?; P::create_grant(&basic_grant, &full_grant.specific, conn).await?;
integrity::sign_entity(conn, &keyholder, &full_grant, basic_grant.id) integrity::sign_entity(
conn,
&keyholder,
&full_grant,
basic_grant.id,
)
.await .await
.map_err(|_| diesel::result::Error::RollbackTransaction)?; .map_err(|_| diesel::result::Error::RollbackTransaction)?;
@@ -345,255 +342,3 @@ impl Engine {
Err(VetError::UnsupportedTransactionType) Err(VetError::UnsupportedTransactionType)
} }
} }
#[cfg(test)]
mod tests {
use alloy::primitives::{Address, Bytes, U256, address};
use chrono::{Duration, Utc};
use diesel::{SelectableHelper, insert_into};
use diesel_async::RunQueryDsl;
use rstest::rstest;
use crate::db::{
self, DatabaseConnection,
models::{
EvmBasicGrant, EvmWalletAccess, NewEvmBasicGrant, NewEvmTransactionLog, SqliteTimestamp,
},
schema::{evm_basic_grant, evm_transaction_log},
};
use crate::evm::policies::{
EvalContext, EvalViolation, SharedGrantSettings, TransactionRateLimit,
};
use super::check_shared_constraints;
const WALLET_ACCESS_ID: i32 = 1;
const CHAIN_ID: u64 = 1;
const RECIPIENT: Address = address!("1111111111111111111111111111111111111111");
fn context() -> EvalContext {
EvalContext {
target: EvmWalletAccess {
id: WALLET_ACCESS_ID,
wallet_id: 10,
client_id: 20,
created_at: SqliteTimestamp(Utc::now()),
},
chain: CHAIN_ID,
to: RECIPIENT,
value: U256::ZERO,
calldata: Bytes::new(),
max_fee_per_gas: 100,
max_priority_fee_per_gas: 10,
}
}
fn shared_settings() -> SharedGrantSettings {
SharedGrantSettings {
wallet_access_id: WALLET_ACCESS_ID,
chain: CHAIN_ID,
valid_from: None,
valid_until: None,
max_gas_fee_per_gas: None,
max_priority_fee_per_gas: None,
rate_limit: None,
}
}
async fn insert_basic_grant(
conn: &mut DatabaseConnection,
shared: &SharedGrantSettings,
) -> EvmBasicGrant {
insert_into(evm_basic_grant::table)
.values(NewEvmBasicGrant {
wallet_access_id: shared.wallet_access_id,
chain_id: shared.chain as i32,
valid_from: shared.valid_from.map(SqliteTimestamp),
valid_until: shared.valid_until.map(SqliteTimestamp),
max_gas_fee_per_gas: shared
.max_gas_fee_per_gas
.map(|fee| super::utils::u256_to_bytes(fee).to_vec()),
max_priority_fee_per_gas: shared
.max_priority_fee_per_gas
.map(|fee| super::utils::u256_to_bytes(fee).to_vec()),
rate_limit_count: shared.rate_limit.as_ref().map(|limit| limit.count as i32),
rate_limit_window_secs: shared
.rate_limit
.as_ref()
.map(|limit| limit.window.num_seconds() as i32),
revoked_at: None,
})
.returning(EvmBasicGrant::as_select())
.get_result(conn)
.await
.unwrap()
}
#[rstest]
#[case::matching_chain(CHAIN_ID, false)]
#[case::mismatching_chain(CHAIN_ID + 1, true)]
#[tokio::test]
async fn check_shared_constraints_enforces_chain_id(
#[case] context_chain: u64,
#[case] expect_mismatch: bool,
) {
let db = db::create_test_pool().await;
let mut conn = db.get().await.unwrap();
let context = EvalContext {
chain: context_chain,
..context()
};
let violations = check_shared_constraints(&context, &shared_settings(), 999, &mut *conn)
.await
.unwrap();
assert_eq!(
violations
.iter()
.any(|violation| matches!(violation, EvalViolation::MismatchingChainId { .. })),
expect_mismatch
);
if expect_mismatch {
assert_eq!(violations.len(), 1);
} else {
assert!(violations.is_empty());
}
}
#[rstest]
#[case::valid_from_in_bounds(Some(Utc::now() - Duration::hours(1)), None, false)]
#[case::valid_from_out_of_bounds(Some(Utc::now() + Duration::hours(1)), None, true)]
#[case::valid_until_in_bounds(None, Some(Utc::now() + Duration::hours(1)), false)]
#[case::valid_until_out_of_bounds(None, Some(Utc::now() - Duration::hours(1)), true)]
#[tokio::test]
async fn check_shared_constraints_enforces_validity_window(
#[case] valid_from: Option<chrono::DateTime<Utc>>,
#[case] valid_until: Option<chrono::DateTime<Utc>>,
#[case] expect_invalid_time: bool,
) {
let db = db::create_test_pool().await;
let mut conn = db.get().await.unwrap();
let shared = SharedGrantSettings {
valid_from,
valid_until,
..shared_settings()
};
let violations = check_shared_constraints(&context(), &shared, 999, &mut *conn)
.await
.unwrap();
assert_eq!(
violations
.iter()
.any(|violation| matches!(violation, EvalViolation::InvalidTime)),
expect_invalid_time
);
if expect_invalid_time {
assert_eq!(violations.len(), 1);
} else {
assert!(violations.is_empty());
}
}
#[rstest]
#[case::max_fee_within_limit(Some(U256::from(100u64)), None, 100, 10, false)]
#[case::max_fee_exceeded(Some(U256::from(99u64)), None, 100, 10, true)]
#[case::priority_fee_within_limit(None, Some(U256::from(10u64)), 100, 10, false)]
#[case::priority_fee_exceeded(None, Some(U256::from(9u64)), 100, 10, true)]
#[tokio::test]
async fn check_shared_constraints_enforces_gas_fee_caps(
#[case] max_gas_fee_per_gas: Option<U256>,
#[case] max_priority_fee_per_gas: Option<U256>,
#[case] actual_max_fee_per_gas: u128,
#[case] actual_max_priority_fee_per_gas: u128,
#[case] expect_gas_limit_violation: bool,
) {
let db = db::create_test_pool().await;
let mut conn = db.get().await.unwrap();
let context = EvalContext {
max_fee_per_gas: actual_max_fee_per_gas,
max_priority_fee_per_gas: actual_max_priority_fee_per_gas,
..context()
};
let shared = SharedGrantSettings {
max_gas_fee_per_gas,
max_priority_fee_per_gas,
..shared_settings()
};
let violations = check_shared_constraints(&context, &shared, 999, &mut *conn)
.await
.unwrap();
assert_eq!(
violations
.iter()
.any(|violation| matches!(violation, EvalViolation::GasLimitExceeded { .. })),
expect_gas_limit_violation
);
if expect_gas_limit_violation {
assert_eq!(violations.len(), 1);
} else {
assert!(violations.is_empty());
}
}
#[rstest]
#[case::under_rate_limit(2, false)]
#[case::at_rate_limit(1, true)]
#[tokio::test]
async fn check_shared_constraints_enforces_rate_limit(
#[case] rate_limit_count: u32,
#[case] expect_rate_limit_violation: bool,
) {
let db = db::create_test_pool().await;
let mut conn = db.get().await.unwrap();
let shared = SharedGrantSettings {
rate_limit: Some(TransactionRateLimit {
count: rate_limit_count,
window: Duration::hours(1),
}),
..shared_settings()
};
let basic_grant = insert_basic_grant(&mut conn, &shared).await;
insert_into(evm_transaction_log::table)
.values(NewEvmTransactionLog {
grant_id: basic_grant.id,
wallet_access_id: WALLET_ACCESS_ID,
chain_id: CHAIN_ID as i32,
eth_value: super::utils::u256_to_bytes(U256::ZERO).to_vec(),
signed_at: SqliteTimestamp(Utc::now()),
})
.execute(&mut *conn)
.await
.unwrap();
let violations = check_shared_constraints(&context(), &shared, basic_grant.id, &mut *conn)
.await
.unwrap();
assert_eq!(
violations
.iter()
.any(|violation| matches!(violation, EvalViolation::RateLimitExceeded)),
expect_rate_limit_violation
);
if expect_rate_limit_violation {
assert_eq!(violations.len(), 1);
} else {
assert!(violations.is_empty());
}
}
}

View File

@@ -11,9 +11,7 @@ use serde::Serialize;
use thiserror::Error; use thiserror::Error;
use crate::{ use crate::{
crypto::integrity::v1::Integrable, crypto::integrity::v1::Integrable, db::models::{self, EvmBasicGrant, EvmWalletAccess}, evm::utils
db::models::{self, EvmBasicGrant, EvmWalletAccess},
evm::utils,
}; };
pub mod ether_transfer; pub mod ether_transfer;
@@ -57,9 +55,6 @@ pub enum EvalViolation {
#[error("Transaction type is not allowed by this grant")] #[error("Transaction type is not allowed by this grant")]
InvalidTransactionType, InvalidTransactionType,
#[error("Mismatching chain ID")]
MismatchingChainId { expected: ChainId, actual: ChainId },
} }
pub type DatabaseID = i32; pub type DatabaseID = i32;
@@ -220,3 +215,4 @@ impl<P: Integrable> Integrable for CombinedSettings<P> {
const KIND: &'static str = P::KIND; const KIND: &'static str = P::KIND;
const VERSION: i32 = P::VERSION; const VERSION: i32 = P::VERSION;
} }

View File

@@ -8,7 +8,7 @@ use arbiter_proto::proto::{
EvalViolation as ProtoEvalViolation, GasLimitExceededViolation, NoMatchingGrantError, EvalViolation as ProtoEvalViolation, GasLimitExceededViolation, NoMatchingGrantError,
PolicyViolationsError, SpecificMeaning as ProtoSpecificMeaning, PolicyViolationsError, SpecificMeaning as ProtoSpecificMeaning,
TokenInfo as ProtoTokenInfo, TransactionEvalError as ProtoTransactionEvalError, TokenInfo as ProtoTokenInfo, TransactionEvalError as ProtoTransactionEvalError,
eval_violation as proto_eval_violation, eval_violation::Kind as ProtoEvalViolationKind, eval_violation::Kind as ProtoEvalViolationKind,
specific_meaning::Meaning as ProtoSpecificMeaningKind, specific_meaning::Meaning as ProtoSpecificMeaningKind,
transaction_eval_error::Kind as ProtoTransactionEvalErrorKind, transaction_eval_error::Kind as ProtoTransactionEvalErrorKind,
}, },
@@ -79,12 +79,6 @@ impl Convert for EvalViolation {
EvalViolation::InvalidTransactionType => { EvalViolation::InvalidTransactionType => {
ProtoEvalViolationKind::InvalidTransactionType(()) ProtoEvalViolationKind::InvalidTransactionType(())
} }
EvalViolation::MismatchingChainId { expected, actual } => {
ProtoEvalViolationKind::ChainIdMismatch(proto_eval_violation::ChainIdMismatch {
expected,
actual,
})
}
}; };
ProtoEvalViolation { kind: Some(kind) } ProtoEvalViolation { kind: Some(kind) }

View File

@@ -10,7 +10,6 @@ use tracing::info;
const PORT: u16 = 50051; const PORT: u16 = 50051;
#[tokio::main] #[tokio::main]
#[mutants::skip]
async fn main() -> anyhow::Result<()> { async fn main() -> anyhow::Result<()> {
aws_lc_rs::default_provider().install_default().unwrap(); aws_lc_rs::default_provider().install_default().unwrap();