5 Commits

Author SHA1 Message Date
CleverWild
763058b014 feat(server): unify integrity API and propagate verified IDs through auth/EVM flows
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline was successful
ci/woodpecker/pr/server-test Pipeline was successful
2026-04-07 21:12:36 +02:00
hdbg
1497884ce6 fix(server::bootsrapper): token compare is now constant-time
Some checks failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/push/server-test Pipeline was successful
2026-04-06 18:33:47 +02:00
hdbg
b3464cf8a6 tests(server::client::auth): integrity envelope insertion for valid paths
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/push/server-test Pipeline was successful
2026-04-06 18:24:13 +02:00
hdbg
46d1318b6f feat(server): add integrity verification for client keys 2026-04-06 18:13:11 +02:00
9c80d51d45 Merge pull request 'fix(server): replaced postcard-based integrity fingerprint with custom trait providing order-independent hashing' (#77) from push-opwuyuwxknyo into main
Some checks failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/push/server-test Pipeline was successful
Reviewed-on: #77
2026-04-06 15:42:47 +00:00
22 changed files with 925 additions and 288 deletions

1
server/Cargo.lock generated
View File

@@ -760,6 +760,7 @@ dependencies = [
"smlang",
"spki",
"strum 0.28.0",
"subtle",
"test-log",
"thiserror 2.0.18",
"tokio",

View File

@@ -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." },
]

View File

@@ -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<Mutex<ClientTransport>>, address: Address) -> Self {
Self {
transport,

View File

@@ -60,6 +60,7 @@ arbiter-tokens-registry.path = "../arbiter-tokens-registry"
anyhow = "1.0.102"
serde_with = "3.18.0"
mutants.workspace = true
subtle = "2.6.1"
[dev-dependencies]
insta = "1.46.3"

View File

@@ -4,6 +4,7 @@ use diesel_async::RunQueryDsl;
use kameo::{Actor, messages};
use rand::{RngExt, distr::Alphanumeric, make_rng, rngs::StdRng};
use subtle::ConstantTimeEq as _;
use thiserror::Error;
use crate::db::{self, DatabasePool, schema};
@@ -44,14 +45,14 @@ pub struct Bootstrapper {
impl Bootstrapper {
pub async fn new(db: &DatabasePool) -> Result<Self, Error> {
let mut conn = db.get().await?;
let row_count: i64 = {
let mut conn = db.get().await?;
let row_count: i64 = schema::useragent_client::table
.count()
.get_result(&mut conn)
.await?;
drop(conn);
schema::useragent_client::table
.count()
.get_result(&mut conn)
.await?
};
let token = if row_count == 0 {
let token = generate_token().await?;
@@ -69,7 +70,13 @@ impl Bootstrapper {
#[message]
pub fn is_correct_token(&self, token: String) -> bool {
match &self.token {
Some(expected) => *expected == token,
Some(expected) => {
let expected_bytes = expected.as_bytes();
let token_bytes = token.as_bytes();
let choice = expected_bytes.ct_eq(token_bytes);
bool::from(choice)
}
None => false,
}
}

View File

@@ -9,14 +9,16 @@ use diesel::{
};
use diesel_async::RunQueryDsl as _;
use ed25519_dalek::{Signature, VerifyingKey};
use kameo::error::SendError;
use kameo::{actor::ActorRef, error::SendError};
use tracing::error;
use crate::{
actors::{
client::{ClientConnection, ClientProfile},
client::{ClientConnection, ClientCredentials, ClientProfile},
flow_coordinator::{self, RequestClientApproval},
keyholder::KeyHolder,
},
crypto::integrity::{self},
db::{
self,
models::{ProgramClientMetadata, SqliteTimestamp},
@@ -30,6 +32,8 @@ pub enum Error {
DatabasePoolUnavailable,
#[error("Database operation failed")]
DatabaseOperationFailed,
#[error("Integrity check failed")]
IntegrityCheckFailed,
#[error("Invalid challenge solution")]
InvalidChallengeSolution,
#[error("Client approval request failed")]
@@ -38,6 +42,13 @@ pub enum Error {
Transport,
}
impl From<diesel::result::Error> for Error {
fn from(e: diesel::result::Error) -> Self {
error!(?e, "Database error");
Self::DatabaseOperationFailed
}
}
#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
pub enum ApproveError {
#[error("Internal error")]
@@ -65,17 +76,69 @@ pub enum Outbound {
AuthSuccess,
}
pub struct ClientInfo {
pub id: i32,
pub current_nonce: i32,
}
/// Atomically reads and increments the nonce for a known client.
/// Returns the current nonce and client ID for a registered client.
/// Returns `None` if the pubkey is not registered.
async fn get_client_and_nonce(
async fn get_current_nonce_and_id(
db: &db::DatabasePool,
pubkey: &VerifyingKey,
) -> Result<Option<ClientInfo>, Error> {
) -> Result<Option<(i32, i32)>, Error> {
let pubkey_bytes = pubkey.as_bytes().to_vec();
let mut conn = db.get().await.map_err(|e| {
error!(error = ?e, "Database pool error");
Error::DatabasePoolUnavailable
})?;
program_client::table
.filter(program_client::public_key.eq(&pubkey_bytes))
.select((program_client::id, program_client::nonce))
.first::<(i32, i32)>(&mut conn)
.await
.optional()
.map_err(|e| {
error!(error = ?e, "Database error");
Error::DatabaseOperationFailed
})
}
async fn verify_integrity(
db: &db::DatabasePool,
keyholder: &ActorRef<KeyHolder>,
pubkey: &VerifyingKey,
) -> Result<(), Error> {
let mut db_conn = db.get().await.map_err(|e| {
error!(error = ?e, "Database pool error");
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
})?;
integrity::verify_entity(
&mut db_conn,
keyholder,
&ClientCredentials {
pubkey: *pubkey,
nonce,
},
id,
)
.await
.map_err(|e| {
error!(?e, "Integrity verification failed");
Error::IntegrityCheckFailed
})?;
Ok(())
}
/// Atomically increments the nonce and re-signs the integrity envelope.
/// Returns the new nonce, which is used as the challenge nonce.
async fn create_nonce(
db: &db::DatabasePool,
keyholder: &ActorRef<KeyHolder>,
pubkey: &VerifyingKey,
) -> Result<i32, Error> {
let pubkey_bytes = pubkey.as_bytes().to_vec();
let mut conn = db.get().await.map_err(|e| {
@@ -84,34 +147,34 @@ async fn get_client_and_nonce(
})?;
conn.exclusive_transaction(|conn| {
let keyholder = keyholder.clone();
Box::pin(async move {
let Some((client_id, current_nonce)) = program_client::table
let (id, new_nonce): (i32, i32) = update(program_client::table)
.filter(program_client::public_key.eq(&pubkey_bytes))
.select((program_client::id, program_client::nonce))
.first::<(i32, i32)>(conn)
.await
.optional()?
else {
return Result::<_, diesel::result::Error>::Ok(None);
};
update(program_client::table)
.filter(program_client::public_key.eq(&pubkey_bytes))
.set(program_client::nonce.eq(current_nonce + 1))
.execute(conn)
.set(program_client::nonce.eq(program_client::nonce + 1))
.returning((program_client::id, program_client::nonce))
.get_result(conn)
.await?;
Ok(Some(ClientInfo {
id: client_id,
current_nonce,
}))
integrity::sign_entity(
conn,
&keyholder,
&ClientCredentials {
pubkey: *pubkey,
nonce: new_nonce,
},
id,
)
.await
.map_err(|e| {
error!(?e, "Integrity sign failed after nonce update");
Error::DatabaseOperationFailed
})?;
Ok(new_nonce)
})
})
.await
.map_err(|e| {
error!(error = ?e, "Database error");
Error::DatabaseOperationFailed
})
}
async fn approve_new_client(
@@ -139,45 +202,63 @@ async fn approve_new_client(
async fn insert_client(
db: &db::DatabasePool,
keyholder: &ActorRef<KeyHolder>,
pubkey: &VerifyingKey,
metadata: &ClientMetadata,
) -> Result<i32, Error> {
use crate::db::schema::{client_metadata, program_client};
let metadata = metadata.clone();
let mut conn = db.get().await.map_err(|e| {
error!(error = ?e, "Database pool error");
Error::DatabasePoolUnavailable
})?;
let metadata_id = insert_into(client_metadata::table)
.values((
client_metadata::name.eq(&metadata.name),
client_metadata::description.eq(&metadata.description),
client_metadata::version.eq(&metadata.version),
))
.returning(client_metadata::id)
.get_result::<i32>(&mut conn)
.await
.map_err(|e| {
error!(error = ?e, "Failed to insert client metadata");
Error::DatabaseOperationFailed
})?;
conn.exclusive_transaction(|conn| {
let keyholder = keyholder.clone();
Box::pin(async move {
const NONCE_START: i32 = 1;
let client_id = insert_into(program_client::table)
.values((
program_client::public_key.eq(pubkey.as_bytes().to_vec()),
program_client::metadata_id.eq(metadata_id),
program_client::nonce.eq(1), // pre-incremented; challenge uses 0
))
.on_conflict_do_nothing()
.returning(program_client::id)
.get_result::<i32>(&mut conn)
.await
.map_err(|e| {
error!(error = ?e, "Failed to insert client metadata");
Error::DatabaseOperationFailed
})?;
let metadata_id = insert_into(client_metadata::table)
.values((
client_metadata::name.eq(&metadata.name),
client_metadata::description.eq(&metadata.description),
client_metadata::version.eq(&metadata.version),
))
.returning(client_metadata::id)
.get_result::<i32>(conn)
.await?;
Ok(client_id)
let client_id = insert_into(program_client::table)
.values((
program_client::public_key.eq(pubkey.as_bytes().to_vec()),
program_client::metadata_id.eq(metadata_id),
program_client::nonce.eq(NONCE_START),
))
.on_conflict_do_nothing()
.returning(program_client::id)
.get_result::<i32>(conn)
.await?;
integrity::sign_entity(
conn,
&keyholder,
&ClientCredentials {
pubkey: *pubkey,
nonce: NONCE_START,
},
client_id,
)
.await
.map_err(|e| {
error!(error = ?e, "Failed to sign integrity tag for new client key");
Error::DatabaseOperationFailed
})?;
Ok(client_id)
})
})
.await
}
async fn sync_client_metadata(
@@ -295,8 +376,11 @@ where
return Err(Error::Transport);
};
let info = match get_client_and_nonce(&props.db, &pubkey).await? {
Some(nonce) => nonce,
let client_id = match get_current_nonce_and_id(&props.db, &pubkey).await? {
Some((id, _)) => {
verify_integrity(&props.db, &props.actors.key_holder, &pubkey).await?;
id
}
None => {
approve_new_client(
&props.actors,
@@ -306,16 +390,13 @@ where
},
)
.await?;
let client_id = insert_client(&props.db, &pubkey, &metadata).await?;
ClientInfo {
id: client_id,
current_nonce: 0,
}
insert_client(&props.db, &props.actors.key_holder, &pubkey, &metadata).await?
}
};
sync_client_metadata(&props.db, info.id, &metadata).await?;
challenge_client(transport, pubkey, info.current_nonce).await?;
sync_client_metadata(&props.db, client_id, &metadata).await?;
let challenge_nonce = create_nonce(&props.db, &props.actors.key_holder, &pubkey).await?;
challenge_client(transport, pubkey, challenge_nonce).await?;
transport
.send(Ok(Outbound::AuthSuccess))
@@ -325,5 +406,5 @@ where
Error::Transport
})?;
Ok(info.id)
Ok(client_id)
}

View File

@@ -4,6 +4,7 @@ use tracing::{error, info};
use crate::{
actors::{GlobalActors, client::session::ClientSession},
crypto::integrity::{Integrable, hashing::Hashable},
db,
};
@@ -13,6 +14,22 @@ pub struct ClientProfile {
pub metadata: ClientMetadata,
}
pub struct ClientCredentials {
pub pubkey: ed25519_dalek::VerifyingKey,
pub nonce: i32,
}
impl Integrable for ClientCredentials {
const KIND: &'static str = "client_credentials";
}
impl Hashable for ClientCredentials {
fn hash<H: sha2::Digest>(&self, hasher: &mut H) {
hasher.update(self.pubkey.as_bytes());
self.nonce.hash(hasher);
}
}
pub struct ClientConnection {
pub(crate) db: db::DatabasePool,
pub(crate) actors: GlobalActors,

View File

@@ -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<i32, Error> {
) -> Result<integrity::Verified<i32>, 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();

View File

@@ -30,17 +30,26 @@ pub enum Error {
}
impl Error {
fn internal(details: impl Into<String>) -> Self {
Self::Internal {
details: details.into(),
}
#[track_caller]
pub(super) fn internal(details: impl Into<String>, 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<diesel::result::Error> for Error {
fn from(e: diesel::result::Error) -> Self {
error!(?e, "Database error");
Self::internal("Database error")
Self::internal("Database error", &e)
}
}

View File

@@ -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<KeyHolder>,
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<KeyHolder>,
pubkey: &AuthPublicKey,
) -> Result<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))?;
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");

View File

@@ -108,7 +108,7 @@ use crate::crypto::integrity::hashing::Hashable;
impl Hashable for AuthPublicKey {
fn hash<H: sha2::Digest>(&self, hasher: &mut H) {
hasher.update(&self.to_stored_bytes());
hasher.update(self.to_stored_bytes());
}
}

View File

@@ -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<M>(err: &SendError<M, crate::actors::evm::Error>) -> 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<u8>, 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<Vec<Grant<SpecificGrant>>, Error> {
pub(crate) async fn handle_grant_list(
&mut self,
) -> Result<Vec<Grant<SpecificGrant>>, 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<i32, GrantMutationError> {
) -> Result<Verified<i32>, 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)

View File

@@ -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>(T);
impl<T> AsRef<T> for Verified<T> {
fn as_ref(&self) -> &T {
&self.0
}
}
impl<T> Verified<T> {
pub fn into_inner(self) -> T {
self.0
}
}
impl<T> Deref for Verified<T> {
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<u8>;
}
#[derive(Debug, Clone)]
pub struct EntityId(Vec<u8>);
impl IntoId for i32 {
fn into_id(self) -> Vec<u8> {
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<u8> {
self.to_vec()
impl From<i32> for EntityId {
fn from(value: i32) -> Self {
Self(value.to_be_bytes().to_vec())
}
}
pub async fn sign_entity<E: Integrable>(
impl From<&'_ [u8]> for EntityId {
fn from(bytes: &'_ [u8]) -> Self {
Self(bytes.to_vec())
}
}
pub async fn lookup_verified<E, C, F, Fut>(
conn: &mut C,
keyholder: &ActorRef<KeyHolder>,
entity_id: impl Into<EntityId>,
load: F,
) -> Result<Verified<E>, Error>
where
C: AsyncConnection<Backend = Sqlite>,
E: Integrable,
F: FnOnce(&mut C) -> Fut,
Fut: Future<Output = Result<E, db::DatabaseError>>,
{
let entity = load(conn).await?;
verify_entity(conn, keyholder, &entity, entity_id).await?;
Ok(Verified(entity))
}
pub async fn lookup_verified_allow_unavailable<E, C, F, Fut>(
conn: &mut C,
keyholder: &ActorRef<KeyHolder>,
entity_id: impl Into<EntityId>,
load: F,
) -> Result<Verified<E>, Error>
where
C: AsyncConnection<Backend = Sqlite>,
E: Integrable+ 'static,
F: FnOnce(&mut C) -> Fut,
Fut: Future<Output = Result<E, db::DatabaseError>>,
{
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<E, Id, C, F>(
conn: &mut C,
keyholder: &ActorRef<KeyHolder>,
load: F,
) -> Result<Verified<E>, Error>
where
C: AsyncConnection<Backend = Sqlite> + Send,
E: Integrable,
Id: Into<EntityId>,
F: for<'a> FnOnce(
&'a mut C,
) -> Pin<
Box<dyn Future<Output = Result<(Id, E), db::DatabaseError>> + 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<E: Integrable, Id: Into<EntityId> + Clone>(
conn: &mut impl AsyncConnection<Backend = Sqlite>,
keyholder: &ActorRef<KeyHolder>,
entity: &E,
entity_id: impl IntoId,
) -> Result<(), Error> {
let payload_hash = payload_hash(&entity);
as_entity_id: Id,
) -> Result<Verified<Id>, 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<E: Integrable>(
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<E: Integrable>(
.await
.map_err(db::DatabaseError::from)?;
Ok(())
Ok(Verified(as_entity_id))
}
pub async fn verify_entity<E: Integrable>(
pub async fn check_entity_attestation<E: Integrable>(
conn: &mut impl AsyncConnection<Backend = Sqlite>,
keyholder: &ActorRef<KeyHolder>,
entity: &E,
entity_id: impl IntoId,
entity_id: impl Into<EntityId>,
) -> Result<AttestationStatus, Error> {
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<E: Integrable>(
});
}
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<E: Integrable>(
}
}
pub async fn verify_entity<'a, E: Integrable>(
conn: &mut impl AsyncConnection<Backend = Sqlite>,
keyholder: &ActorRef<KeyHolder>,
entity: &'a E,
entity_id: impl Into<EntityId>,
) -> Result<Verified<&'a E>, Error> {
match check_entity_attestation::<E>(conn, keyholder, entity, entity_id).await? {
AttestationStatus::Attested => Ok(Verified(entity)),
AttestationStatus::Unavailable => Err(Error::Keyholder(keyholder::Error::NotBootstrapped)),
}
}
pub async fn delete_envelope<E: Integrable>(
conn: &mut impl AsyncConnection<Backend = Sqlite>,
entity_id: impl Into<EntityId>,
) -> Result<usize, Error> {
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<u8>,
@@ -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)
));
}
}

View File

@@ -62,10 +62,10 @@ impl<T: Hashable> Hashable for Option<T> {
fn hash<H: Digest>(&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<H: Digest>(&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<chrono::Utc> {
fn hash<H: Digest>(&self, hasher: &mut H) {
hasher.update(&self.timestamp_millis().to_be_bytes());
hasher.update(self.timestamp_millis().to_be_bytes());
}
}

View File

@@ -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<P: Policy>(
&self,
full_grant: CombinedSettings<P::Settings>,
) -> Result<i32, DatabaseError>
) -> Result<Verified<i32>, 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<Kind: Policy, Y>(
&self,
conn: &mut impl AsyncConnection<Backend = Sqlite>,
) -> Result<impl Iterator<Item = Grant<Y>>, ListError>
) -> Result<Vec<Grant<Y>>, ListError>
where
Y: From<Kind::Settings>,
{
@@ -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<Vec<Grant<SpecificGrant>>, ListError> {

View File

@@ -200,7 +200,7 @@ pub enum SpecificGrant {
TokenTransfer(token_transfers::Settings),
}
#[derive(Debug)]
#[derive(Debug, Clone)]
pub struct CombinedSettings<PolicyGrant> {
pub shared: SharedGrantSettings,
pub specific: PolicyGrant,

View File

@@ -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),
},
},
},
}))
}

View File

@@ -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,
},
},
}))
}

