diff --git a/server/Cargo.lock b/server/Cargo.lock index fc4628a..dcd37c5 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -744,6 +744,8 @@ dependencies = [ "kameo", "memsafe", "pem", + "postcard", + "prost", "prost-types", "rand 0.10.0", "rcgen", @@ -751,6 +753,8 @@ dependencies = [ "rsa", "rustls", "secrecy", + "serde", + "serde_with", "sha2 0.10.9", "smlang", "spki", @@ -1053,6 +1057,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "atomic-polyfill" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" +dependencies = [ + "critical-section", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -1443,6 +1456,15 @@ dependencies = [ "cc", ] +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.18", +] + [[package]] name = "console" version = "0.15.11" @@ -1550,6 +1572,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -1955,6 +1983,7 @@ version = "3.0.0-rc.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6e914c7c52decb085cea910552e24c63ac019e3ab8bf001ff736da9a9d9d890" dependencies = [ + "serde", "signature 3.0.0-rc.10", ] @@ -1967,6 +1996,7 @@ dependencies = [ "curve25519-dalek 5.0.0-pre.6", "ed25519", "rand_core 0.10.0", + "serde", "sha2 0.11.0-rc.5", "subtle", "zeroize", @@ -2013,6 +2043,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + [[package]] name = "encode_unicode" version = "1.0.0" @@ -2052,7 +2094,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2430,6 +2472,15 @@ dependencies = [ "tracing", ] +[[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -2464,6 +2515,20 @@ dependencies = [ "serde_core", ] +[[package]] +name = "heapless" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" +dependencies = [ + "atomic-polyfill", + "hash32", + "rustc_version 0.4.1", + "serde", + "spin", + "stable_deref_trait", +] + [[package]] name = "heck" version = "0.5.0" @@ -3187,7 +3252,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3212,6 +3277,7 @@ dependencies = [ "num-iter", "num-traits", "rand 0.8.5", + "serde", "smallvec", "zeroize", ] @@ -3523,6 +3589,19 @@ version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "heapless", + "serde", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -4151,6 +4230,7 @@ dependencies = [ "pkcs1", "pkcs8", "rand_core 0.6.4", + "serde", "sha2 0.10.9", "signature 2.2.0", "spki", @@ -4286,7 +4366,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4702,7 +4782,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4710,6 +4790,9 @@ name = "spin" version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] [[package]] name = "spki" @@ -4896,7 +4979,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/server/Cargo.toml b/server/Cargo.toml index 1e41511..52d0794 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -42,4 +42,5 @@ k256 = { version = "0.13.4", features = ["ecdsa", "pkcs8"] } rsa = { version = "0.9", features = ["sha2"] } sha2 = "0.10" spki = "0.7" -miette = { version = "7.6.0", features = ["fancy", "serde"] } \ No newline at end of file +prost = "0.14.3" +miette = { version = "7.6.0", features = ["fancy", "serde"] } diff --git a/server/crates/arbiter-proto/Cargo.toml b/server/crates/arbiter-proto/Cargo.toml index 8299691..e1651a7 100644 --- a/server/crates/arbiter-proto/Cargo.toml +++ b/server/crates/arbiter-proto/Cargo.toml @@ -11,7 +11,7 @@ tokio.workspace = true futures.workspace = true hex = "0.4.3" tonic-prost = "0.14.5" -prost = "0.14.3" +prost.workspace = true kameo.workspace = true url = "2.5.8" miette.workspace = true diff --git a/server/crates/arbiter-server/Cargo.toml b/server/crates/arbiter-server/Cargo.toml index ccff5ba..a0bdae2 100644 --- a/server/crates/arbiter-server/Cargo.toml +++ b/server/crates/arbiter-server/Cargo.toml @@ -17,6 +17,7 @@ diesel-async = { version = "0.8.0", features = [ "tokio", ] } ed25519-dalek.workspace = true +ed25519-dalek.features = ["serde"] arbiter-proto.path = "../arbiter-proto" tracing.workspace = true tracing-subscriber = { version = "0.3", features = ["env-filter"] } @@ -46,14 +47,20 @@ restructed = "0.2.2" strum = { version = "0.28.0", features = ["derive"] } pem = "3.0.6" k256.workspace = true +k256.features = ["serde"] rsa.workspace = true +rsa.features = ["serde"] sha2.workspace = true hmac = "0.12" spki.workspace = true alloy.workspace = true prost-types.workspace = true +prost.workspace = true arbiter-tokens-registry.path = "../arbiter-tokens-registry" anyhow = "1.0.102" +postcard = { version = "1.1.3", features = ["use-std"] } +serde = { version = "1.0.228", features = ["derive"] } +serde_with = "3.18.0" [dev-dependencies] insta = "1.46.3" diff --git a/server/crates/arbiter-server/migrations/2026-02-14-171124-0000_init/up.sql b/server/crates/arbiter-server/migrations/2026-02-14-171124-0000_init/up.sql index a34aa5a..7ec6729 100644 --- a/server/crates/arbiter-server/migrations/2026-02-14-171124-0000_init/up.sql +++ b/server/crates/arbiter-server/migrations/2026-02-14-171124-0000_init/up.sql @@ -47,7 +47,6 @@ create table if not exists useragent_client ( id integer not null primary key, nonce integer not null default(1), -- used for auth challenge public_key blob not null, - pubkey_integrity_tag blob, key_type integer not null default(1), -- 1=Ed25519, 2=ECDSA(secp256k1) created_at integer not null default(unixepoch ('now')), updated_at integer not null default(unixepoch ('now')) @@ -192,3 +191,19 @@ create table if not exists evm_ether_transfer_grant_target ( ) STRICT; create unique index if not exists uniq_ether_transfer_target on evm_ether_transfer_grant_target (grant_id, address); + +-- =============================== +-- Integrity Envelopes +-- =============================== +create table if not exists integrity_envelope ( + id integer not null primary key, + entity_kind text not null, + entity_id blob not null, + payload_version integer not null, + key_version integer not null, + mac blob not null, -- 20-byte recipient address + signed_at integer not null default(unixepoch ('now')), + created_at integer not null default(unixepoch ('now')) +) STRICT; + +create unique index if not exists uniq_integrity_envelope_entity on integrity_envelope (entity_kind, entity_id); diff --git a/server/crates/arbiter-server/src/actors/evm/mod.rs b/server/crates/arbiter-server/src/actors/evm/mod.rs index 5a3a2f7..a615e19 100644 --- a/server/crates/arbiter-server/src/actors/evm/mod.rs +++ b/server/crates/arbiter-server/src/actors/evm/mod.rs @@ -7,18 +7,18 @@ use kameo::{Actor, actor::ActorRef, messages}; use rand::{SeedableRng, rng, rngs::StdRng}; use crate::{ - actors::keyholder::{CreateNew, Decrypt, KeyHolder}, + actors::keyholder::{CreateNew, Decrypt, GetState, KeyHolder, KeyHolderState}, + crypto::integrity, db::{ DatabaseError, DatabasePool, models::{self, SqliteTimestamp}, schema, }, evm::{ - self, RunKind, - policies::{ - FullGrant, Grant, SharedGrantSettings, SpecificGrant, SpecificMeaning, + self, ListError, RunKind, policies::{ + CombinedSettings, Grant, SharedGrantSettings, SpecificGrant, SpecificMeaning, ether_transfer::EtherTransfer, token_transfers::TokenTransfer, - }, + } }, safe_cell::{SafeCell, SafeCellHandle as _}, }; @@ -56,6 +56,9 @@ pub enum Error { #[error("Database error: {0}")] Database(#[from] DatabaseError), + + #[error("Integrity violation: {0}")] + Integrity(#[from] integrity::Error), } #[derive(Actor)] @@ -71,7 +74,7 @@ impl EvmActor { // is it safe to seed rng from system once? // todo: audit let rng = StdRng::from_rng(&mut rng()); - let engine = evm::Engine::new(db.clone()); + let engine = evm::Engine::new(db.clone(), keyholder.clone()); Self { keyholder, db, @@ -132,46 +135,59 @@ impl EvmActor { &mut self, basic: SharedGrantSettings, grant: SpecificGrant, - ) -> Result { + ) -> Result { match grant { - SpecificGrant::EtherTransfer(settings) => { - self.engine - .create_grant::(FullGrant { - basic, - specific: settings, - }) - .await - } - SpecificGrant::TokenTransfer(settings) => { - self.engine - .create_grant::(FullGrant { - basic, - specific: settings, - }) - .await - } + SpecificGrant::EtherTransfer(settings) => self + .engine + .create_grant::(CombinedSettings { + shared: basic, + specific: settings, + }) + .await + .map_err(Error::from), + SpecificGrant::TokenTransfer(settings) => self + .engine + .create_grant::(CombinedSettings { + shared: basic, + specific: settings, + }) + .await + .map_err(Error::from), } } #[message] pub async fn useragent_delete_grant(&mut self, grant_id: i32) -> Result<(), Error> { - let mut conn = self.db.get().await.map_err(DatabaseError::from)?; - diesel::update(schema::evm_basic_grant::table) - .filter(schema::evm_basic_grant::id.eq(grant_id)) - .set(schema::evm_basic_grant::revoked_at.eq(SqliteTimestamp::now())) - .execute(&mut conn) - .await - .map_err(DatabaseError::from)?; - Ok(()) + // let mut conn = self.db.get().await.map_err(DatabaseError::from)?; + // let keyholder = self.keyholder.clone(); + + // diesel_async::AsyncConnection::transaction(&mut conn, |conn| { + // Box::pin(async move { + // diesel::update(schema::evm_basic_grant::table) + // .filter(schema::evm_basic_grant::id.eq(grant_id)) + // .set(schema::evm_basic_grant::revoked_at.eq(SqliteTimestamp::now())) + // .execute(conn) + // .await?; + + // let signed = integrity::evm::load_signed_grant_by_basic_id(conn, grant_id).await?; + + // diesel::result::QueryResult::Ok(()) + // }) + // }) + // .await + // .map_err(DatabaseError::from)?; + + // Ok(()) + todo!() } #[message] pub async fn useragent_list_grants(&mut self) -> Result>, Error> { - Ok(self - .engine - .list_all_grants() - .await - .map_err(DatabaseError::from)?) + match self.engine.list_all_grants().await { + Ok(grants) => Ok(grants), + Err(ListError::Database(db_err)) => Err(Error::Database(db_err)), + Err(ListError::Integrity(integrity_err)) => Err(Error::Integrity(integrity_err)), + } } #[message] diff --git a/server/crates/arbiter-server/src/actors/keyholder/mod.rs b/server/crates/arbiter-server/src/actors/keyholder/mod.rs index 0ef0d82..8e43129 100644 --- a/server/crates/arbiter-server/src/actors/keyholder/mod.rs +++ b/server/crates/arbiter-server/src/actors/keyholder/mod.rs @@ -4,6 +4,7 @@ use diesel::{ dsl::{insert_into, update}, }; use diesel_async::{AsyncConnection, RunQueryDsl}; +use hmac::Mac as _; use kameo::{Actor, Reply, messages}; use strum::{EnumDiscriminants, IntoDiscriminant}; use tracing::{error, info}; @@ -12,7 +13,7 @@ use crate::{ crypto::{ KeyCell, derive_key, encryption::v1::{self, Nonce}, - integrity::v1::compute_integrity_tag, + integrity::v1::HmacSha256, }, safe_cell::SafeCell, }; @@ -250,22 +251,6 @@ impl KeyHolder { Ok(()) } - // Signs a generic integrity payload using the vault-derived integrity key - #[message] - pub fn sign_integrity_tag( - &mut self, - purpose_tag: Vec, - data_parts: Vec>, - ) -> Result, Error> { - let State::Unsealed { root_key, .. } = &mut self.state else { - return Err(Error::NotBootstrapped); - }; - - let tag = - compute_integrity_tag(root_key, &purpose_tag, data_parts.iter().map(Vec::as_slice)); - Ok(tag.to_vec()) - } - #[message] pub async fn decrypt(&mut self, aead_id: i32) -> Result>, Error> { let State::Unsealed { root_key, .. } = &mut self.state else { @@ -339,6 +324,60 @@ impl KeyHolder { self.state.discriminant() } + #[message] + pub fn sign_integrity(&mut self, mac_input: Vec) -> Result<(i32, Vec), Error> { + let State::Unsealed { + root_key, + root_key_history_id, + } = &mut self.state + else { + return Err(Error::NotBootstrapped); + }; + + let mut hmac = root_key + .0 + .read_inline(|k| match HmacSha256::new_from_slice(k) { + 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, + expected_mac: Vec, + key_version: i32, + ) -> Result { + 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 mut hmac = root_key + .0 + .read_inline(|k| match HmacSha256::new_from_slice(k) { + Ok(v) => v, + Err(_) => unreachable!("HMAC accepts keys of any size"), + }); + hmac.update(&key_version.to_be_bytes()); + hmac.update(&mac_input); + + Ok(hmac.verify_slice(&expected_mac).is_ok()) + } + #[message] pub fn seal(&mut self) -> Result<(), Error> { let State::Unsealed { diff --git a/server/crates/arbiter-server/src/actors/user_agent/auth.rs b/server/crates/arbiter-server/src/actors/user_agent/auth.rs index 7e2cf9c..83b0472 100644 --- a/server/crates/arbiter-server/src/actors/user_agent/auth.rs +++ b/server/crates/arbiter-server/src/actors/user_agent/auth.rs @@ -37,6 +37,13 @@ impl Error { } } +impl From for Error { + fn from(e: diesel::result::Error) -> Self { + error!(?e, "Database error"); + Self::internal("Database error") + } +} + #[derive(Debug, Clone)] pub enum Outbound { AuthChallenge { nonce: i32 }, diff --git a/server/crates/arbiter-server/src/actors/user_agent/auth/state.rs b/server/crates/arbiter-server/src/actors/user_agent/auth/state.rs index a9e0070..5ce6374 100644 --- a/server/crates/arbiter-server/src/actors/user_agent/auth/state.rs +++ b/server/crates/arbiter-server/src/actors/user_agent/auth/state.rs @@ -1,27 +1,20 @@ use arbiter_proto::transport::Bi; use diesel::{ExpressionMethods as _, OptionalExtension as _, QueryDsl, update}; -use diesel_async::RunQueryDsl; -use kameo::error::SendError; +use diesel_async::{AsyncConnection, RunQueryDsl}; +use kameo::{actor::ActorRef, error::SendError}; use tracing::error; use super::Error; use crate::{ actors::{ bootstrap::ConsumeToken, - keyholder::{self, SignIntegrityTag}, - user_agent::{AuthPublicKey, UserAgentConnection, auth::Outbound}, + keyholder::KeyHolder, + user_agent::{AuthPublicKey, UserAgentConnection, UserAgentCredentials, auth::Outbound}, }, - crypto::integrity::v1::USERAGENT_INTEGRITY_TAG, - db::schema, + crypto::integrity::{self, AttestationStatus}, + db::{DatabasePool, schema::useragent_client}, }; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum AttestationStatus { - Attested, - NotAttested, - Unavailable, -} - pub struct ChallengeRequest { pub pubkey: AuthPublicKey, } @@ -50,11 +43,11 @@ smlang::statemachine!( } ); -async fn create_nonce( - db: &crate::db::DatabasePool, - pubkey_bytes: &[u8], - key_type: crate::db::models::KeyType, -) -> Result { +/// Returns the current nonce, ready to use for the challenge nonce. +async fn get_current_nonce_and_id( + db: &DatabasePool, + key: &AuthPublicKey, +) -> Result<(i32, i32), Error> { let mut db_conn = db.get().await.map_err(|e| { error!(error = ?e, "Database pool error"); Error::internal("Database unavailable") @@ -62,21 +55,12 @@ async fn create_nonce( db_conn .exclusive_transaction(|conn| { Box::pin(async move { - let current_nonce = schema::useragent_client::table - .filter(schema::useragent_client::public_key.eq(pubkey_bytes.to_vec())) - .filter(schema::useragent_client::key_type.eq(key_type)) - .select(schema::useragent_client::nonce) - .first::(conn) - .await?; - - update(schema::useragent_client::table) - .filter(schema::useragent_client::public_key.eq(pubkey_bytes.to_vec())) - .filter(schema::useragent_client::key_type.eq(key_type)) - .set(schema::useragent_client::nonce.eq(current_nonce + 1)) - .execute(conn) - .await?; - - Result::<_, diesel::result::Error>::Ok(current_nonce) + useragent_client::table + .filter(useragent_client::public_key.eq(key.to_stored_bytes())) + .filter(useragent_client::key_type.eq(key.key_type())) + .select((useragent_client::id, useragent_client::nonce)) + .first::<(i32, i32)>(conn) + .await }) }) .await @@ -86,15 +70,92 @@ async fn create_nonce( Error::internal("Database operation failed") })? .ok_or_else(|| { - error!(?pubkey_bytes, "Public key not found in database"); + error!(?key, "Public key not found in database"); Error::UnregisteredPublicKey }) } -async fn register_key( - db: &crate::db::DatabasePool, +async fn verify_integrity( + db: &DatabasePool, + keyholder: &ActorRef, + pubkey: &AuthPublicKey, +) -> Result<(), Error> { + let mut db_conn = db.get().await.map_err(|e| { + error!(error = ?e, "Database pool error"); + Error::internal("Database unavailable") + })?; + + let (id, nonce) = get_current_nonce_and_id(db, pubkey).await?; + + let result = integrity::verify_entity( + &mut db_conn, + keyholder, + &UserAgentCredentials { + pubkey: pubkey.clone(), + nonce, + }, + id, + ) + .await + .map_err(|e| { + error!(?e, "Integrity verification failed"); + Error::internal("Integrity verification failed") + })?; + + Ok(()) + +} + +async fn create_nonce( + db: &DatabasePool, + keyholder: &ActorRef, + pubkey: &AuthPublicKey, +) -> Result { + let mut db_conn = db.get().await.map_err(|e| { + error!(error = ?e, "Database pool error"); + Error::internal("Database unavailable") + })?; + let new_nonce = db_conn + .exclusive_transaction(|conn| { + Box::pin(async move { + let (id, new_nonce): (i32, i32) = update(useragent_client::table) + .filter(useragent_client::public_key.eq(pubkey.to_stored_bytes())) + .filter(useragent_client::key_type.eq(pubkey.key_type())) + .set(useragent_client::nonce.eq(useragent_client::nonce + 1)) + .returning((useragent_client::id, useragent_client::nonce)) + .get_result(conn) + .await + .map_err(|e| { + error!(error = ?e, "Database error"); + Error::internal("Database operation failed") + })?; + + integrity::sign_entity( + conn, + keyholder, + &UserAgentCredentials { + pubkey: pubkey.clone(), + nonce: new_nonce, + }, + id, + ) + .await + .map_err(|e| { + error!(?e, "Integrity signature update failed"); + Error::internal("Database error") + })?; + + Result::<_, Error>::Ok(new_nonce) + }) + }) + .await?; + Ok(new_nonce) +} + +async fn register_key( + db: &DatabasePool, + keyholder: &ActorRef, pubkey: &AuthPublicKey, - integrity_tag: Option>, ) -> Result<(), Error> { let pubkey_bytes = pubkey.to_stored_bytes(); let key_type = pubkey.key_type(); @@ -103,19 +164,40 @@ async fn register_key( Error::internal("Database unavailable") })?; - diesel::insert_into(schema::useragent_client::table) - .values(( - schema::useragent_client::public_key.eq(pubkey_bytes), - schema::useragent_client::nonce.eq(1), - schema::useragent_client::key_type.eq(key_type), - schema::useragent_client::pubkey_integrity_tag.eq(integrity_tag), - )) - .execute(&mut conn) - .await - .map_err(|e| { - error!(error = ?e, "Database error"); - Error::internal("Database operation failed") - })?; + conn.transaction(|conn| { + Box::pin(async move { + const NONCE_START: i32 = 1; + + let id: i32 = diesel::insert_into(useragent_client::table) + .values(( + useragent_client::public_key.eq(pubkey_bytes), + useragent_client::nonce.eq(NONCE_START), + useragent_client::key_type.eq(key_type), + )) + .returning(useragent_client::id) + .get_result(conn) + .await + .map_err(|e| { + error!(error = ?e, "Database error"); + Error::internal("Database operation failed") + })?; + + let entity = UserAgentCredentials { + pubkey: pubkey.clone(), + nonce: NONCE_START, + }; + + integrity::sign_entity(conn, &keyholder, &entity, id) + .await + .map_err(|e| { + error!(error = ?e, "Failed to sign integrity tag for new user-agent key"); + Error::internal("Failed to register public key") + })?; + + Result::<_, Error>::Ok(()) + }) + }) + .await?; Ok(()) } @@ -141,15 +223,9 @@ where &mut self, ChallengeRequest { pubkey }: ChallengeRequest, ) -> Result { - match self.verify_pubkey_attestation_status(&pubkey).await? { - AttestationStatus::Attested | AttestationStatus::Unavailable => {} - AttestationStatus::NotAttested => { - return Err(Error::InvalidChallengeSolution); - } - } + verify_integrity(&self.conn.db, &self.conn.actors.key_holder, &pubkey).await?; - let stored_bytes = pubkey.to_stored_bytes(); - let nonce = create_nonce(&self.conn.db, &stored_bytes, pubkey.key_type()).await?; + let nonce = create_nonce(&self.conn.db, &self.conn.actors.key_holder, &pubkey).await?; self.transport .send(Ok(Outbound::AuthChallenge { nonce })) @@ -189,22 +265,24 @@ where return Err(Error::InvalidBootstrapToken); } - let integrity_tag = self - .try_sign_pubkey_integrity_tag(&pubkey) - .await - .map_err(|err| { - error!(?err, "Failed to sign user-agent pubkey integrity tag"); - Error::internal("Failed to sign user-agent pubkey integrity tag") - })?; - - register_key(&self.conn.db, &pubkey, integrity_tag).await?; - - self.transport - .send(Ok(Outbound::AuthSuccess)) - .await - .map_err(|_| Error::Transport)?; - - Ok(pubkey) + match token_ok { + true => { + register_key(&self.conn.db, &self.conn.actors.key_holder, &pubkey).await?; + self.transport + .send(Ok(Outbound::AuthSuccess)) + .await + .map_err(|_| Error::Transport)?; + Ok(pubkey) + } + false => { + error!("Invalid bootstrap token provided"); + self.transport + .send(Err(Error::InvalidBootstrapToken)) + .await + .map_err(|_| Error::Transport)?; + Err(Error::InvalidBootstrapToken) + } + } } #[allow(missing_docs)] @@ -264,93 +342,3 @@ where } } } - -impl AuthContext<'_, T> -where - T: Bi> + Send, -{ - async fn try_sign_pubkey_integrity_tag( - &self, - pubkey: &AuthPublicKey, - ) -> Result>, Error> { - let signed = self - .conn - .actors - .key_holder - .ask(SignIntegrityTag { - purpose_tag: USERAGENT_INTEGRITY_TAG.to_vec(), - data_parts: vec![ - (pubkey.key_type() as i32).to_be_bytes().to_vec(), - pubkey.to_stored_bytes(), - ], - }) - .await; - - match signed { - Ok(tag) => Ok(Some(tag)), - Err(SendError::HandlerError(keyholder::Error::NotBootstrapped)) => Ok(None), - Err(SendError::HandlerError(err)) => { - error!( - ?err, - "Keyholder failed to sign user-agent pubkey integrity tag" - ); - Err(Error::internal( - "Keyholder failed to sign user-agent pubkey integrity tag", - )) - } - Err(err) => { - error!( - ?err, - "Failed to contact keyholder for user-agent pubkey integrity tag" - ); - Err(Error::internal( - "Failed to contact keyholder for user-agent pubkey integrity tag", - )) - } - } - } - - async fn verify_pubkey_attestation_status( - &self, - pubkey: &AuthPublicKey, - ) -> Result { - let stored_tag: Option>> = { - let mut conn = self.conn.db.get().await.map_err(|e| { - error!(error = ?e, "Database pool error"); - Error::internal("Database unavailable") - })?; - - schema::useragent_client::table - .filter(schema::useragent_client::public_key.eq(pubkey.to_stored_bytes())) - .filter(schema::useragent_client::key_type.eq(pubkey.key_type())) - .select(schema::useragent_client::pubkey_integrity_tag) - .first::>>(&mut conn) - .await - .optional() - .map_err(|e| { - error!(error = ?e, "Database error"); - Error::internal("Database operation failed") - })? - }; - - let Some(stored_tag) = stored_tag else { - return Err(Error::UnregisteredPublicKey); - }; - - let Some(expected_tag) = self.try_sign_pubkey_integrity_tag(pubkey).await? else { - return Ok(AttestationStatus::Unavailable); - }; - - match stored_tag { - Some(stored_tag) if stored_tag == expected_tag => Ok(AttestationStatus::Attested), - Some(_) => { - error!("User-agent pubkey integrity tag mismatch"); - Ok(AttestationStatus::NotAttested) - } - None => { - error!("Missing pubkey integrity tag for registered key while vault is unsealed"); - Ok(AttestationStatus::NotAttested) - } - } - } -} diff --git a/server/crates/arbiter-server/src/actors/user_agent/mod.rs b/server/crates/arbiter-server/src/actors/user_agent/mod.rs index 3a45cc5..efe3adf 100644 --- a/server/crates/arbiter-server/src/actors/user_agent/mod.rs +++ b/server/crates/arbiter-server/src/actors/user_agent/mod.rs @@ -1,18 +1,65 @@ use crate::{ - actors::{GlobalActors, client::ClientProfile}, - db::{self, models::KeyType}, + actors::{GlobalActors, client::ClientProfile}, crypto::integrity::Integrable, db::{self, models::KeyType} }; +fn serialize_ecdsa(key: &k256::ecdsa::VerifyingKey, serializer: S) -> Result +where + S: serde::Serializer, +{ + // Serialize as hex string for easier debugging (33 bytes compressed SEC1 format) + let key = key.to_encoded_point(true); + let bytes = key.as_bytes(); + serializer.serialize_bytes(bytes) +} + +fn deserialize_ecdsa<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + struct EcdsaVisitor; + + impl<'de> serde::de::Visitor<'de> for EcdsaVisitor { + type Value = k256::ecdsa::VerifyingKey; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a compressed SEC1-encoded ECDSA public key") + } + + fn visit_bytes(self, v: &[u8]) -> Result + where + E: serde::de::Error, + { + let point = k256::EncodedPoint::from_bytes(v) + .map_err(|_| E::custom("invalid compressed SEC1 format"))?; + k256::ecdsa::VerifyingKey::from_encoded_point(&point) + .map_err(|_| E::custom("invalid ECDSA public key")) + } + } + + deserializer.deserialize_bytes(EcdsaVisitor) +} + /// Abstraction over Ed25519 / ECDSA-secp256k1 / RSA public keys used during the auth handshake. -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Serialize)] pub enum AuthPublicKey { Ed25519(ed25519_dalek::VerifyingKey), /// Compressed SEC1 public key; signature bytes are raw 64-byte (r||s). + #[serde(serialize_with = "serialize_ecdsa", deserialize_with = "deserialize_ecdsa")] EcdsaSecp256k1(k256::ecdsa::VerifyingKey), /// RSA-2048+ public key (Windows Hello / KeyCredentialManager); signature bytes are PSS+SHA-256. Rsa(rsa::RsaPublicKey), } +#[derive(Debug, Serialize)] +pub struct UserAgentCredentials { + pub pubkey: AuthPublicKey, + pub nonce: i32 +} + +impl Integrable for UserAgentCredentials { + const KIND: &'static str = "useragent_credentials"; +} + impl AuthPublicKey { /// Canonical bytes stored in DB and echoed back in the challenge. /// Ed25519: raw 32 bytes. ECDSA: SEC1 compressed 33 bytes. RSA: DER-encoded SPKI. @@ -91,4 +138,5 @@ pub mod auth; pub mod session; pub use auth::authenticate; +use serde::Serialize; pub use session::UserAgentSession; diff --git a/server/crates/arbiter-server/src/actors/user_agent/session/connection.rs b/server/crates/arbiter-server/src/actors/user_agent/session/connection.rs index 382dec5..3017819 100644 --- a/server/crates/arbiter-server/src/actors/user_agent/session/connection.rs +++ b/server/crates/arbiter-server/src/actors/user_agent/session/connection.rs @@ -120,6 +120,15 @@ pub enum SignTransactionError { Internal, } +#[derive(Debug, Error)] +pub enum GrantMutationError { + #[error("Vault is sealed")] + VaultSealed, + + #[error("Internal grant mutation error")] + Internal, +} + #[messages] impl UserAgentSession { #[message] @@ -331,7 +340,7 @@ impl UserAgentSession { &mut self, basic: crate::evm::policies::SharedGrantSettings, grant: crate::evm::policies::SpecificGrant, - ) -> Result { + ) -> Result { match self .props .actors @@ -342,13 +351,16 @@ impl UserAgentSession { Ok(grant_id) => Ok(grant_id), Err(err) => { error!(?err, "EVM grant create failed"); - Err(Error::internal("Failed to create EVM grant")) + Err(GrantMutationError::Internal) } } } #[message] - pub(crate) async fn handle_grant_delete(&mut self, grant_id: i32) -> Result<(), Error> { + pub(crate) async fn handle_grant_delete( + &mut self, + grant_id: i32, + ) -> Result<(), GrantMutationError> { match self .props .actors @@ -359,7 +371,7 @@ impl UserAgentSession { Ok(()) => Ok(()), Err(err) => { error!(?err, "EVM grant delete failed"); - Err(Error::internal("Failed to delete EVM grant")) + Err(GrantMutationError::Internal) } } } diff --git a/server/crates/arbiter-server/src/crypto/encryption/mod.rs b/server/crates/arbiter-server/src/crypto/encryption/mod.rs index a3a6d96..22fb807 100644 --- a/server/crates/arbiter-server/src/crypto/encryption/mod.rs +++ b/server/crates/arbiter-server/src/crypto/encryption/mod.rs @@ -1 +1,3 @@ pub mod v1; + +pub use v1::*; \ No newline at end of file diff --git a/server/crates/arbiter-server/src/crypto/integrity/mod.rs b/server/crates/arbiter-server/src/crypto/integrity/mod.rs index a3a6d96..22fb807 100644 --- a/server/crates/arbiter-server/src/crypto/integrity/mod.rs +++ b/server/crates/arbiter-server/src/crypto/integrity/mod.rs @@ -1 +1,3 @@ pub mod v1; + +pub use v1::*; \ No newline at end of file diff --git a/server/crates/arbiter-server/src/crypto/integrity/v1.rs b/server/crates/arbiter-server/src/crypto/integrity/v1.rs index 2ff4cdd..3fa7d17 100644 --- a/server/crates/arbiter-server/src/crypto/integrity/v1.rs +++ b/server/crates/arbiter-server/src/crypto/integrity/v1.rs @@ -1,78 +1,319 @@ -use crate::{crypto::KeyCell, safe_cell::SafeCellHandle as _}; +use crate::{actors::keyholder, crypto::KeyCell,safe_cell::SafeCellHandle as _}; use chacha20poly1305::Key; -use hmac::Mac as _; +use hmac::{Hmac, Mac as _}; +use serde::Serialize; +use sha2::Sha256; -pub const USERAGENT_INTEGRITY_DERIVE_TAG: &[u8] = "arbiter/useragent/integrity-key/v1".as_bytes(); -pub const USERAGENT_INTEGRITY_TAG: &[u8] = "arbiter/useragent/pubkey-entry/v1".as_bytes(); +use diesel::{ExpressionMethods as _, QueryDsl, dsl::insert_into, sqlite::Sqlite}; +use diesel_async::{AsyncConnection, RunQueryDsl}; +use kameo::{actor::ActorRef, error::SendError}; +use sha2::Digest as _; -/// Computes an integrity tag for a specific domain and payload shape. -pub fn compute_integrity_tag<'a, I>( - integrity_key: &mut KeyCell, - purpose_tag: &[u8], - data_parts: I, -) -> [u8; 32] -where - I: IntoIterator, -{ - type HmacSha256 = hmac::Hmac; +use crate::{ + actors::keyholder::{KeyHolder, SignIntegrity, VerifyIntegrity}, + db::{ + self, + models::{IntegrityEnvelope, NewIntegrityEnvelope}, + schema::integrity_envelope, + }, +}; - let mut output_tag = [0u8; 32]; - integrity_key.0.read_inline(|integrity_key_bytes: &Key| { - let mut mac = ::new_from_slice(integrity_key_bytes.as_ref()) - .expect("HMAC key initialization must not fail for 32-byte key"); - mac.update(purpose_tag); - for data_part in data_parts { - mac.update(data_part); - } - output_tag.copy_from_slice(&mac.finalize().into_bytes()); - }); +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("Database error: {0}")] + Database(#[from] db::DatabaseError), - output_tag + #[error("KeyHolder error: {0}")] + Keyholder(#[from] keyholder::Error), + + #[error("KeyHolder mailbox error")] + KeyholderSend, + + #[error("Integrity envelope is missing for entity {entity_kind}")] + MissingEnvelope { entity_kind: &'static str }, + + #[error( + "Integrity payload version mismatch for entity {entity_kind}: expected {expected}, found {found}" + )] + PayloadVersionMismatch { + entity_kind: &'static str, + expected: i32, + found: i32, + }, + + #[error("Integrity MAC mismatch for entity {entity_kind}")] + MacMismatch { entity_kind: &'static str }, + + #[error("Payload serialization error: {0}")] + PayloadSerialization(#[from] postcard::Error), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AttestationStatus { + Attested, + Unavailable, +} + +pub const CURRENT_PAYLOAD_VERSION: i32 = 1; +pub const INTEGRITY_SUBKEY_TAG: &[u8] = b"arbiter/db-integrity-key/v1"; + +pub type HmacSha256 = Hmac; + +pub trait Integrable: Serialize { + const KIND: &'static str; + const VERSION: i32 = 1; +} + +fn payload_hash(payload: &[u8]) -> [u8; 32] { + Sha256::digest(payload).into() +} + +fn push_len_prefixed(out: &mut Vec, 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 { + 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 trait IntoId { + fn into_id(self) -> Vec; +} + +impl IntoId for i32 { + fn into_id(self) -> Vec { + self.to_be_bytes().to_vec() + } +} + +impl IntoId for &'_ [u8] { + fn into_id(self) -> Vec { + self.to_vec() + } +} + +pub async fn sign_entity( + conn: &mut impl AsyncConnection, + keyholder: &ActorRef, + entity: &E, + entity_id: impl IntoId, +) -> Result<(), Error> { + let payload = postcard::to_stdvec(entity)?; + let payload_hash = payload_hash(&payload); + + let entity_id = entity_id.into_id(); + + let mac_input = build_mac_input(E::KIND, &entity_id, E::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, + })?; + + insert_into(integrity_envelope::table) + .values(NewIntegrityEnvelope { + entity_kind: E::KIND.to_owned(), + entity_id: entity_id, + payload_version: E::VERSION , + key_version, + mac: mac.to_vec(), + }) + .on_conflict(( + integrity_envelope::entity_id, + integrity_envelope::entity_kind, + )) + .do_update() + .set(( + integrity_envelope::payload_version.eq(E::VERSION), + integrity_envelope::key_version.eq(key_version), + integrity_envelope::mac.eq(mac), + )) + .execute(conn) + .await + .map_err(db::DatabaseError::from)?; + + Ok(()) +} + +pub async fn verify_entity( + conn: &mut impl AsyncConnection, + keyholder: &ActorRef, + entity: &E, + entity_id: impl IntoId, +) -> Result { + let entity_id = entity_id.into_id(); + let envelope: IntegrityEnvelope = integrity_envelope::table + .filter(integrity_envelope::entity_kind.eq(E::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: E::KIND }, + other => Error::Database(db::DatabaseError::from(other)), + })?; + + if envelope.payload_version != E::VERSION { + return Err(Error::PayloadVersionMismatch { + entity_kind: E::KIND, + expected: E::VERSION, + found: envelope.payload_version, + }); + } + + let payload = postcard::to_stdvec(entity)?; + let payload_hash = payload_hash(&payload); + let mac_input = build_mac_input( + E::KIND, + &entity_id, + envelope.payload_version, + &payload_hash, + ); + + let result = keyholder + .ask(VerifyIntegrity { + mac_input, + expected_mac: envelope.mac, + key_version: envelope.key_version, + }) + .await + ; + + match result { + Ok(true) => Ok(AttestationStatus::Attested), + Ok(false) => Err(Error::MacMismatch { entity_kind: E::KIND }), + Err(SendError::HandlerError(keyholder::Error::NotBootstrapped)) => Ok(AttestationStatus::Unavailable), + Err(_) => Err(Error::KeyholderSend), + } } #[cfg(test)] mod tests { + use diesel::{ExpressionMethods as _, QueryDsl}; + use diesel_async::RunQueryDsl; + use kameo::{actor::ActorRef, prelude::Spawn}; + use crate::{ - crypto::{derive_key, encryption::v1::generate_salt}, + actors::keyholder::{Bootstrap, KeyHolder}, + db::{self, schema}, safe_cell::{SafeCell, SafeCellHandle as _}, }; - use super::{USERAGENT_INTEGRITY_TAG, compute_integrity_tag}; + use super::{Error, Integrable, sign_entity, verify_entity}; - #[test] - pub fn integrity_tag_deterministic() { - let salt = generate_salt(); - let mut integrity_key = derive_key(SafeCell::new(b"password".to_vec()), &salt); - let key_type = 1i32.to_be_bytes(); - let t1 = compute_integrity_tag( - &mut integrity_key, - USERAGENT_INTEGRITY_TAG, - [key_type.as_slice(), b"pubkey".as_ref()], - ); - let t2 = compute_integrity_tag( - &mut integrity_key, - USERAGENT_INTEGRITY_TAG, - [key_type.as_slice(), b"pubkey".as_ref()], - ); - assert_eq!(t1, t2); + #[derive(Clone, serde::Serialize)] + struct DummyEntity { + payload_version: i32, + payload: Vec, } - #[test] - pub fn integrity_tag_changes_with_payload() { - let salt = generate_salt(); - let mut integrity_key = derive_key(SafeCell::new(b"password".to_vec()), &salt); - let key_type_1 = 1i32.to_be_bytes(); - let key_type_2 = 2i32.to_be_bytes(); - let t1 = compute_integrity_tag( - &mut integrity_key, - USERAGENT_INTEGRITY_TAG, - [key_type_1.as_slice(), b"pubkey".as_ref()], - ); - let t2 = compute_integrity_tag( - &mut integrity_key, - USERAGENT_INTEGRITY_TAG, - [key_type_2.as_slice(), b"pubkey".as_ref()], - ); - assert_ne!(t1, t2); + impl Integrable for DummyEntity { + const KIND: &'static str = "dummy_entity"; + } + + async fn bootstrapped_keyholder(db: &db::DatabasePool) -> ActorRef { + 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(); + + const ENTITY_ID: &[u8] = b"entity-id-7"; + + let entity = DummyEntity { + payload_version: 1, + payload: b"payload-v1".to_vec(), + }; + + sign_entity(&mut conn, &keyholder, &entity, ENTITY_ID).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_ID)) + .count() + .get_result(&mut conn) + .await + .unwrap(); + + assert_eq!(count, 1, "envelope row must be created exactly once"); + verify_entity(&mut conn, &keyholder, &entity, ENTITY_ID).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(); + + const ENTITY_ID: &[u8] = b"entity-id-11"; + + let entity = DummyEntity { + payload_version: 1, + payload: b"payload-v1".to_vec(), + }; + + sign_entity(&mut conn, &keyholder, &entity, ENTITY_ID).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_ID)) + .set(schema::integrity_envelope::mac.eq(vec![0u8; 32])) + .execute(&mut conn) + .await + .unwrap(); + + let err = verify_entity(&mut conn, &keyholder, &entity, ENTITY_ID) + .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(); + + const ENTITY_ID: &[u8] = b"entity-id-21"; + + let entity = DummyEntity { + payload_version: 1, + payload: b"payload-v1".to_vec(), + }; + + sign_entity(&mut conn, &keyholder, &entity, ENTITY_ID).await.unwrap(); + + let tampered = DummyEntity { + payload: b"payload-v1-but-tampered".to_vec(), + ..entity + }; + + let err = verify_entity(&mut conn, &keyholder, &tampered, ENTITY_ID) + .await + .unwrap_err(); + assert!(matches!(err, Error::MacMismatch { .. })); } } diff --git a/server/crates/arbiter-server/src/db/models.rs b/server/crates/arbiter-server/src/db/models.rs index 6fb171c..f558072 100644 --- a/server/crates/arbiter-server/src/db/models.rs +++ b/server/crates/arbiter-server/src/db/models.rs @@ -5,7 +5,7 @@ use crate::db::schema::{ self, aead_encrypted, arbiter_settings, evm_basic_grant, evm_ether_transfer_grant, evm_ether_transfer_grant_target, evm_ether_transfer_limit, evm_token_transfer_grant, evm_token_transfer_log, evm_token_transfer_volume_limit, evm_transaction_log, evm_wallet, - root_key_history, tls_history, + integrity_envelope, root_key_history, tls_history, }; use chrono::{DateTime, Utc}; use diesel::{prelude::*, sqlite::Sqlite}; @@ -242,7 +242,6 @@ pub struct UseragentClient { pub id: i32, pub nonce: i32, pub public_key: Vec, - pub pubkey_integrity_tag: Option>, pub created_at: SqliteTimestamp, pub updated_at: SqliteTimestamp, pub key_type: KeyType, @@ -377,3 +376,22 @@ pub struct EvmTokenTransferLog { pub value: Vec, 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, + pub payload_version: i32, + pub key_version: i32, + pub mac: Vec, + pub signed_at: SqliteTimestamp, + pub created_at: SqliteTimestamp, +} diff --git a/server/crates/arbiter-server/src/db/schema.rs b/server/crates/arbiter-server/src/db/schema.rs index 88bdef3..c9b980c 100644 --- a/server/crates/arbiter-server/src/db/schema.rs +++ b/server/crates/arbiter-server/src/db/schema.rs @@ -139,6 +139,19 @@ diesel::table! { } } +diesel::table! { + integrity_envelope (id) { + id -> Integer, + entity_kind -> Text, + entity_id -> Binary, + payload_version -> Integer, + key_version -> Integer, + mac -> Binary, + signed_at -> Integer, + created_at -> Integer, + } +} + diesel::table! { program_client (id) { id -> Integer, @@ -178,7 +191,6 @@ diesel::table! { id -> Integer, nonce -> Integer, public_key -> Binary, - pubkey_integrity_tag -> Nullable, key_type -> Integer, created_at -> Integer, updated_at -> Integer, @@ -220,6 +232,7 @@ diesel::allow_tables_to_appear_in_same_query!( evm_transaction_log, evm_wallet, evm_wallet_access, + integrity_envelope, program_client, root_key_history, tls_history, diff --git a/server/crates/arbiter-server/src/evm/mod.rs b/server/crates/arbiter-server/src/evm/mod.rs index d840d81..6727650 100644 --- a/server/crates/arbiter-server/src/evm/mod.rs +++ b/server/crates/arbiter-server/src/evm/mod.rs @@ -8,8 +8,11 @@ use alloy::{ use chrono::Utc; use diesel::{ExpressionMethods as _, QueryDsl as _, QueryResult, insert_into, sqlite::Sqlite}; use diesel_async::{AsyncConnection, RunQueryDsl}; +use kameo::actor::ActorRef; use crate::{ + actors::keyholder::KeyHolder, + crypto::integrity, db::{ self, DatabaseError, models::{ @@ -18,7 +21,7 @@ use crate::{ schema::{self, evm_transaction_log}, }, evm::policies::{ - DatabaseID, EvalContext, EvalViolation, FullGrant, Grant, Policy, SharedGrantSettings, + DatabaseID, EvalContext, EvalViolation, Grant, Policy, CombinedSettings, SharedGrantSettings, SpecificGrant, SpecificMeaning, ether_transfer::EtherTransfer, token_transfers::TokenTransfer, }, @@ -36,6 +39,9 @@ pub enum PolicyError { Violations(Vec), #[error("No matching grant found")] NoMatchingGrant, + + #[error("Integrity error: {0}")] + Integrity(#[from] integrity::Error), } #[derive(Debug, thiserror::Error)] @@ -57,6 +63,15 @@ pub enum AnalyzeError { UnsupportedTransactionType, } +#[derive(Debug, thiserror::Error)] +pub enum ListError { + #[error("Database error")] + Database(#[from] crate::db::DatabaseError), + + #[error("Integrity verification failed for grant")] + Integrity(#[from] integrity::Error), +} + /// Controls whether a transaction should be executed or only validated #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RunKind { @@ -115,6 +130,7 @@ async fn check_shared_constraints( // Supporting only EIP-1559 transactions for now, but we can easily extend this to support legacy transactions if needed pub struct Engine { db: db::DatabasePool, + keyholder: ActorRef, } impl Engine { @@ -123,7 +139,10 @@ impl Engine { context: EvalContext, meaning: &P::Meaning, run_kind: RunKind, - ) -> Result<(), PolicyError> { + ) -> Result<(), PolicyError> + where + P::Settings: Clone, + { let mut conn = self.db.get().await.map_err(DatabaseError::from)?; let grant = P::try_find_grant(&context, &mut conn) @@ -131,10 +150,16 @@ impl Engine { .map_err(DatabaseError::from)? .ok_or(PolicyError::NoMatchingGrant)?; - let mut violations = - check_shared_constraints(&context, &grant.shared, grant.shared_grant_id, &mut conn) - .await - .map_err(DatabaseError::from)?; + integrity::verify_entity(&mut conn, &self.keyholder, &grant.settings, grant.id).await?; + + let mut violations = check_shared_constraints( + &context, + &grant.settings.shared, + grant.common_settings_id, + &mut conn, + ) + .await + .map_err(DatabaseError::from)?; violations.extend( P::evaluate(&context, meaning, &grant, &mut conn) .await @@ -143,12 +168,14 @@ impl Engine { if !violations.is_empty() { return Err(PolicyError::Violations(violations)); - } else if run_kind == RunKind::Execution { + } + + if run_kind == RunKind::Execution { conn.transaction(|conn| { Box::pin(async move { let log_id: i32 = insert_into(evm_transaction_log::table) .values(&NewEvmTransactionLog { - grant_id: grant.shared_grant_id, + grant_id: grant.common_settings_id, wallet_access_id: context.target.id, chain_id: context.chain as i32, eth_value: utils::u256_to_bytes(context.value).to_vec(), @@ -172,15 +199,19 @@ impl Engine { } impl Engine { - pub fn new(db: db::DatabasePool) -> Self { - Self { db } + pub fn new(db: db::DatabasePool, keyholder: ActorRef) -> Self { + Self { db, keyholder } } pub async fn create_grant( &self, - full_grant: FullGrant, - ) -> Result { + full_grant: CombinedSettings, + ) -> Result + where + P::Settings: Clone, + { let mut conn = self.db.get().await?; + let keyholder = self.keyholder.clone(); let id = conn .transaction(|conn| { @@ -189,25 +220,25 @@ impl Engine { let basic_grant: EvmBasicGrant = insert_into(evm_basic_grant::table) .values(&NewEvmBasicGrant { - chain_id: full_grant.basic.chain as i32, - wallet_access_id: full_grant.basic.wallet_access_id, - valid_from: full_grant.basic.valid_from.map(SqliteTimestamp), - valid_until: full_grant.basic.valid_until.map(SqliteTimestamp), + chain_id: full_grant.shared.chain as i32, + wallet_access_id: full_grant.shared.wallet_access_id, + valid_from: full_grant.shared.valid_from.map(SqliteTimestamp), + valid_until: full_grant.shared.valid_until.map(SqliteTimestamp), max_gas_fee_per_gas: full_grant - .basic + .shared .max_gas_fee_per_gas .map(|fee| utils::u256_to_bytes(fee).to_vec()), max_priority_fee_per_gas: full_grant - .basic + .shared .max_priority_fee_per_gas .map(|fee| utils::u256_to_bytes(fee).to_vec()), rate_limit_count: full_grant - .basic + .shared .rate_limit .as_ref() .map(|rl| rl.count as i32), rate_limit_window_secs: full_grant - .basic + .shared .rate_limit .as_ref() .map(|rl| rl.window.num_seconds() as i32), @@ -217,7 +248,18 @@ impl Engine { .get_result(conn) .await?; - P::create_grant(&basic_grant, &full_grant.specific, conn).await + P::create_grant(&basic_grant, &full_grant.specific, conn).await?; + + integrity::sign_entity( + conn, + &keyholder, + &full_grant, + basic_grant.id, + ) + .await + .map_err(|_| diesel::result::Error::RollbackTransaction)?; + + QueryResult::Ok(basic_grant.id) }) }) .await?; @@ -225,33 +267,36 @@ impl Engine { Ok(id) } - pub async fn list_all_grants(&self) -> Result>, DatabaseError> { - let mut conn = self.db.get().await?; + async fn list_one_kind( + &self, + conn: &mut impl AsyncConnection, + ) -> Result>, ListError> + where + Y: From, + { + let all_grants = Kind::find_all_grants(conn) + .await + .map_err(DatabaseError::from)?; + + // Verify integrity of all grants before returning any results + for grant in &all_grants { + integrity::verify_entity(conn, &self.keyholder, &grant.settings, grant.id).await?; + } + + Ok(all_grants.into_iter().map(|g| Grant { + id: g.id, + common_settings_id: g.common_settings_id, + settings: g.settings.generalize(), + })) + } + + pub async fn list_all_grants(&self) -> Result>, ListError> { + let mut conn = self.db.get().await.map_err(DatabaseError::from)?; let mut grants: Vec> = Vec::new(); - grants.extend( - EtherTransfer::find_all_grants(&mut conn) - .await? - .into_iter() - .map(|g| Grant { - id: g.id, - shared_grant_id: g.shared_grant_id, - shared: g.shared, - settings: SpecificGrant::EtherTransfer(g.settings), - }), - ); - grants.extend( - TokenTransfer::find_all_grants(&mut conn) - .await? - .into_iter() - .map(|g| Grant { - id: g.id, - shared_grant_id: g.shared_grant_id, - shared: g.shared, - settings: SpecificGrant::TokenTransfer(g.settings), - }), - ); + grants.extend(self.list_one_kind::(&mut conn).await?); + grants.extend(self.list_one_kind::(&mut conn).await?); Ok(grants) } diff --git a/server/crates/arbiter-server/src/evm/policies.rs b/server/crates/arbiter-server/src/evm/policies.rs index f0e7796..d69af22 100644 --- a/server/crates/arbiter-server/src/evm/policies.rs +++ b/server/crates/arbiter-server/src/evm/policies.rs @@ -7,11 +7,11 @@ use diesel::{ }; use diesel_async::{AsyncConnection, RunQueryDsl}; +use serde::Serialize; use thiserror::Error; use crate::{ - db::models::{self, EvmBasicGrant, EvmWalletAccess}, - evm::utils, + crypto::integrity::v1::Integrable, db::models::{self, EvmBasicGrant, EvmWalletAccess}, evm::utils }; pub mod ether_transfer; @@ -59,16 +59,15 @@ pub enum EvalViolation { pub type DatabaseID = i32; -#[derive(Debug)] +#[derive(Debug, Serialize)] pub struct Grant { pub id: DatabaseID, - pub shared_grant_id: DatabaseID, // ID of the basic grant for shared-logic checks like rate limits and validity periods - pub shared: SharedGrantSettings, - pub settings: PolicySettings, + pub common_settings_id: DatabaseID, // ID of the basic grant for shared-logic checks like rate limits and validity periods + pub settings: CombinedSettings, } pub trait Policy: Sized { - type Settings: Send + Sync + 'static + Into; + type Settings: Send + Sync + 'static + Into + Integrable; type Meaning: Display + std::fmt::Debug + Send + Sync + 'static + Into; fn analyze(context: &EvalContext) -> Option; @@ -124,19 +123,19 @@ pub enum SpecificMeaning { TokenTransfer(token_transfers::Meaning), } -#[derive(Clone, Debug, PartialEq, Eq, Hash)] +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize)] pub struct TransactionRateLimit { pub count: u32, pub window: Duration, } -#[derive(Clone, Debug, PartialEq, Eq, Hash)] +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize)] pub struct VolumeRateLimit { pub max_volume: U256, pub window: Duration, } -#[derive(Clone, Debug, PartialEq, Eq, Hash)] +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize)] pub struct SharedGrantSettings { pub wallet_access_id: i32, pub chain: ChainId, @@ -151,7 +150,7 @@ pub struct SharedGrantSettings { } impl SharedGrantSettings { - fn try_from_model(model: EvmBasicGrant) -> QueryResult { + pub(crate) fn try_from_model(model: EvmBasicGrant) -> QueryResult { Ok(Self { wallet_access_id: model.wallet_access_id, chain: model.chain_id as u64, // safe because chain_id is stored as i32 but is guaranteed to be a valid ChainId by the API when creating grants @@ -197,7 +196,23 @@ pub enum SpecificGrant { TokenTransfer(token_transfers::Settings), } -pub struct FullGrant { - pub basic: SharedGrantSettings, +#[derive(Debug, Serialize)] +pub struct CombinedSettings { + pub shared: SharedGrantSettings, pub specific: PolicyGrant, } + +impl

