feat(server): add integrity verification for client keys

This commit is contained in:
hdbg
2026-04-06 17:53:06 +02:00
parent 9c80d51d45
commit 46d1318b6f
3 changed files with 179 additions and 69 deletions

View File

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

View File

@@ -4,6 +4,7 @@ use tracing::{error, info};
use crate::{ use crate::{
actors::{GlobalActors, client::session::ClientSession}, actors::{GlobalActors, client::session::ClientSession},
crypto::integrity::{Integrable, hashing::Hashable},
db, db,
}; };
@@ -13,6 +14,22 @@ pub struct ClientProfile {
pub metadata: ClientMetadata, 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 struct ClientConnection {
pub(crate) db: db::DatabasePool, pub(crate) db: db::DatabasePool,
pub(crate) actors: GlobalActors, pub(crate) actors: GlobalActors,

View File

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