View File

@@ -68,6 +68,7 @@ impl<'a> AuthTransportAdapter<'a> {
auth::Error::ApproveError(auth::ApproveError::Internal)
| auth::Error::DatabasePoolUnavailable
| auth::Error::DatabaseOperationFailed
| auth::Error::IntegrityCheckFailed
| auth::Error::Transport => ProtoAuthResult::Internal,
}
.into(),

View File

@@ -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())
}

View File

@@ -1,9 +1,14 @@
use arbiter_proto::ClientMetadata;
use arbiter_proto::transport::{Receiver, Sender};
use arbiter_server::actors::GlobalActors;
use arbiter_server::{
actors::client::{ClientConnection, auth, connect_client},
db,
actors::{
GlobalActors,
client::{ClientConnection, ClientCredentials, auth, connect_client},
keyholder::Bootstrap,
},
crypto::integrity,
db::{self, schema},
safe_cell::{SafeCell, SafeCellHandle as _},
};
use diesel::{ExpressionMethods as _, NullableExpressionMethods as _, QueryDsl as _, insert_into};
use diesel_async::RunQueryDsl;
@@ -21,7 +26,8 @@ fn metadata(name: &str, description: Option<&str>, version: Option<&str>) -> Cli
async fn insert_registered_client(
db: &db::DatabasePool,
pubkey: Vec<u8>,
actors: &GlobalActors,
pubkey: ed25519_dalek::VerifyingKey,
metadata: &ClientMetadata,
) {
use arbiter_server::db::schema::{client_metadata, program_client};
@@ -37,23 +43,64 @@ async fn insert_registered_client(
.get_result(&mut conn)
.await
.unwrap();
insert_into(program_client::table)
let client_id: i32 = insert_into(program_client::table)
.values((
program_client::public_key.eq(pubkey),
program_client::public_key.eq(pubkey.to_bytes().to_vec()),
program_client::metadata_id.eq(metadata_id),
))
.returning(program_client::id)
.get_result(&mut conn)
.await
.unwrap();
integrity::sign_entity(
&mut conn,
&actors.key_holder,
&ClientCredentials { pubkey, nonce: 1 },
client_id,
)
.await
.unwrap();
}
async fn insert_bootstrap_sentinel_useragent(db: &db::DatabasePool) {
let mut conn = db.get().await.unwrap();
let sentinel_key = ed25519_dalek::SigningKey::generate(&mut rand::rng())
.verifying_key()
.to_bytes()
.to_vec();
insert_into(schema::useragent_client::table)
.values((
schema::useragent_client::public_key.eq(sentinel_key),
schema::useragent_client::key_type.eq(1i32),
))
.execute(&mut conn)
.await
.unwrap();
}
async fn spawn_test_actors(db: &db::DatabasePool) -> GlobalActors {
insert_bootstrap_sentinel_useragent(db).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();
actors
}
#[tokio::test]
#[test_log::test]
pub async fn test_unregistered_pubkey_rejected() {
let db = db::create_test_pool().await;
let (server_transport, mut test_transport) = ChannelTransport::new();
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
let actors = spawn_test_actors(&db).await;
let props = ClientConnection::new(db.clone(), actors);
let task = tokio::spawn(async move {
let mut server_transport = server_transport;
@@ -78,20 +125,19 @@ pub async fn test_unregistered_pubkey_rejected() {
#[test_log::test]
pub async fn test_challenge_auth() {
let db = db::create_test_pool().await;
let actors = spawn_test_actors(&db).await;
let new_key = ed25519_dalek::SigningKey::generate(&mut rand::rng());
let pubkey_bytes = new_key.verifying_key().to_bytes().to_vec();
insert_registered_client(
&db,
pubkey_bytes.clone(),
&actors,
new_key.verifying_key(),
&metadata("client", Some("desc"), Some("1.0.0")),
)
.await;
let (server_transport, mut test_transport) = ChannelTransport::new();
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
let props = ClientConnection::new(db.clone(), actors);
let task = tokio::spawn(async move {
let mut server_transport = server_transport;
@@ -147,34 +193,13 @@ pub async fn test_challenge_auth() {
#[test_log::test]
pub async fn test_metadata_unchanged_does_not_append_history() {
let db = db::create_test_pool().await;
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
let props = ClientConnection::new(db.clone(), actors);
let actors = spawn_test_actors(&db).await;
let new_key = ed25519_dalek::SigningKey::generate(&mut rand::rng());
let requested = metadata("client", Some("desc"), Some("1.0.0"));
{
use arbiter_server::db::schema::{client_metadata, program_client};
let mut conn = db.get().await.unwrap();
let metadata_id: i32 = insert_into(client_metadata::table)
.values((
client_metadata::name.eq(&requested.name),
client_metadata::description.eq(&requested.description),
client_metadata::version.eq(&requested.version),
))
.returning(client_metadata::id)
.get_result(&mut conn)
.await
.unwrap();
insert_into(program_client::table)
.values((
program_client::public_key.eq(new_key.verifying_key().to_bytes().to_vec()),
program_client::metadata_id.eq(metadata_id),
))
.execute(&mut conn)
.await
.unwrap();
}
insert_registered_client(&db, &actors, new_key.verifying_key(), &requested).await;
let props = ClientConnection::new(db.clone(), actors);
let (server_transport, mut test_transport) = ChannelTransport::new();
let task = tokio::spawn(async move {
@@ -225,33 +250,18 @@ pub async fn test_metadata_unchanged_does_not_append_history() {
#[test_log::test]
pub async fn test_metadata_change_appends_history_and_repoints_binding() {
let db = db::create_test_pool().await;
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
let props = ClientConnection::new(db.clone(), actors);
let actors = spawn_test_actors(&db).await;
let new_key = ed25519_dalek::SigningKey::generate(&mut rand::rng());
{
use arbiter_server::db::schema::{client_metadata, program_client};
let mut conn = db.get().await.unwrap();
let metadata_id: i32 = insert_into(client_metadata::table)
.values((
client_metadata::name.eq("client"),
client_metadata::description.eq(Some("old")),
client_metadata::version.eq(Some("1.0.0")),
))
.returning(client_metadata::id)
.get_result(&mut conn)
.await
.unwrap();
insert_into(program_client::table)
.values((
program_client::public_key.eq(new_key.verifying_key().to_bytes().to_vec()),
program_client::metadata_id.eq(metadata_id),
))
.execute(&mut conn)
.await
.unwrap();
}
insert_registered_client(
&db,
&actors,
new_key.verifying_key(),
&metadata("client", Some("old"), Some("1.0.0")),
)
.await;
let props = ClientConnection::new(db.clone(), actors);
let (server_transport, mut test_transport) = ChannelTransport::new();
let task = tokio::spawn(async move {
@@ -322,3 +332,59 @@ pub async fn test_metadata_change_appends_history_and_repoints_binding() {
);
}
}
#[tokio::test]
#[test_log::test]
pub async fn test_challenge_auth_rejects_integrity_tag_mismatch() {
let db = db::create_test_pool().await;
let actors = spawn_test_actors(&db).await;
let new_key = ed25519_dalek::SigningKey::generate(&mut rand::rng());
let requested = metadata("client", Some("desc"), Some("1.0.0"));
{
use arbiter_server::db::schema::{client_metadata, program_client};
let mut conn = db.get().await.unwrap();
let metadata_id: i32 = insert_into(client_metadata::table)
.values((
client_metadata::name.eq(&requested.name),
client_metadata::description.eq(&requested.description),
client_metadata::version.eq(&requested.version),
))
.returning(client_metadata::id)
.get_result(&mut conn)
.await
.unwrap();
insert_into(program_client::table)
.values((
program_client::public_key.eq(new_key.verifying_key().to_bytes().to_vec()),
program_client::metadata_id.eq(metadata_id),
))
.execute(&mut conn)
.await
.unwrap();
}
let (server_transport, mut test_transport) = ChannelTransport::new();
let props = ClientConnection::new(db.clone(), actors);
let task = tokio::spawn(async move {
let mut server_transport = server_transport;
connect_client(props, &mut server_transport).await;
});
test_transport
.send(auth::Inbound::AuthChallengeRequest {
pubkey: new_key.verifying_key(),
metadata: requested,
})
.await
.unwrap();
let response = test_transport
.recv()
.await
.expect("should receive auth rejection");
assert!(matches!(response, Err(auth::Error::IntegrityCheckFailed)));
task.await.unwrap();
}

View File

@@ -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};