From e0d3352a90ddbdeb06314e6f38d2afe6fa4ddd78 Mon Sep 17 00:00:00 2001 From: mat Date: Wed, 7 May 2025 20:50:29 +0000 Subject: add chat signing --- azalea-client/src/plugins/chat/handler.rs | 74 +++++++++--- azalea-client/src/plugins/chat_signing.rs | 182 ++++++++++++++++++++++++++++++ azalea-client/src/plugins/disconnect.rs | 6 +- azalea-client/src/plugins/mod.rs | 4 +- 4 files changed, 250 insertions(+), 16 deletions(-) create mode 100644 azalea-client/src/plugins/chat_signing.rs (limited to 'azalea-client/src/plugins') diff --git a/azalea-client/src/plugins/chat/handler.rs b/azalea-client/src/plugins/chat/handler.rs index 356d3fba..a289eb14 100644 --- a/azalea-client/src/plugins/chat/handler.rs +++ b/azalea-client/src/plugins/chat/handler.rs @@ -1,5 +1,6 @@ use std::time::{SystemTime, UNIX_EPOCH}; +use azalea_crypto::SignChatMessageOptions; use azalea_protocol::packets::{ Packet, game::{ServerboundChat, ServerboundChatCommand, s_chat::LastSeenMessagesUpdate}, @@ -7,7 +8,7 @@ use azalea_protocol::packets::{ use bevy_ecs::prelude::*; use super::ChatKind; -use crate::packet::game::SendPacketEvent; +use crate::{Account, chat_signing::ChatSigningSession, packet::game::SendPacketEvent}; /// Send a chat packet to the server of a specific kind (chat message or /// command). Usually you just want [`SendChatEvent`] instead. @@ -30,6 +31,7 @@ pub struct SendChatKindEvent { pub fn handle_send_chat_kind_event( mut events: EventReader, mut commands: Commands, + mut query: Query<(&Account, &mut ChatSigningSession)>, ) { for event in events.read() { let content = event @@ -38,22 +40,43 @@ pub fn handle_send_chat_kind_event( .filter(|c| !matches!(c, '\x00'..='\x1F' | '\x7F' | 'ยง')) .take(256) .collect::(); + + let timestamp = SystemTime::now(); + let packet = match event.kind { - ChatKind::Message => ServerboundChat { - message: content, - timestamp: SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("Time shouldn't be before epoch") - .as_millis() - .try_into() - .expect("Instant should fit into a u64"), - salt: azalea_crypto::make_salt(), - signature: None, - last_seen_messages: LastSeenMessagesUpdate::default(), + ChatKind::Message => { + let salt = azalea_crypto::make_salt(); + + let signature = if let Ok((account, mut chat_session)) = query.get_mut(event.entity) + { + Some(create_signature( + account, + &mut chat_session, + salt, + timestamp, + &content, + )) + } else { + None + }; + + ServerboundChat { + message: content, + timestamp: timestamp + .duration_since(UNIX_EPOCH) + .expect("Time shouldn't be before epoch") + .as_millis() + .try_into() + .expect("Instant should fit into a u64"), + salt, + signature, + // TODO: implement last_seen_messages + last_seen_messages: LastSeenMessagesUpdate::default(), + } } .into_variant(), ChatKind::Command => { - // TODO: chat signing + // TODO: commands that require chat signing ServerboundChatCommand { command: content }.into_variant() } }; @@ -61,3 +84,28 @@ pub fn handle_send_chat_kind_event( commands.trigger(SendPacketEvent::new(event.entity, packet)); } } + +pub fn create_signature( + account: &Account, + chat_session: &mut ChatSigningSession, + salt: u64, + timestamp: SystemTime, + message: &str, +) -> azalea_crypto::MessageSignature { + let certs = account.certs.lock(); + let certs = certs.as_ref().expect("certs shouldn't be set back to None"); + + let signature = azalea_crypto::sign_chat_message(&SignChatMessageOptions { + account_uuid: account.uuid.expect("account must have a uuid"), + chat_session_uuid: chat_session.session_id, + message_index: chat_session.messages_sent, + salt, + timestamp, + message: message.to_owned(), + private_key: certs.private_key.clone(), + }); + + chat_session.messages_sent += 1; + + signature +} diff --git a/azalea-client/src/plugins/chat_signing.rs b/azalea-client/src/plugins/chat_signing.rs new file mode 100644 index 00000000..03dd1a21 --- /dev/null +++ b/azalea-client/src/plugins/chat_signing.rs @@ -0,0 +1,182 @@ +use std::time::{Duration, Instant}; + +use azalea_auth::certs::{Certificates, FetchCertificatesError}; +use azalea_protocol::packets::game::{ + ServerboundChatSessionUpdate, + s_chat_session_update::{ProfilePublicKeyData, RemoteChatSessionData}, +}; +use bevy_app::prelude::*; +use bevy_ecs::prelude::*; +use bevy_tasks::{IoTaskPool, Task, futures_lite::future}; +use chrono::Utc; +use tracing::{debug, error}; +use uuid::Uuid; + +use super::{chat, packet::game::SendPacketEvent}; +use crate::{Account, InGameState}; + +pub struct ChatSigningPlugin; +impl Plugin for ChatSigningPlugin { + fn build(&self, app: &mut App) { + app.add_systems( + Update, + ( + request_certs_if_needed, + poll_request_certs_task, + handle_queued_certs_to_send, + ) + .chain() + .before(chat::handler::handle_send_chat_kind_event), + ); + } +} + +#[derive(Component)] +pub struct RequestCertsTask(pub Task>); + +/// A component that makes us have to wait until the given time to refresh the +/// certs. +/// +/// This is used to avoid spamming requests if requesting certs fails. Usually, +/// we just check [`Certificates::expires_at`]. +#[derive(Component)] +pub struct OnlyRefreshCertsAfter { + pub refresh_at: Instant, +} +/// A component that's present when that this client has sent its certificates +/// to the server. +/// +/// This should be removed if you want to re-send the certs. +/// +/// If you want to get the client's actual certificates, you can get that from +/// the `certs` in the [`Account`] component. +#[derive(Component)] +pub struct ChatSigningSession { + pub session_id: Uuid, + pub messages_sent: u32, +} + +pub fn poll_request_certs_task( + mut commands: Commands, + mut query: Query<(Entity, &mut RequestCertsTask, &Account)>, +) { + for (entity, mut auth_task, account) in query.iter_mut() { + if let Some(poll_res) = future::block_on(future::poll_once(&mut auth_task.0)) { + debug!("Finished requesting certs"); + commands.entity(entity).remove::(); + + match poll_res { + Ok(certs) => { + commands.entity(entity).insert(QueuedCertsToSend { + certs: certs.clone(), + }); + *account.certs.lock() = Some(certs); + } + Err(err) => { + error!("Error requesting certs: {err:?}. Retrying in an hour."); + + commands.entity(entity).insert(OnlyRefreshCertsAfter { + refresh_at: Instant::now() + Duration::from_secs(60 * 60), + }); + } + } + } + } +} + +#[allow(clippy::type_complexity)] +pub fn request_certs_if_needed( + mut commands: Commands, + mut query: Query< + ( + Entity, + &Account, + Option<&OnlyRefreshCertsAfter>, + Option<&ChatSigningSession>, + ), + (Without, With), + >, +) { + for (entity, account, only_refresh_certs_after, chat_signing_session) in query.iter_mut() { + if let Some(only_refresh_certs_after) = only_refresh_certs_after { + if only_refresh_certs_after.refresh_at > Instant::now() { + continue; + } + } + + let certs = account.certs.lock(); + let should_refresh = if let Some(certs) = &*certs { + // certs were already requested and we're waiting for them to refresh + + // but maybe they weren't sent yet, in which case we still want to send the + // certs + if chat_signing_session.is_none() { + true + } else { + Utc::now() > certs.expires_at + } + } else { + true + }; + drop(certs); + + if should_refresh { + if let Some(access_token) = &account.access_token { + let task_pool = IoTaskPool::get(); + + let access_token = access_token.lock().clone(); + debug!("Started task to fetch certs"); + let task = task_pool.spawn(async_compat::Compat::new(async move { + azalea_auth::certs::fetch_certificates(&access_token).await + })); + commands + .entity(entity) + .insert(RequestCertsTask(task)) + .remove::(); + } + } + } +} + +/// A component that's present on players that should send their chat signing +/// certificates as soon as possible. +/// +/// This is removed when the certificates get sent. +#[derive(Component)] +pub struct QueuedCertsToSend { + pub certs: Certificates, +} + +pub fn handle_queued_certs_to_send( + mut commands: Commands, + query: Query<(Entity, &QueuedCertsToSend)>, +) { + for (entity, queued_certs) in &query { + let certs = &queued_certs.certs; + + let session_id = Uuid::new_v4(); + + let chat_session = RemoteChatSessionData { + session_id, + profile_public_key: ProfilePublicKeyData { + expires_at: certs.expires_at.timestamp_millis() as u64, + key: certs.public_key_der.clone(), + key_signature: certs.signature_v2.clone(), + }, + }; + + debug!("Sending chat signing certs to server"); + + commands.trigger(SendPacketEvent::new( + entity, + ServerboundChatSessionUpdate { chat_session }, + )); + commands + .entity(entity) + .remove::() + .insert(ChatSigningSession { + session_id, + messages_sent: 0, + }); + } +} diff --git a/azalea-client/src/plugins/disconnect.rs b/azalea-client/src/plugins/disconnect.rs index 987007c2..3e27eba3 100644 --- a/azalea-client/src/plugins/disconnect.rs +++ b/azalea-client/src/plugins/disconnect.rs @@ -7,7 +7,7 @@ use bevy_ecs::prelude::*; use derive_more::Deref; use tracing::info; -use crate::{InstanceHolder, client::JoinedClientBundle, connection::RawConnection}; +use crate::{InstanceHolder, chat_signing, client::JoinedClientBundle, connection::RawConnection}; pub struct DisconnectPlugin; impl Plugin for DisconnectPlugin { @@ -70,7 +70,9 @@ pub fn remove_components_from_disconnected_players( // this makes it close the tcp connection .remove::() // this makes it not send DisconnectEvent again - .remove::(); + .remove::() + // resend our chat signing certs next time + .remove::(); // note that we don't remove the client from the ECS, so if they decide // to reconnect they'll keep their state diff --git a/azalea-client/src/plugins/mod.rs b/azalea-client/src/plugins/mod.rs index f657b9e9..dea2fdb8 100644 --- a/azalea-client/src/plugins/mod.rs +++ b/azalea-client/src/plugins/mod.rs @@ -4,6 +4,7 @@ pub mod attack; pub mod auto_reconnect; pub mod brand; pub mod chat; +pub mod chat_signing; pub mod chunks; pub mod connection; pub mod disconnect; @@ -53,7 +54,8 @@ impl PluginGroup for DefaultPlugins { .add(connection::ConnectionPlugin) .add(login::LoginPlugin) .add(join::JoinPlugin) - .add(auto_reconnect::AutoReconnectPlugin); + .add(auto_reconnect::AutoReconnectPlugin) + .add(chat_signing::ChatSigningPlugin); #[cfg(feature = "log")] { group = group.add(bevy_log::LogPlugin::default()); -- cgit v1.2.3