From 763058b0147a229f7154e3d19df67631f26d2ce7 Mon Sep 17 00:00:00 2001 From: CleverWild Date: Tue, 7 Apr 2026 21:12:36 +0200 Subject: [PATCH] feat(server): unify integrity API and propagate verified IDs through auth/EVM flows --- server/clippy.toml | 2 + .../crates/arbiter-client/src/wallets/evm.rs | 4 + .../arbiter-server/src/actors/client/auth.rs | 29 +- .../arbiter-server/src/actors/evm/mod.rs | 8 +- .../src/actors/user_agent/auth.rs | 21 +- .../src/actors/user_agent/auth/state.rs | 113 ++--- .../src/actors/user_agent/mod.rs | 2 +- .../actors/user_agent/session/connection.rs | 77 +++- .../arbiter-server/src/crypto/integrity/v1.rs | 416 ++++++++++++++++-- .../src/crypto/integrity/v1/hashing.rs | 8 +- server/crates/arbiter-server/src/evm/mod.rs | 69 ++- .../crates/arbiter-server/src/evm/policies.rs | 2 +- .../src/evm/policies/ether_transfer/mod.rs | 22 +- .../src/evm/policies/token_transfers/mod.rs | 12 +- .../arbiter-server/src/grpc/user_agent/evm.rs | 5 +- .../arbiter-server/tests/user_agent/unseal.rs | 2 - 16 files changed, 622 insertions(+), 170 deletions(-) diff --git a/server/clippy.toml b/server/clippy.toml index d0fc168..89e2823 100644 --- a/server/clippy.toml +++ b/server/clippy.toml @@ -6,4 +6,6 @@ disallowed-methods = [ { path = "rsa::RsaPrivateKey::decrypt_blinded", reason = "RSA decryption is forbidden (RUSTSEC-2023-0071 Marvin Attack). Only PSS signing/verification is permitted." }, { path = "rsa::traits::Decryptor::decrypt", reason = "RSA decryption is forbidden (RUSTSEC-2023-0071 Marvin Attack). This blocks decrypt() on rsa::{pkcs1v15,oaep}::DecryptingKey." }, { path = "rsa::traits::RandomizedDecryptor::decrypt_with_rng", reason = "RSA decryption is forbidden (RUSTSEC-2023-0071 Marvin Attack). This blocks decrypt_with_rng() on rsa::{pkcs1v15,oaep}::DecryptingKey." }, + + { path = "arbiter_server::crypto::integrity::v1::lookup_verified_allow_unavailable", reason = "This function allows integrity checks to be bypassed when vault key material is unavailable, which can lead to silent security failures if used incorrectly. It should only be used in specific contexts where this behavior is acceptable, and its use should be carefully audited." }, ] diff --git a/server/crates/arbiter-client/src/wallets/evm.rs b/server/crates/arbiter-client/src/wallets/evm.rs index 5c975c9..b4297bc 100644 --- a/server/crates/arbiter-client/src/wallets/evm.rs +++ b/server/crates/arbiter-client/src/wallets/evm.rs @@ -59,6 +59,10 @@ pub struct ArbiterEvmWallet { } impl ArbiterEvmWallet { + #[expect( + dead_code, + reason = "constructor may be used in future extensions, e.g. to support wallet listing" + )] pub(crate) fn new(transport: Arc>, address: Address) -> Self { Self { transport, diff --git a/server/crates/arbiter-server/src/actors/client/auth.rs b/server/crates/arbiter-server/src/actors/client/auth.rs index 034efd3..1973049 100644 --- a/server/crates/arbiter-server/src/actors/client/auth.rs +++ b/server/crates/arbiter-server/src/actors/client/auth.rs @@ -18,7 +18,7 @@ use crate::{ flow_coordinator::{self, RequestClientApproval}, keyholder::KeyHolder, }, - crypto::integrity::{self, AttestationStatus}, + crypto::integrity::{self}, db::{ self, models::{ProgramClientMetadata, SqliteTimestamp}, @@ -109,18 +109,16 @@ async fn verify_integrity( Error::DatabasePoolUnavailable })?; - let (id, nonce) = get_current_nonce_and_id(db, pubkey) - .await? - .ok_or_else(|| { - error!("Client not found during integrity verification"); - Error::DatabaseOperationFailed - })?; + let (id, nonce) = get_current_nonce_and_id(db, pubkey).await?.ok_or_else(|| { + error!("Client not found during integrity verification"); + Error::DatabaseOperationFailed + })?; - let attestation = integrity::verify_entity( + integrity::verify_entity( &mut db_conn, keyholder, &ClientCredentials { - pubkey: pubkey.clone(), + pubkey: *pubkey, nonce, }, id, @@ -131,11 +129,6 @@ async fn verify_integrity( Error::IntegrityCheckFailed })?; - if attestation != AttestationStatus::Attested { - error!("Integrity attestation unavailable for client {id}"); - return Err(Error::IntegrityCheckFailed); - } - Ok(()) } @@ -147,7 +140,6 @@ async fn create_nonce( pubkey: &VerifyingKey, ) -> Result { let pubkey_bytes = pubkey.as_bytes().to_vec(); - let pubkey = pubkey.clone(); let mut conn = db.get().await.map_err(|e| { error!(error = ?e, "Database pool error"); @@ -156,7 +148,6 @@ async fn create_nonce( conn.exclusive_transaction(|conn| { let keyholder = keyholder.clone(); - let pubkey = pubkey.clone(); Box::pin(async move { let (id, new_nonce): (i32, i32) = update(program_client::table) .filter(program_client::public_key.eq(&pubkey_bytes)) @@ -169,7 +160,7 @@ async fn create_nonce( conn, &keyholder, &ClientCredentials { - pubkey: pubkey.clone(), + pubkey: *pubkey, nonce: new_nonce, }, id, @@ -216,7 +207,6 @@ async fn insert_client( metadata: &ClientMetadata, ) -> Result { use crate::db::schema::{client_metadata, program_client}; - let pubkey = pubkey.clone(); let metadata = metadata.clone(); let mut conn = db.get().await.map_err(|e| { @@ -226,7 +216,6 @@ async fn insert_client( conn.exclusive_transaction(|conn| { let keyholder = keyholder.clone(); - let pubkey = pubkey.clone(); Box::pin(async move { const NONCE_START: i32 = 1; @@ -255,7 +244,7 @@ async fn insert_client( conn, &keyholder, &ClientCredentials { - pubkey: pubkey.clone(), + pubkey: *pubkey, nonce: NONCE_START, }, client_id, diff --git a/server/crates/arbiter-server/src/actors/evm/mod.rs b/server/crates/arbiter-server/src/actors/evm/mod.rs index 84326eb..45d15de 100644 --- a/server/crates/arbiter-server/src/actors/evm/mod.rs +++ b/server/crates/arbiter-server/src/actors/evm/mod.rs @@ -7,11 +7,11 @@ use kameo::{Actor, actor::ActorRef, messages}; use rand::{SeedableRng, rng, rngs::StdRng}; use crate::{ - actors::keyholder::{CreateNew, Decrypt, GetState, KeyHolder, KeyHolderState}, + actors::keyholder::{CreateNew, Decrypt, KeyHolder}, crypto::integrity, db::{ DatabaseError, DatabasePool, - models::{self, SqliteTimestamp}, + models::{self}, schema, }, evm::{ @@ -136,7 +136,7 @@ impl EvmActor { &mut self, basic: SharedGrantSettings, grant: SpecificGrant, - ) -> Result { + ) -> Result, Error> { match grant { SpecificGrant::EtherTransfer(settings) => self .engine @@ -158,7 +158,7 @@ impl EvmActor { } #[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 keyholder = self.keyholder.clone(); 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 83b0472..118cca9 100644 --- a/server/crates/arbiter-server/src/actors/user_agent/auth.rs +++ b/server/crates/arbiter-server/src/actors/user_agent/auth.rs @@ -30,17 +30,26 @@ pub enum Error { } impl Error { - fn internal(details: impl Into) -> Self { - Self::Internal { - details: details.into(), - } + #[track_caller] + pub(super) fn internal(details: impl Into, err: &impl std::fmt::Debug) -> Self { + let details = details.into(); + let caller = std::panic::Location::caller(); + error!( + caller_file = %caller.file(), + caller_line = caller.line(), + caller_column = caller.column(), + details = %details, + error = ?err, + "Internal error" + ); + + Self::Internal { details } } } impl From for Error { fn from(e: diesel::result::Error) -> Self { - error!(?e, "Database error"); - Self::internal("Database error") + Self::internal("Database error", &e) } } 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 ccc4b31..3240d4d 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,7 +1,7 @@ use arbiter_proto::transport::Bi; use diesel::{ExpressionMethods as _, OptionalExtension as _, QueryDsl, update}; use diesel_async::{AsyncConnection, RunQueryDsl}; -use kameo::{actor::ActorRef, error::SendError}; +use kameo::actor::ActorRef; use tracing::error; use super::Error; @@ -11,7 +11,7 @@ use crate::{ keyholder::KeyHolder, user_agent::{AuthPublicKey, UserAgentConnection, UserAgentCredentials, auth::Outbound}, }, - crypto::integrity::{self, AttestationStatus}, + crypto::integrity, db::{DatabasePool, schema::useragent_client}, }; @@ -48,10 +48,10 @@ 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") - })?; + let mut db_conn = db + .get() + .await + .map_err(|e| Error::internal("Database unavailable", &e))?; db_conn .exclusive_transaction(|conn| { Box::pin(async move { @@ -65,10 +65,7 @@ async fn get_current_nonce_and_id( }) .await .optional() - .map_err(|e| { - error!(error = ?e, "Database error"); - Error::internal("Database operation failed") - })? + .map_err(|e| Error::internal("Database operation failed", &e))? .ok_or_else(|| { error!(?key, "Public key not found in database"); Error::UnregisteredPublicKey @@ -80,14 +77,14 @@ async fn verify_integrity( 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 mut db_conn = db + .get() + .await + .map_err(|e| Error::internal("Database unavailable", &e))?; let (id, nonce) = get_current_nonce_and_id(db, pubkey).await?; - let result = integrity::verify_entity( + let attestation_status = integrity::check_entity_attestation( &mut db_conn, keyholder, &UserAgentCredentials { @@ -97,12 +94,17 @@ async fn verify_integrity( id, ) .await - .map_err(|e| { - error!(?e, "Integrity verification failed"); - Error::internal("Integrity verification failed") - })?; + .map_err(|e| Error::internal("Integrity verification failed", &e))?; - Ok(()) + use integrity::AttestationStatus as AS; + // SAFETY (policy): challenge auth must work in both vault states. + // While sealed, integrity checks can only report `Unavailable` because key material is not + // accessible. While unsealed, the same check can report `Attested`. + // This path intentionally accepts both outcomes to keep challenge auth available across state + // transitions; stricter verification is enforced in sensitive post-auth flows. + match attestation_status { + AS::Attested | AS::Unavailable => Ok(()), + } } async fn create_nonce( @@ -110,10 +112,10 @@ async fn create_nonce( 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 mut db_conn = db + .get() + .await + .map_err(|e| Error::internal("Database unavailable", &e))?; let new_nonce = db_conn .exclusive_transaction(|conn| { Box::pin(async move { @@ -124,10 +126,7 @@ async fn create_nonce( .returning((useragent_client::id, useragent_client::nonce)) .get_result(conn) .await - .map_err(|e| { - error!(error = ?e, "Database error"); - Error::internal("Database operation failed") - })?; + .map_err(|e| Error::internal("Database operation failed", &e))?; integrity::sign_entity( conn, @@ -139,10 +138,7 @@ async fn create_nonce( id, ) .await - .map_err(|e| { - error!(?e, "Integrity signature update failed"); - Error::internal("Database error") - })?; + .map_err(|e| Error::internal("Database error", &e))?; Result::<_, Error>::Ok(new_nonce) }) @@ -158,10 +154,10 @@ async fn register_key( ) -> Result<(), Error> { let pubkey_bytes = pubkey.to_stored_bytes(); let key_type = pubkey.key_type(); - let mut conn = db.get().await.map_err(|e| { - error!(error = ?e, "Database pool error"); - Error::internal("Database unavailable") - })?; + let mut conn = db + .get() + .await + .map_err(|e| Error::internal("Database unavailable", &e))?; conn.transaction(|conn| { Box::pin(async move { @@ -176,22 +172,32 @@ async fn register_key( .returning(useragent_client::id) .get_result(conn) .await - .map_err(|e| { - error!(error = ?e, "Database error"); - Error::internal("Database operation failed") - })?; + .map_err(|e| Error::internal("Database operation failed", &e))?; - 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") - })?; + if let Err(e) = integrity::sign_entity( + conn, + keyholder, + &UserAgentCredentials { + pubkey: pubkey.clone(), + nonce: NONCE_START, + }, + id, + ) + .await + { + match e { + integrity::Error::Keyholder( + crate::actors::keyholder::Error::NotBootstrapped, + ) => { + // IMPORTANT: bootstrap-token auth must work before the vault has a root key. + // We intentionally allow creating the DB row first and backfill envelopes + // after bootstrap/unseal to keep the bootstrap flow possible. + } + other => { + return Err(Error::internal("Failed to register public key", &other)); + } + } + } Result::<_, Error>::Ok(()) }) @@ -254,10 +260,7 @@ where token: token.clone(), }) .await - .map_err(|e| { - error!(?e, "Failed to consume bootstrap token"); - Error::internal("Failed to consume bootstrap token") - })?; + .map_err(|e| Error::internal("Failed to consume bootstrap token", &e))?; if !token_ok { error!("Invalid bootstrap token provided"); 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 2451e49..5faaa61 100644 --- a/server/crates/arbiter-server/src/actors/user_agent/mod.rs +++ b/server/crates/arbiter-server/src/actors/user_agent/mod.rs @@ -108,7 +108,7 @@ use crate::crypto::integrity::hashing::Hashable; impl Hashable for AuthPublicKey { fn hash(&self, hasher: &mut H) { - hasher.update(&self.to_stored_bytes()); + hasher.update(self.to_stored_bytes()); } } 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 3017819..7d8f340 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 @@ -10,7 +10,6 @@ use kameo::prelude::Context; use tracing::{error, info}; use x25519_dalek::{EphemeralSecret, PublicKey}; -use crate::actors::flow_coordinator::client_connect_approval::ClientApprovalAnswer; use crate::actors::keyholder::KeyHolderState; use crate::actors::user_agent::session::Error; use crate::db::models::{ @@ -18,6 +17,10 @@ use crate::db::models::{ }; use crate::evm::policies::{Grant, SpecificGrant}; use crate::safe_cell::SafeCell; +use crate::{ + actors::flow_coordinator::client_connect_approval::ClientApprovalAnswer, + crypto::integrity::{self, Verified}, +}; use crate::{ actors::{ evm::{ @@ -29,11 +32,66 @@ use crate::{ UserAgentSession, state::{UnsealContext, UserAgentEvents, UserAgentStates}, }, + user_agent::{AuthPublicKey, UserAgentCredentials}, }, + db::schema::useragent_client, safe_cell::SafeCellHandle as _, }; +fn is_vault_sealed_from_evm(err: &SendError) -> bool { + matches!( + err, + SendError::HandlerError(crate::actors::evm::Error::Keyholder( + keyholder::Error::NotBootstrapped + )) | SendError::HandlerError(crate::actors::evm::Error::Integrity( + crate::crypto::integrity::Error::Keyholder(keyholder::Error::NotBootstrapped) + )) + ) +} + impl UserAgentSession { + async fn backfill_useragent_integrity(&self) -> Result<(), Error> { + let mut conn = self.props.db.get().await?; + let keyholder = self.props.actors.key_holder.clone(); + + conn.transaction(|conn| { + Box::pin(async move { + let rows: Vec<(i32, i32, Vec, crate::db::models::KeyType)> = + useragent_client::table + .select(( + useragent_client::id, + useragent_client::nonce, + useragent_client::public_key, + useragent_client::key_type, + )) + .load(conn) + .await?; + + for (id, nonce, public_key, key_type) in rows { + let pubkey = AuthPublicKey::try_from((key_type, public_key)).map_err(|e| { + Error::internal(format!("Invalid user-agent key in db: {e}")) + })?; + + integrity::sign_entity( + conn, + &keyholder, + &UserAgentCredentials { pubkey, nonce }, + id, + ) + .await + .map_err(|e| { + Error::internal(format!("Failed to backfill user-agent integrity: {e}")) + })?; + } + + Result::<_, Error>::Ok(()) + }) + }) + .await?; + + Ok(()) + } + fn take_unseal_secret(&mut self) -> Result<(EphemeralSecret, PublicKey), Error> { let UserAgentStates::WaitingForUnsealKey(unseal_context) = self.state.state() else { error!("Received encrypted key in invalid state"); @@ -191,6 +249,7 @@ impl UserAgentSession { .await { Ok(_) => { + self.backfill_useragent_integrity().await?; info!("Successfully unsealed key with client-provided key"); self.transition(UserAgentEvents::ReceivedValidKey)?; Ok(()) @@ -252,6 +311,7 @@ impl UserAgentSession { .await { Ok(_) => { + self.backfill_useragent_integrity().await?; info!("Successfully bootstrapped vault with client-provided key"); self.transition(UserAgentEvents::ReceivedValidKey)?; Ok(()) @@ -325,12 +385,15 @@ impl UserAgentSession { #[messages] impl UserAgentSession { #[message] - pub(crate) async fn handle_grant_list(&mut self) -> Result>, Error> { + pub(crate) async fn handle_grant_list( + &mut self, + ) -> Result>, GrantMutationError> { match self.props.actors.evm.ask(UseragentListGrants {}).await { Ok(grants) => Ok(grants), + Err(err) if is_vault_sealed_from_evm(&err) => Err(GrantMutationError::VaultSealed), Err(err) => { error!(?err, "EVM grant list failed"); - Err(Error::internal("Failed to list EVM grants")) + Err(GrantMutationError::Internal) } } } @@ -340,7 +403,7 @@ impl UserAgentSession { &mut self, basic: crate::evm::policies::SharedGrantSettings, grant: crate::evm::policies::SpecificGrant, - ) -> Result { + ) -> Result, GrantMutationError> { match self .props .actors @@ -349,6 +412,7 @@ impl UserAgentSession { .await { Ok(grant_id) => Ok(grant_id), + Err(err) if is_vault_sealed_from_evm(&err) => Err(GrantMutationError::VaultSealed), Err(err) => { error!(?err, "EVM grant create failed"); Err(GrantMutationError::Internal) @@ -365,10 +429,13 @@ impl UserAgentSession { .props .actors .evm - .ask(UseragentDeleteGrant { grant_id }) + .ask(UseragentDeleteGrant { + _grant_id: grant_id, + }) .await { Ok(()) => Ok(()), + Err(err) if is_vault_sealed_from_evm(&err) => Err(GrantMutationError::VaultSealed), Err(err) => { error!(?err, "EVM grant delete failed"); Err(GrantMutationError::Internal) diff --git a/server/crates/arbiter-server/src/crypto/integrity/v1.rs b/server/crates/arbiter-server/src/crypto/integrity/v1.rs index afd8358..706d6fd 100644 --- a/server/crates/arbiter-server/src/crypto/integrity/v1.rs +++ b/server/crates/arbiter-server/src/crypto/integrity/v1.rs @@ -1,8 +1,9 @@ -use crate::{ - actors::keyholder, crypto::integrity::hashing::Hashable, safe_cell::SafeCellHandle as _, -}; -use hmac::{Hmac, Mac as _}; +use crate::actors::keyholder; +use hmac::Hmac; use sha2::Sha256; +use std::future::Future; +use std::ops::Deref; +use std::pin::Pin; use diesel::{ExpressionMethods as _, QueryDsl, dsl::insert_into, sqlite::Sqlite}; use diesel_async::{AsyncConnection, RunQueryDsl}; @@ -10,12 +11,13 @@ use kameo::{actor::ActorRef, error::SendError}; use sha2::Digest as _; pub mod hashing; +use self::hashing::Hashable; use crate::{ actors::keyholder::{KeyHolder, SignIntegrity, VerifyIntegrity}, db::{ self, - models::{IntegrityEnvelope, NewIntegrityEnvelope}, + models::{IntegrityEnvelope as IntegrityEnvelopeRow, NewIntegrityEnvelope}, schema::integrity_envelope, }, }; @@ -48,11 +50,35 @@ pub enum Error { } #[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[must_use] pub enum AttestationStatus { Attested, Unavailable, } +#[derive(Debug)] +pub struct Verified(T); + +impl AsRef for Verified { + fn as_ref(&self) -> &T { + &self.0 + } +} + +impl Verified { + pub fn into_inner(self) -> T { + self.0 + } +} + +impl Deref for Verified { + type Target = T; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + pub const CURRENT_PAYLOAD_VERSION: i32 = 1; pub const INTEGRITY_SUBKEY_TAG: &[u8] = b"arbiter/db-integrity-key/v1"; @@ -88,31 +114,95 @@ fn build_mac_input( out } -pub trait IntoId { - fn into_id(self) -> Vec; -} +#[derive(Debug, Clone)] +pub struct EntityId(Vec); -impl IntoId for i32 { - fn into_id(self) -> Vec { - self.to_be_bytes().to_vec() +impl Deref for EntityId { + type Target = [u8]; + + fn deref(&self) -> &Self::Target { + &self.0 } } -impl IntoId for &'_ [u8] { - fn into_id(self) -> Vec { - self.to_vec() +impl From for EntityId { + fn from(value: i32) -> Self { + Self(value.to_be_bytes().to_vec()) } } -pub async fn sign_entity( +impl From<&'_ [u8]> for EntityId { + fn from(bytes: &'_ [u8]) -> Self { + Self(bytes.to_vec()) + } +} + +pub async fn lookup_verified( + conn: &mut C, + keyholder: &ActorRef, + entity_id: impl Into, + load: F, +) -> Result, Error> +where + C: AsyncConnection, + E: Integrable, + F: FnOnce(&mut C) -> Fut, + Fut: Future>, +{ + let entity = load(conn).await?; + verify_entity(conn, keyholder, &entity, entity_id).await?; + Ok(Verified(entity)) +} + +pub async fn lookup_verified_allow_unavailable( + conn: &mut C, + keyholder: &ActorRef, + entity_id: impl Into, + load: F, +) -> Result, Error> +where + C: AsyncConnection, + E: Integrable+ 'static, + F: FnOnce(&mut C) -> Fut, + Fut: Future>, +{ + let entity = load(conn).await?; + match check_entity_attestation(conn, keyholder, &entity, entity_id.into()).await? { + // IMPORTANT: allow_unavailable mode must succeed with an unattested result when vault key + // material is unavailable, otherwise integrity checks can be silently bypassed while sealed. + AttestationStatus::Attested | AttestationStatus::Unavailable => Ok(Verified(entity)), + } +} + +pub async fn lookup_verified_from_query( + conn: &mut C, + keyholder: &ActorRef, + load: F, +) -> Result, Error> +where + C: AsyncConnection + Send, + E: Integrable, + Id: Into, + F: for<'a> FnOnce( + &'a mut C, + ) -> Pin< + Box> + Send + 'a>, + >, +{ + let (entity_id, entity) = load(conn).await?; + verify_entity(conn, keyholder, &entity, entity_id).await?; + Ok(Verified(entity)) +} + +pub async fn sign_entity + Clone>( conn: &mut impl AsyncConnection, keyholder: &ActorRef, entity: &E, - entity_id: impl IntoId, -) -> Result<(), Error> { - let payload_hash = payload_hash(&entity); + as_entity_id: Id, +) -> Result, Error> { + let payload_hash = payload_hash(entity); - let entity_id = entity_id.into_id(); + let entity_id = as_entity_id.clone().into(); let mac_input = build_mac_input(E::KIND, &entity_id, E::VERSION, &payload_hash); @@ -127,7 +217,7 @@ pub async fn sign_entity( insert_into(integrity_envelope::table) .values(NewIntegrityEnvelope { entity_kind: E::KIND.to_owned(), - entity_id: entity_id, + entity_id: entity_id.to_vec(), payload_version: E::VERSION, key_version, mac: mac.to_vec(), @@ -146,19 +236,19 @@ pub async fn sign_entity( .await .map_err(db::DatabaseError::from)?; - Ok(()) + Ok(Verified(as_entity_id)) } -pub async fn verify_entity( +pub async fn check_entity_attestation( conn: &mut impl AsyncConnection, keyholder: &ActorRef, entity: &E, - entity_id: impl IntoId, + entity_id: impl Into, ) -> Result { - let entity_id = entity_id.into_id(); - let envelope: IntegrityEnvelope = integrity_envelope::table + let entity_id = entity_id.into(); + let envelope: IntegrityEnvelopeRow = integrity_envelope::table .filter(integrity_envelope::entity_kind.eq(E::KIND)) - .filter(integrity_envelope::entity_id.eq(&entity_id)) + .filter(integrity_envelope::entity_id.eq(&*entity_id)) .first(conn) .await .map_err(|err| match err { @@ -176,7 +266,7 @@ pub async fn verify_entity( }); } - let payload_hash = payload_hash(&entity); + let payload_hash = payload_hash(entity); let mac_input = build_mac_input(E::KIND, &entity_id, envelope.payload_version, &payload_hash); let result = keyholder @@ -199,26 +289,56 @@ pub async fn verify_entity( } } +pub async fn verify_entity<'a, E: Integrable>( + conn: &mut impl AsyncConnection, + keyholder: &ActorRef, + entity: &'a E, + entity_id: impl Into, +) -> Result, Error> { + match check_entity_attestation::(conn, keyholder, entity, entity_id).await? { + AttestationStatus::Attested => Ok(Verified(entity)), + AttestationStatus::Unavailable => Err(Error::Keyholder(keyholder::Error::NotBootstrapped)), + } +} + +pub async fn delete_envelope( + conn: &mut impl AsyncConnection, + entity_id: impl Into, +) -> Result { + let entity_id = entity_id.into(); + + let affected = diesel::delete( + integrity_envelope::table + .filter(integrity_envelope::entity_kind.eq(E::KIND)) + .filter(integrity_envelope::entity_id.eq(&*entity_id)), + ) + .execute(conn) + .await + .map_err(db::DatabaseError::from)?; + + Ok(affected) +} + #[cfg(test)] mod tests { use diesel::{ExpressionMethods as _, QueryDsl}; use diesel_async::RunQueryDsl; use kameo::{actor::ActorRef, prelude::Spawn}; - use rand::seq::SliceRandom; use sha2::Digest; - use proptest::prelude::*; - use crate::{ actors::keyholder::{Bootstrap, KeyHolder}, db::{self, schema}, safe_cell::{SafeCell, SafeCellHandle as _}, }; - use super::{Error, Integrable, sign_entity, verify_entity}; - use super::{hashing::Hashable, payload_hash}; + use super::hashing::Hashable; + use super::{ + check_entity_attestation, AttestationStatus, Error, Integrable, lookup_verified, + lookup_verified_allow_unavailable, lookup_verified_from_query, sign_entity, verify_entity, + }; - #[derive(Clone)] + #[derive(Clone, Debug)] struct DummyEntity { payload_version: i32, payload: Vec, @@ -271,7 +391,7 @@ mod tests { .unwrap(); assert_eq!(count, 1, "envelope row must be created exactly once"); - verify_entity(&mut conn, &keyholder, &entity, ENTITY_ID) + let _ = check_entity_attestation(&mut conn, &keyholder, &entity, ENTITY_ID) .await .unwrap(); } @@ -301,7 +421,7 @@ mod tests { .await .unwrap(); - let err = verify_entity(&mut conn, &keyholder, &entity, ENTITY_ID) + let err = check_entity_attestation(&mut conn, &keyholder, &entity, ENTITY_ID) .await .unwrap_err(); assert!(matches!(err, Error::MacMismatch { .. })); @@ -329,9 +449,233 @@ mod tests { ..entity }; - let err = verify_entity(&mut conn, &keyholder, &tampered, ENTITY_ID) + let err = check_entity_attestation(&mut conn, &keyholder, &tampered, ENTITY_ID) .await .unwrap_err(); assert!(matches!(err, Error::MacMismatch { .. })); } + + #[tokio::test] + async fn allow_unavailable_lookup_passes_while_sealed() { + 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-31"; + + let entity = DummyEntity { + payload_version: 1, + payload: b"payload-v1".to_vec(), + }; + + sign_entity(&mut conn, &keyholder, &entity, ENTITY_ID) + .await + .unwrap(); + drop(keyholder); + + let sealed_keyholder = KeyHolder::spawn(KeyHolder::new(db.clone()).await.unwrap()); + let status = check_entity_attestation(&mut conn, &sealed_keyholder, &entity, ENTITY_ID) + .await + .unwrap(); + assert_eq!(status, AttestationStatus::Unavailable); + + #[expect(clippy::disallowed_methods, reason = "test only")] + lookup_verified_allow_unavailable(&mut conn, &sealed_keyholder, ENTITY_ID, |_| async { + Ok::<_, db::DatabaseError>(DummyEntity { + payload_version: 1, + payload: b"payload-v1".to_vec(), + }) + }) + .await + .unwrap(); + } + + #[tokio::test] + async fn strict_verify_fails_closed_while_sealed() { + 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-41"; + + let entity = DummyEntity { + payload_version: 1, + payload: b"payload-v1".to_vec(), + }; + + sign_entity(&mut conn, &keyholder, &entity, ENTITY_ID) + .await + .unwrap(); + drop(keyholder); + + let sealed_keyholder = KeyHolder::spawn(KeyHolder::new(db.clone()).await.unwrap()); + + let err = verify_entity(&mut conn, &sealed_keyholder, &entity, ENTITY_ID) + .await + .unwrap_err(); + assert!(matches!( + err, + Error::Keyholder(crate::actors::keyholder::Error::NotBootstrapped) + )); + + let err = lookup_verified(&mut conn, &sealed_keyholder, ENTITY_ID, |_| async { + Ok::<_, db::DatabaseError>(DummyEntity { + payload_version: 1, + payload: b"payload-v1".to_vec(), + }) + }) + .await + .unwrap_err(); + assert!(matches!( + err, + Error::Keyholder(crate::actors::keyholder::Error::NotBootstrapped) + )); + } + + #[tokio::test] + async fn lookup_verified_supports_loaded_aggregate() { + let db = db::create_test_pool().await; + let keyholder = bootstrapped_keyholder(&db).await; + let mut conn = db.get().await.unwrap(); + + const ENTITY_ID: i32 = 77; + + let entity = DummyEntity { + payload_version: 1, + payload: b"payload-v1".to_vec(), + }; + + sign_entity(&mut conn, &keyholder, &entity, ENTITY_ID) + .await + .unwrap(); + + let verified = lookup_verified(&mut conn, &keyholder, ENTITY_ID, |_| async { + Ok::<_, db::DatabaseError>(DummyEntity { + payload_version: 1, + payload: b"payload-v1".to_vec(), + }) + }) + .await + .unwrap(); + + assert_eq!(verified.payload, b"payload-v1".to_vec()); + } + + #[tokio::test] + async fn lookup_verified_allow_unavailable_works_while_sealed() { + let db = db::create_test_pool().await; + let keyholder = bootstrapped_keyholder(&db).await; + let mut conn = db.get().await.unwrap(); + + const ENTITY_ID: i32 = 78; + + let entity = DummyEntity { + payload_version: 1, + payload: b"payload-v1".to_vec(), + }; + + sign_entity(&mut conn, &keyholder, &entity, ENTITY_ID) + .await + .unwrap(); + drop(keyholder); + + let sealed_keyholder = KeyHolder::spawn(KeyHolder::new(db.clone()).await.unwrap()); + + #[expect(clippy::disallowed_methods, reason = "test only")] + lookup_verified_allow_unavailable(&mut conn, &sealed_keyholder, ENTITY_ID, |_| async { + Ok::<_, db::DatabaseError>(DummyEntity { + payload_version: 1, + payload: b"payload-v1".to_vec(), + }) + }) + .await + .unwrap(); + } + + #[tokio::test] + async fn extension_trait_lookup_verified_required_works() { + let db = db::create_test_pool().await; + let keyholder = bootstrapped_keyholder(&db).await; + let mut conn = db.get().await.unwrap(); + + const ENTITY_ID: i32 = 79; + + let entity = DummyEntity { + payload_version: 1, + payload: b"payload-v1".to_vec(), + }; + + sign_entity(&mut conn, &keyholder, &entity, ENTITY_ID) + .await + .unwrap(); + + let verified = lookup_verified(&mut conn, &keyholder, ENTITY_ID, |_| { + Box::pin(async { + Ok::<_, db::DatabaseError>(DummyEntity { + payload_version: 1, + payload: b"payload-v1".to_vec(), + }) + }) + }) + .await + .unwrap(); + + assert_eq!(verified.payload, b"payload-v1".to_vec()); + } + + #[tokio::test] + async fn lookup_verified_from_query_helpers_work() { + let db = db::create_test_pool().await; + let keyholder = bootstrapped_keyholder(&db).await; + let mut conn = db.get().await.unwrap(); + + const ENTITY_ID: i32 = 80; + + let entity = DummyEntity { + payload_version: 1, + payload: b"payload-v1".to_vec(), + }; + + sign_entity(&mut conn, &keyholder, &entity, ENTITY_ID) + .await + .unwrap(); + + let verified = lookup_verified_from_query(&mut conn, &keyholder, |_| { + Box::pin(async { + Ok::<_, db::DatabaseError>(( + ENTITY_ID, + DummyEntity { + payload_version: 1, + payload: b"payload-v1".to_vec(), + }, + )) + }) + }) + .await + .unwrap(); + + assert_eq!(verified.payload, b"payload-v1".to_vec()); + + drop(keyholder); + let sealed_keyholder = KeyHolder::spawn(KeyHolder::new(db.clone()).await.unwrap()); + + let err = lookup_verified_from_query(&mut conn, &sealed_keyholder, |_| { + Box::pin(async { + Ok::<_, db::DatabaseError>(( + ENTITY_ID, + DummyEntity { + payload_version: 1, + payload: b"payload-v1".to_vec(), + }, + )) + }) + }) + .await + .unwrap_err(); + + assert!(matches!( + err, + Error::Keyholder(crate::actors::keyholder::Error::NotBootstrapped) + )); + } } diff --git a/server/crates/arbiter-server/src/crypto/integrity/v1/hashing.rs b/server/crates/arbiter-server/src/crypto/integrity/v1/hashing.rs index d172359..ec1aa71 100644 --- a/server/crates/arbiter-server/src/crypto/integrity/v1/hashing.rs +++ b/server/crates/arbiter-server/src/crypto/integrity/v1/hashing.rs @@ -62,10 +62,10 @@ impl Hashable for Option { fn hash(&self, hasher: &mut H) { match self { Some(value) => { - hasher.update(&[1]); + hasher.update([1]); value.hash(hasher); } - None => hasher.update(&[0]), + None => hasher.update([0]), } } } @@ -96,12 +96,12 @@ impl Hashable for alloy::primitives::U256 { impl Hashable for chrono::Duration { fn hash(&self, hasher: &mut H) { - hasher.update(&self.num_seconds().to_be_bytes()); + hasher.update(self.num_seconds().to_be_bytes()); } } impl Hashable for chrono::DateTime { fn hash(&self, hasher: &mut H) { - hasher.update(&self.timestamp_millis().to_be_bytes()); + hasher.update(self.timestamp_millis().to_be_bytes()); } } diff --git a/server/crates/arbiter-server/src/evm/mod.rs b/server/crates/arbiter-server/src/evm/mod.rs index 15ac999..a6bd5a9 100644 --- a/server/crates/arbiter-server/src/evm/mod.rs +++ b/server/crates/arbiter-server/src/evm/mod.rs @@ -12,7 +12,7 @@ use kameo::actor::ActorRef; use crate::{ actors::keyholder::KeyHolder, - crypto::integrity, + crypto::integrity::{self, Verified}, db::{ self, DatabaseError, models::{ @@ -153,12 +153,36 @@ impl Engine { { let mut conn = self.db.get().await.map_err(DatabaseError::from)?; - let grant = P::try_find_grant(&context, &mut conn) + let verified_settings = + match integrity::lookup_verified_from_query(&mut conn, &self.keyholder, |conn| { + let context = context.clone(); + Box::pin(async move { + let grant = P::try_find_grant(&context, conn) + .await + .map_err(DatabaseError::from)? + .ok_or_else(|| DatabaseError::from(diesel::result::Error::NotFound))?; + + Ok::<_, DatabaseError>((grant.common_settings_id, grant.settings)) + }) + }) + .await + { + Ok(verified) => verified, + Err(integrity::Error::Database(DatabaseError::Connection( + diesel::result::Error::NotFound, + ))) => return Err(PolicyError::NoMatchingGrant), + Err(err) => return Err(PolicyError::Integrity(err)), + }; + + let mut grant = P::try_find_grant(&context, &mut conn) .await .map_err(DatabaseError::from)? .ok_or(PolicyError::NoMatchingGrant)?; - integrity::verify_entity(&mut conn, &self.keyholder, &grant.settings, grant.id).await?; + // IMPORTANT: policy evaluation uses extra non-integrity fields from Grant + // (e.g., per-policy ids), so we currently reload Grant after the query-native + // integrity check over canonicalized settings. + grant.settings = verified_settings.into_inner(); let mut violations = check_shared_constraints( &context, @@ -214,7 +238,7 @@ impl Engine { pub async fn create_grant( &self, full_grant: CombinedSettings, - ) -> Result + ) -> Result, DatabaseError> where P::Settings: Clone, { @@ -258,11 +282,12 @@ impl Engine { 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)?; + let verified_entity_id = + integrity::sign_entity(conn, &keyholder, &full_grant, basic_grant.id) + .await + .map_err(|_| diesel::result::Error::RollbackTransaction)?; - QueryResult::Ok(basic_grant.id) + QueryResult::Ok(verified_entity_id) }) }) .await?; @@ -273,7 +298,7 @@ impl Engine { async fn list_one_kind( &self, conn: &mut impl AsyncConnection, - ) -> Result>, ListError> + ) -> Result>, ListError> where Y: From, { @@ -281,16 +306,26 @@ impl Engine { .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?; + let mut verified_grants = Vec::with_capacity(all_grants.len()); + + // Verify integrity of all grants before returning any results. + for grant in all_grants { + integrity::verify_entity( + conn, + &self.keyholder, + &grant.settings, + grant.common_settings_id, + ) + .await?; + + verified_grants.push(Grant { + id: grant.id, + common_settings_id: grant.common_settings_id, + settings: grant.settings.generalize(), + }); } - Ok(all_grants.into_iter().map(|g| Grant { - id: g.id, - common_settings_id: g.common_settings_id, - settings: g.settings.generalize(), - })) + Ok(verified_grants) } pub async fn list_all_grants(&self) -> Result>, ListError> { diff --git a/server/crates/arbiter-server/src/evm/policies.rs b/server/crates/arbiter-server/src/evm/policies.rs index 2ce22e2..95707fc 100644 --- a/server/crates/arbiter-server/src/evm/policies.rs +++ b/server/crates/arbiter-server/src/evm/policies.rs @@ -200,7 +200,7 @@ pub enum SpecificGrant { TokenTransfer(token_transfers::Settings), } -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct CombinedSettings { pub shared: SharedGrantSettings, pub specific: PolicyGrant, 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 a641403..c0e4868 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 @@ -110,7 +110,8 @@ async fn check_rate_limits( let mut violations = Vec::new(); let window = grant.settings.specific.limit.window; - let past_transaction = query_relevant_past_transaction(grant.id, window, db).await?; + let past_transaction = + query_relevant_past_transaction(grant.common_settings_id, window, db).await?; let window_start = chrono::Utc::now() - grant.settings.specific.limit.window; let prospective_cumulative_volume: U256 = past_transaction @@ -249,21 +250,20 @@ impl Policy for EtherTransfer { }) .collect(); - let settings = Settings { - target: targets, - limit: VolumeRateLimit { - max_volume: utils::try_bytes_to_u256(&limit.max_volume) - .map_err(|err| diesel::result::Error::DeserializationError(Box::new(err)))?, - window: chrono::Duration::seconds(limit.window_secs as i64), - }, - }; - Ok(Some(Grant { id: grant.id, common_settings_id: grant.basic_grant_id, settings: CombinedSettings { shared: SharedGrantSettings::try_from_model(basic_grant)?, - specific: settings, + specific: Settings { + target: targets, + limit: VolumeRateLimit { + max_volume: utils::try_bytes_to_u256(&limit.max_volume).map_err(|err| { + diesel::result::Error::DeserializationError(Box::new(err)) + })?, + window: chrono::Duration::seconds(limit.window_secs as i64), + }, + }, }, })) } 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 49e03b6..73961fe 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 @@ -286,18 +286,16 @@ impl Policy for TokenTransfer { } }; - let settings = Settings { - token_contract: Address::from(token_contract), - target, - volume_limits, - }; - Ok(Some(Grant { id: token_grant.id, common_settings_id: token_grant.basic_grant_id, settings: CombinedSettings { shared: SharedGrantSettings::try_from_model(basic_grant)?, - specific: settings, + specific: Settings { + token_contract: Address::from(token_contract), + target, + volume_limits, + }, }, })) } 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 28725c2..138e28e 100644 --- a/server/crates/arbiter-server/src/grpc/user_agent/evm.rs +++ b/server/crates/arbiter-server/src/grpc/user_agent/evm.rs @@ -121,6 +121,9 @@ async fn handle_grant_list( }) .collect(), }), + Err(kameo::error::SendError::HandlerError(GrantMutationError::VaultSealed)) => { + EvmGrantListResult::Error(ProtoEvmError::VaultSealed.into()) + } Err(err) => { warn!(error = ?err, "Failed to list EVM grants"); EvmGrantListResult::Error(ProtoEvmError::Internal.into()) @@ -147,7 +150,7 @@ async fn handle_grant_create( .try_convert()?; let result = match actor.ask(HandleGrantCreate { basic, grant }).await { - Ok(grant_id) => EvmGrantCreateResult::GrantId(grant_id), + Ok(grant_id) => EvmGrantCreateResult::GrantId(grant_id.into_inner()), Err(kameo::error::SendError::HandlerError(GrantMutationError::VaultSealed)) => { EvmGrantCreateResult::Error(ProtoEvmError::VaultSealed.into()) } diff --git a/server/crates/arbiter-server/tests/user_agent/unseal.rs b/server/crates/arbiter-server/tests/user_agent/unseal.rs index 232b2e9..fcc3e50 100644 --- a/server/crates/arbiter-server/tests/user_agent/unseal.rs +++ b/server/crates/arbiter-server/tests/user_agent/unseal.rs @@ -11,8 +11,6 @@ use arbiter_server::{ safe_cell::{SafeCell, SafeCellHandle as _}, }; use chacha20poly1305::{AeadInPlace, XChaCha20Poly1305, XNonce, aead::KeyInit}; -use diesel::{ExpressionMethods as _, QueryDsl as _, insert_into}; -use diesel_async::RunQueryDsl; use kameo::actor::Spawn as _; use x25519_dalek::{EphemeralSecret, PublicKey}; -- 2.49.1