CombinedSettings

{ + pub fn generalize>(self) -> CombinedSettings { + CombinedSettings { + shared: self.shared, + specific: self.specific.into(), + } + } +} + +impl Integrable for CombinedSettings

{ + const KIND: &'static str = P::KIND; + const VERSION: i32 = P::VERSION; +} + diff --git a/server/crates/arbiter-server/src/evm/policies/ether_transfer/mod.rs b/server/crates/arbiter-server/src/evm/policies/ether_transfer/mod.rs index d419b59..57a38dd 100644 --- a/server/crates/arbiter-server/src/evm/policies/ether_transfer/mod.rs +++ b/server/crates/arbiter-server/src/evm/policies/ether_transfer/mod.rs @@ -8,13 +8,14 @@ use diesel::sqlite::Sqlite; use diesel::{ExpressionMethods, JoinOnDsl, prelude::*}; use diesel_async::{AsyncConnection, RunQueryDsl}; +use crate::crypto::integrity::v1::Integrable; use crate::db::models::{ EvmBasicGrant, EvmEtherTransferGrant, EvmEtherTransferGrantTarget, EvmEtherTransferLimit, NewEvmEtherTransferLimit, SqliteTimestamp, }; use crate::db::schema::{evm_basic_grant, evm_ether_transfer_limit, evm_transaction_log}; use crate::evm::policies::{ - Grant, SharedGrantSettings, SpecificGrant, SpecificMeaning, VolumeRateLimit, + CombinedSettings, Grant, SharedGrantSettings, SpecificGrant, SpecificMeaning, VolumeRateLimit, }; use crate::{ db::{ @@ -51,11 +52,14 @@ impl From for SpecificMeaning { } // A grant for ether transfers, which can be scoped to specific target addresses and volume limits -#[derive(Debug, Clone)] +#[derive(Debug, Clone, serde::Serialize)] pub struct Settings { pub target: Vec

, pub limit: VolumeRateLimit, } +impl Integrable for Settings { + const KIND: &'static str = "EtherTransfer"; +} impl From for SpecificGrant { fn from(val: Settings) -> SpecificGrant { @@ -95,17 +99,17 @@ async fn check_rate_limits( db: &mut impl AsyncConnection, ) -> QueryResult> { let mut violations = Vec::new(); - let window = grant.settings.limit.window; + let window = grant.settings.specific.limit.window; let past_transaction = query_relevant_past_transaction(grant.id, window, db).await?; - let window_start = chrono::Utc::now() - grant.settings.limit.window; + let window_start = chrono::Utc::now() - grant.settings.specific.limit.window; let prospective_cumulative_volume: U256 = past_transaction .iter() .filter(|(_, timestamp)| timestamp >= &window_start) .fold(current_transfer_value, |acc, (value, _)| acc + *value); - if prospective_cumulative_volume > grant.settings.limit.max_volume { + if prospective_cumulative_volume > grant.settings.specific.limit.max_volume { violations.push(EvalViolation::VolumetricLimitExceeded); } @@ -138,7 +142,7 @@ impl Policy for EtherTransfer { let mut violations = Vec::new(); // Check if the target address is within the grant's allowed targets - if !grant.settings.target.contains(&meaning.to) { + if !grant.settings.specific.target.contains(&meaning.to) { violations.push(EvalViolation::InvalidTarget { target: meaning.to }); } @@ -247,9 +251,11 @@ impl Policy for EtherTransfer { Ok(Some(Grant { id: grant.id, - shared_grant_id: grant.basic_grant_id, - shared: SharedGrantSettings::try_from_model(basic_grant)?, - settings, + common_settings_id: grant.basic_grant_id, + settings: CombinedSettings { + shared: SharedGrantSettings::try_from_model(basic_grant)?, + specific: settings, + }, })) } @@ -327,15 +333,17 @@ impl Policy for EtherTransfer { Ok(Grant { id: specific.id, - shared_grant_id: specific.basic_grant_id, - shared: SharedGrantSettings::try_from_model(basic)?, - settings: Settings { - target: targets, - limit: VolumeRateLimit { - max_volume: utils::try_bytes_to_u256(&limit.max_volume).map_err( - |e| diesel::result::Error::DeserializationError(Box::new(e)), - )?, - window: Duration::seconds(limit.window_secs as i64), + common_settings_id: specific.basic_grant_id, + settings: CombinedSettings { + shared: SharedGrantSettings::try_from_model(basic)?, + specific: Settings { + target: targets, + limit: VolumeRateLimit { + max_volume: utils::try_bytes_to_u256(&limit.max_volume).map_err( + |e| diesel::result::Error::DeserializationError(Box::new(e)), + )?, + window: Duration::seconds(limit.window_secs as i64), + }, }, }, }) diff --git a/server/crates/arbiter-server/src/evm/policies/ether_transfer/tests.rs b/server/crates/arbiter-server/src/evm/policies/ether_transfer/tests.rs index 9ba48be..ab4e8f0 100644 --- a/server/crates/arbiter-server/src/evm/policies/ether_transfer/tests.rs +++ b/server/crates/arbiter-server/src/evm/policies/ether_transfer/tests.rs @@ -11,7 +11,10 @@ use crate::db::{ schema::{evm_basic_grant, evm_transaction_log}, }; use crate::evm::{ - policies::{EvalContext, EvalViolation, Grant, Policy, SharedGrantSettings, VolumeRateLimit}, + policies::{ + CombinedSettings, EvalContext, EvalViolation, Grant, Policy, SharedGrantSettings, + VolumeRateLimit, + }, utils, }; @@ -108,9 +111,11 @@ async fn evaluate_passes_for_allowed_target() { let grant = Grant { id: 999, - shared_grant_id: 999, - shared: shared(), - settings: make_settings(vec![ALLOWED], 1_000_000), + common_settings_id: 999, + settings: CombinedSettings { + shared: shared(), + specific: make_settings(vec![ALLOWED], 1_000_000), + }, }; let context = ctx(ALLOWED, U256::from(100u64)); let m = EtherTransfer::analyze(&context).unwrap(); @@ -127,9 +132,11 @@ async fn evaluate_rejects_disallowed_target() { let grant = Grant { id: 999, - shared_grant_id: 999, - shared: shared(), - settings: make_settings(vec![ALLOWED], 1_000_000), + common_settings_id: 999, + settings: CombinedSettings { + shared: shared(), + specific: make_settings(vec![ALLOWED], 1_000_000), + }, }; let context = ctx(OTHER, U256::from(100u64)); let m = EtherTransfer::analyze(&context).unwrap(); @@ -167,9 +174,11 @@ async fn evaluate_passes_when_volume_within_limit() { let grant = Grant { id: grant_id, - shared_grant_id: basic.id, - shared: shared(), - settings, + common_settings_id: basic.id, + settings: CombinedSettings { + shared: shared(), + specific: settings, + }, }; let context = ctx(ALLOWED, U256::from(100u64)); let m = EtherTransfer::analyze(&context).unwrap(); @@ -207,9 +216,11 @@ async fn evaluate_rejects_volume_over_limit() { let grant = Grant { id: grant_id, - shared_grant_id: basic.id, - shared: shared(), - settings, + common_settings_id: basic.id, + settings: CombinedSettings { + shared: shared(), + specific: settings, + }, }; let context = ctx(ALLOWED, U256::from(1u64)); let m = EtherTransfer::analyze(&context).unwrap(); @@ -248,9 +259,11 @@ async fn evaluate_passes_at_exactly_volume_limit() { let grant = Grant { id: grant_id, - shared_grant_id: basic.id, - shared: shared(), - settings, + common_settings_id: basic.id, + settings: CombinedSettings { + shared: shared(), + specific: settings, + }, }; let context = ctx(ALLOWED, U256::from(100u64)); let m = EtherTransfer::analyze(&context).unwrap(); @@ -282,8 +295,11 @@ async fn try_find_grant_roundtrip() { assert!(found.is_some()); let g = found.unwrap(); - assert_eq!(g.settings.target, vec![ALLOWED]); - assert_eq!(g.settings.limit.max_volume, U256::from(1_000_000u64)); + assert_eq!(g.settings.specific.target, vec![ALLOWED]); + assert_eq!( + g.settings.specific.limit.max_volume, + U256::from(1_000_000u64) + ); } #[tokio::test] @@ -347,7 +363,7 @@ async fn find_all_grants_excludes_revoked() { let all = EtherTransfer::find_all_grants(&mut *conn).await.unwrap(); assert_eq!(all.len(), 1); - assert_eq!(all[0].settings.target, vec![ALLOWED]); + assert_eq!(all[0].settings.specific.target, vec![ALLOWED]); } #[tokio::test] @@ -363,8 +379,11 @@ async fn find_all_grants_multiple_targets() { let all = EtherTransfer::find_all_grants(&mut *conn).await.unwrap(); assert_eq!(all.len(), 1); - assert_eq!(all[0].settings.target.len(), 2); - assert_eq!(all[0].settings.limit.max_volume, U256::from(1_000_000u64)); + assert_eq!(all[0].settings.specific.target.len(), 2); + assert_eq!( + all[0].settings.specific.limit.max_volume, + U256::from(1_000_000u64) + ); } #[tokio::test] diff --git a/server/crates/arbiter-server/src/evm/policies/token_transfers/mod.rs b/server/crates/arbiter-server/src/evm/policies/token_transfers/mod.rs index cef49d9..0a1253c 100644 --- a/server/crates/arbiter-server/src/evm/policies/token_transfers/mod.rs +++ b/server/crates/arbiter-server/src/evm/policies/token_transfers/mod.rs @@ -10,11 +10,8 @@ use diesel::dsl::{auto_type, insert_into}; use diesel::sqlite::Sqlite; use diesel::{ExpressionMethods, prelude::*}; use diesel_async::{AsyncConnection, RunQueryDsl}; +use serde::Serialize; -use crate::db::models::{ - EvmBasicGrant, EvmTokenTransferGrant, EvmTokenTransferVolumeLimit, NewEvmTokenTransferGrant, - NewEvmTokenTransferLog, NewEvmTokenTransferVolumeLimit, SqliteTimestamp, -}; use crate::db::schema::{ evm_basic_grant, evm_token_transfer_grant, evm_token_transfer_log, evm_token_transfer_volume_limit, @@ -26,6 +23,15 @@ use crate::evm::{ }, utils, }; +use crate::{ + crypto::integrity::Integrable, + db::models::{ + EvmBasicGrant, EvmTokenTransferGrant, EvmTokenTransferVolumeLimit, + NewEvmTokenTransferGrant, NewEvmTokenTransferLog, NewEvmTokenTransferVolumeLimit, + SqliteTimestamp, + }, + evm::policies::CombinedSettings, +}; use super::{DatabaseID, EvalContext, EvalViolation}; @@ -38,9 +44,9 @@ fn grant_join() -> _ { #[derive(Clone, Debug, PartialEq, Eq, Hash)] pub struct Meaning { - pub(crate) token: &'static TokenInfo, - pub(crate) to: Address, - pub(crate) value: U256, + pub token: &'static TokenInfo, + pub to: Address, + pub value: U256, } impl std::fmt::Display for Meaning { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { @@ -58,12 +64,15 @@ impl From for SpecificMeaning { } // A grant for token transfers, which can be scoped to specific target addresses and volume limits -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize)] pub struct Settings { pub token_contract: Address, pub target: Option
, pub volume_limits: Vec, } +impl Integrable for Settings { + const KIND: &'static str = "TokenTransfer"; +} impl From for SpecificGrant { fn from(val: Settings) -> SpecificGrant { SpecificGrant::TokenTransfer(val) @@ -106,13 +115,20 @@ async fn check_volume_rate_limits( ) -> QueryResult> { let mut violations = Vec::new(); - let Some(longest_window) = grant.settings.volume_limits.iter().map(|l| l.window).max() else { + let Some(longest_window) = grant + .settings + .specific + .volume_limits + .iter() + .map(|l| l.window) + .max() + else { return Ok(violations); }; let past_transfers = query_relevant_past_transfers(grant.id, longest_window, db).await?; - for limit in &grant.settings.volume_limits { + for limit in &grant.settings.specific.volume_limits { let window_start = chrono::Utc::now() - limit.window; let prospective_cumulative_volume: U256 = past_transfers .iter() @@ -158,7 +174,7 @@ impl Policy for TokenTransfer { return Ok(violations); } - if let Some(allowed) = grant.settings.target + if let Some(allowed) = grant.settings.specific.target && allowed != meaning.to { violations.push(EvalViolation::InvalidTarget { target: meaning.to }); @@ -269,9 +285,11 @@ impl Policy for TokenTransfer { Ok(Some(Grant { id: token_grant.id, - shared_grant_id: token_grant.basic_grant_id, - shared: SharedGrantSettings::try_from_model(basic_grant)?, - settings, + common_settings_id: token_grant.basic_grant_id, + settings: CombinedSettings { + shared: SharedGrantSettings::try_from_model(basic_grant)?, + specific: settings, + }, })) } @@ -369,12 +387,14 @@ impl Policy for TokenTransfer { Ok(Grant { id: specific.id, - shared_grant_id: specific.basic_grant_id, - shared: SharedGrantSettings::try_from_model(basic)?, - settings: Settings { - token_contract: Address::from(token_contract), - target, - volume_limits, + common_settings_id: specific.basic_grant_id, + settings: CombinedSettings { + shared: SharedGrantSettings::try_from_model(basic)?, + specific: Settings { + token_contract: Address::from(token_contract), + target, + volume_limits, + }, }, }) }) diff --git a/server/crates/arbiter-server/src/evm/policies/token_transfers/tests.rs b/server/crates/arbiter-server/src/evm/policies/token_transfers/tests.rs index 2f1b72f..c714afc 100644 --- a/server/crates/arbiter-server/src/evm/policies/token_transfers/tests.rs +++ b/server/crates/arbiter-server/src/evm/policies/token_transfers/tests.rs @@ -11,7 +11,10 @@ use crate::db::{ }; use crate::evm::{ abi::IERC20::transferCall, - policies::{EvalContext, EvalViolation, Grant, Policy, SharedGrantSettings, VolumeRateLimit}, + policies::{ + CombinedSettings, EvalContext, EvalViolation, Grant, Policy, SharedGrantSettings, + VolumeRateLimit, + }, utils, }; @@ -134,9 +137,11 @@ async fn evaluate_rejects_nonzero_eth_value() { let grant = Grant { id: 999, - shared_grant_id: 999, - shared: shared(), - settings: make_settings(None, None), + common_settings_id: 999, + settings: CombinedSettings { + shared: shared(), + specific: make_settings(None, None), + }, }; let calldata = transfer_calldata(RECIPIENT, U256::from(100u64)); let mut context = ctx(DAI, calldata); @@ -163,9 +168,11 @@ async fn evaluate_passes_any_recipient_when_no_restriction() { let grant = Grant { id: 999, - shared_grant_id: 999, - shared: shared(), - settings: make_settings(None, None), + common_settings_id: 999, + settings: CombinedSettings { + shared: shared(), + specific: make_settings(None, None), + }, }; let calldata = transfer_calldata(RECIPIENT, U256::from(100u64)); let context = ctx(DAI, calldata); @@ -183,9 +190,11 @@ async fn evaluate_passes_matching_restricted_recipient() { let grant = Grant { id: 999, - shared_grant_id: 999, - shared: shared(), - settings: make_settings(Some(RECIPIENT), None), + common_settings_id: 999, + settings: CombinedSettings { + shared: shared(), + specific: make_settings(Some(RECIPIENT), None), + }, }; let calldata = transfer_calldata(RECIPIENT, U256::from(100u64)); let context = ctx(DAI, calldata); @@ -203,9 +212,11 @@ async fn evaluate_rejects_wrong_restricted_recipient() { let grant = Grant { id: 999, - shared_grant_id: 999, - shared: shared(), - settings: make_settings(Some(RECIPIENT), None), + common_settings_id: 999, + settings: CombinedSettings { + shared: shared(), + specific: make_settings(Some(RECIPIENT), None), + }, }; let calldata = transfer_calldata(OTHER, U256::from(100u64)); let context = ctx(DAI, calldata); @@ -247,9 +258,11 @@ async fn evaluate_passes_volume_at_exact_limit() { let grant = Grant { id: grant_id, - shared_grant_id: basic.id, - shared: shared(), - settings, + common_settings_id: basic.id, + settings: CombinedSettings { + shared: shared(), + specific: settings, + }, }; let calldata = transfer_calldata(RECIPIENT, U256::from(100u64)); let context = ctx(DAI, calldata); @@ -290,9 +303,11 @@ async fn evaluate_rejects_volume_over_limit() { let grant = Grant { id: grant_id, - shared_grant_id: basic.id, - shared: shared(), - settings, + common_settings_id: basic.id, + settings: CombinedSettings { + shared: shared(), + specific: settings, + }, }; let calldata = transfer_calldata(RECIPIENT, U256::from(1u64)); let context = ctx(DAI, calldata); @@ -313,9 +328,11 @@ async fn evaluate_no_volume_limits_always_passes() { let grant = Grant { id: 999, - shared_grant_id: 999, - shared: shared(), - settings: make_settings(None, None), // no volume limits + common_settings_id: 999, + settings: CombinedSettings { + shared: shared(), + specific: make_settings(None, None), // no volume limits + }, }; let calldata = transfer_calldata(RECIPIENT, U256::from(u64::MAX)); let context = ctx(DAI, calldata); @@ -349,10 +366,13 @@ async fn try_find_grant_roundtrip() { assert!(found.is_some()); let g = found.unwrap(); - assert_eq!(g.settings.token_contract, DAI); - assert_eq!(g.settings.target, Some(RECIPIENT)); - assert_eq!(g.settings.volume_limits.len(), 1); - assert_eq!(g.settings.volume_limits[0].max_volume, U256::from(5_000u64)); + assert_eq!(g.settings.specific.token_contract, DAI); + assert_eq!(g.settings.specific.target, Some(RECIPIENT)); + assert_eq!(g.settings.specific.volume_limits.len(), 1); + assert_eq!( + g.settings.specific.volume_limits[0].max_volume, + U256::from(5_000u64) + ); } #[tokio::test] @@ -434,9 +454,9 @@ async fn find_all_grants_loads_volume_limits() { let all = TokenTransfer::find_all_grants(&mut *conn).await.unwrap(); assert_eq!(all.len(), 1); - assert_eq!(all[0].settings.volume_limits.len(), 1); + assert_eq!(all[0].settings.specific.volume_limits.len(), 1); assert_eq!( - all[0].settings.volume_limits[0].max_volume, + all[0].settings.specific.volume_limits[0].max_volume, U256::from(9_999u64) ); } diff --git a/server/crates/arbiter-server/src/grpc/common/outbound.rs b/server/crates/arbiter-server/src/grpc/common/outbound.rs index 6ca8916..1b500c9 100644 --- a/server/crates/arbiter-server/src/grpc/common/outbound.rs +++ b/server/crates/arbiter-server/src/grpc/common/outbound.rs @@ -108,12 +108,12 @@ impl Convert for VetError { violations: violations.into_iter().map(Convert::convert).collect(), }) } - PolicyError::Database(_) => { + PolicyError::Database(_)| PolicyError::Integrity(_) => { return EvmSignTransactionResult::Error(ProtoEvmError::Internal.into()); } }, }; - EvmSignTransactionResult::EvalError(ProtoTransactionEvalError { kind: Some(kind) }.into()) + EvmSignTransactionResult::EvalError(ProtoTransactionEvalError { kind: Some(kind) }) } } diff --git a/server/crates/arbiter-server/src/grpc/user_agent/evm.rs b/server/crates/arbiter-server/src/grpc/user_agent/evm.rs index a65b220..28725c2 100644 --- a/server/crates/arbiter-server/src/grpc/user_agent/evm.rs +++ b/server/crates/arbiter-server/src/grpc/user_agent/evm.rs @@ -26,8 +26,8 @@ use crate::{ actors::user_agent::{ UserAgentSession, session::connection::{ - HandleEvmWalletCreate, HandleEvmWalletList, HandleGrantCreate, HandleGrantDelete, - HandleGrantList, HandleSignTransaction, + GrantMutationError, HandleEvmWalletCreate, HandleEvmWalletList, HandleGrantCreate, + HandleGrantDelete, HandleGrantList, HandleSignTransaction, SignTransactionError as SessionSignTransactionError, }, }, @@ -114,10 +114,10 @@ async fn handle_grant_list( grants: grants .into_iter() .map(|grant| GrantEntry { - id: grant.id, - wallet_access_id: grant.shared.wallet_access_id, - shared: Some(grant.shared.convert()), - specific: Some(grant.settings.convert()), + id: grant.common_settings_id, + wallet_access_id: grant.settings.shared.wallet_access_id, + shared: Some(grant.settings.shared.convert()), + specific: Some(grant.settings.specific.convert()), }) .collect(), }), @@ -148,6 +148,9 @@ async fn handle_grant_create( let result = match actor.ask(HandleGrantCreate { basic, grant }).await { Ok(grant_id) => EvmGrantCreateResult::GrantId(grant_id), + Err(kameo::error::SendError::HandlerError(GrantMutationError::VaultSealed)) => { + EvmGrantCreateResult::Error(ProtoEvmError::VaultSealed.into()) + } Err(err) => { warn!(error = ?err, "Failed to create EVM grant"); EvmGrantCreateResult::Error(ProtoEvmError::Internal.into()) @@ -171,6 +174,9 @@ async fn handle_grant_delete( .await { Ok(()) => EvmGrantDeleteResult::Ok(()), + Err(kameo::error::SendError::HandlerError(GrantMutationError::VaultSealed)) => { + EvmGrantDeleteResult::Error(ProtoEvmError::VaultSealed.into()) + } Err(err) => { warn!(error = ?err, "Failed to delete EVM grant"); EvmGrantDeleteResult::Error(ProtoEvmError::Internal.into()) diff --git a/server/crates/arbiter-server/tests/user_agent/auth.rs b/server/crates/arbiter-server/tests/user_agent/auth.rs index 0006343..660fae4 100644 --- a/server/crates/arbiter-server/tests/user_agent/auth.rs +++ b/server/crates/arbiter-server/tests/user_agent/auth.rs @@ -4,8 +4,9 @@ use arbiter_server::{ GlobalActors, bootstrap::GetToken, keyholder::Bootstrap, - user_agent::{AuthPublicKey, UserAgentConnection, auth}, + user_agent::{AuthPublicKey, UserAgentConnection, UserAgentCredentials, auth}, }, + crypto::integrity, db::{self, schema}, safe_cell::{SafeCell, SafeCellHandle as _}, }; @@ -20,6 +21,13 @@ use super::common::ChannelTransport; pub async fn test_bootstrap_token_auth() { let db = db::create_test_pool().await; let actors = GlobalActors::spawn(db.clone()).await.unwrap(); + actors + .key_holder + .ask(Bootstrap { + seal_key_raw: SafeCell::new(b"test-seal-key".to_vec()), + }) + .await + .unwrap(); let token = actors.bootstrapper.ask(GetToken).await.unwrap().unwrap(); let (server_transport, mut test_transport) = ChannelTransport::new(); @@ -99,20 +107,39 @@ pub async fn test_bootstrap_invalid_token_auth() { pub async fn test_challenge_auth() { let db = db::create_test_pool().await; let actors = GlobalActors::spawn(db.clone()).await.unwrap(); + actors + .key_holder + .ask(Bootstrap { + seal_key_raw: SafeCell::new(b"test-seal-key".to_vec()), + }) + .await + .unwrap(); let new_key = ed25519_dalek::SigningKey::generate(&mut rand::rng()); let pubkey_bytes = new_key.verifying_key().to_bytes().to_vec(); { let mut conn = db.get().await.unwrap(); - insert_into(schema::useragent_client::table) + let id: i32 = insert_into(schema::useragent_client::table) .values(( schema::useragent_client::public_key.eq(pubkey_bytes.clone()), schema::useragent_client::key_type.eq(1i32), )) - .execute(&mut conn) + .returning(schema::useragent_client::id) + .get_result(&mut conn) .await .unwrap(); + integrity::sign_entity( + &mut conn, + &actors.key_holder, + &UserAgentCredentials { + pubkey: AuthPublicKey::Ed25519(new_key.verifying_key()), + nonce: 1, + }, + id, + ) + .await + .unwrap(); } let (server_transport, mut test_transport) = ChannelTransport::new(); @@ -187,7 +214,6 @@ pub async fn test_challenge_auth_rejects_integrity_tag_mismatch_when_unsealed() .values(( schema::useragent_client::public_key.eq(pubkey_bytes.clone()), schema::useragent_client::key_type.eq(1i32), - schema::useragent_client::pubkey_integrity_tag.eq(Some(vec![0u8; 32])), )) .execute(&mut conn) .await @@ -211,7 +237,7 @@ pub async fn test_challenge_auth_rejects_integrity_tag_mismatch_when_unsealed() assert!(matches!( task.await.unwrap(), - Err(auth::Error::InvalidChallengeSolution) + Err(auth::Error::Internal { .. }) )); } @@ -220,20 +246,39 @@ pub async fn test_challenge_auth_rejects_integrity_tag_mismatch_when_unsealed() pub async fn test_challenge_auth_rejects_invalid_signature() { let db = db::create_test_pool().await; let actors = GlobalActors::spawn(db.clone()).await.unwrap(); + actors + .key_holder + .ask(Bootstrap { + seal_key_raw: SafeCell::new(b"test-seal-key".to_vec()), + }) + .await + .unwrap(); let new_key = ed25519_dalek::SigningKey::generate(&mut rand::rng()); let pubkey_bytes = new_key.verifying_key().to_bytes().to_vec(); { let mut conn = db.get().await.unwrap(); - insert_into(schema::useragent_client::table) + let id: i32 = insert_into(schema::useragent_client::table) .values(( schema::useragent_client::public_key.eq(pubkey_bytes.clone()), schema::useragent_client::key_type.eq(1i32), )) - .execute(&mut conn) + .returning(schema::useragent_client::id) + .get_result(&mut conn) .await .unwrap(); + integrity::sign_entity( + &mut conn, + &actors.key_holder, + &UserAgentCredentials { + pubkey: AuthPublicKey::Ed25519(new_key.verifying_key()), + nonce: 1, + }, + id, + ) + .await + .unwrap(); } let (server_transport, mut test_transport) = ChannelTransport::new(); diff --git a/server/crates/arbiter-server/tests/user_agent/unseal.rs b/server/crates/arbiter-server/tests/user_agent/unseal.rs index 16bb257..57cd1ee 100644 --- a/server/crates/arbiter-server/tests/user_agent/unseal.rs +++ b/server/crates/arbiter-server/tests/user_agent/unseal.rs @@ -151,43 +151,4 @@ pub async fn test_unseal_retry_after_invalid_key() { let response = user_agent.ask(encrypted_key).await; assert!(matches!(response, Ok(()))); } -} - -#[tokio::test] -#[test_log::test] -pub async fn test_unseal_backfills_missing_pubkey_integrity_tags() { - let seal_key = b"test-seal-key"; - let (db, user_agent) = setup_sealed_user_agent(seal_key).await; - - { - let mut conn = db.get().await.unwrap(); - insert_into(arbiter_server::db::schema::useragent_client::table) - .values(( - arbiter_server::db::schema::useragent_client::public_key - .eq(vec![1u8, 2u8, 3u8, 4u8]), - arbiter_server::db::schema::useragent_client::key_type.eq(1i32), - arbiter_server::db::schema::useragent_client::pubkey_integrity_tag - .eq(Option::>::None), - )) - .execute(&mut conn) - .await - .unwrap(); - } - - let encrypted_key = client_dh_encrypt(&user_agent, seal_key).await; - let response = user_agent.ask(encrypted_key).await; - assert!(matches!(response, Ok(()))); - - { - let mut conn = db.get().await.unwrap(); - let tags: Vec>> = arbiter_server::db::schema::useragent_client::table - .select(arbiter_server::db::schema::useragent_client::pubkey_integrity_tag) - .load(&mut conn) - .await - .unwrap(); - assert!( - tags.iter() - .all(|tag| matches!(tag, Some(v) if v.len() == 32)) - ); - } -} +} \ No newline at end of file