1pub mod message_chain;
7mod message_validator;
8pub mod profile_key;
9mod signature_cache;
10mod spam_throttler;
11
12pub use message_validator::LastSeenMessagesValidator;
13pub use signature_cache::{LastSeen, MessageCache};
14
15use std::sync::Arc;
16use std::time::{Duration, SystemTime, UNIX_EPOCH};
17
18use steel_crypto::{SignatureValidator, public_key_from_bytes, signature::NoValidation};
19use steel_protocol::packets::game::{
20 CPlayerChat, CPlayerInfoUpdate, CSystemChat, ChatTypeBound, FilterType, SChat, SChatAck,
21 SChatSessionUpdate,
22};
23use steel_registry::{RegistryEntry, vanilla_chat_types};
24use steel_utils::translations;
25use text_components::Modifier;
26use text_components::TextComponent;
27use text_components::interactivity::{ClickEvent, HoverEvent};
28
29use crate::entity::Entity;
30use crate::player::Player;
31use message_chain::SignedMessageChain;
32use profile_key::RemoteChatSession;
33use spam_throttler::TickThrottler;
34
35pub struct ChatState {
41 pub messages_sent: i32,
43 pub messages_received: i32,
45 pub signature_cache: MessageCache,
47 pub message_validator: LastSeenMessagesValidator,
49 pub chat_session: Option<RemoteChatSession>,
51 pub message_chain: Option<SignedMessageChain>,
53 chat_spam_throttler: TickThrottler,
54 command_spam_throttler: TickThrottler,
55}
56
57impl ChatState {
58 #[must_use]
60 pub fn new(chat_spam_threshold_seconds: i32, command_spam_threshold_seconds: i32) -> Self {
61 Self {
62 messages_sent: 0,
63 messages_received: 0,
64 signature_cache: MessageCache::new(),
65 message_validator: LastSeenMessagesValidator::new(),
66 chat_session: None,
67 message_chain: None,
68 chat_spam_throttler: TickThrottler::new(
69 20,
70 chat_spam_threshold_seconds.wrapping_mul(20),
71 ),
72 command_spam_throttler: TickThrottler::new(
73 20,
74 command_spam_threshold_seconds.wrapping_mul(20),
75 ),
76 }
77 }
78}
79
80impl Player {
81 pub fn tick_spam_throttlers(&self) {
83 let mut chat = self.chat.lock();
84 chat.chat_spam_throttler.tick();
85 chat.command_spam_throttler.tick();
86 }
87
88 const fn should_disconnect_for_rate_spam(
89 throttler: &mut TickThrottler,
90 is_operator: bool,
91 ) -> bool {
92 throttler.increment();
93 !throttler.is_under_threshold() && !is_operator
95 }
96
97 pub fn detect_command_rate_spam(&self) {
99 let is_operator = self.is_operator();
100 let should_disconnect = {
101 let mut chat = self.chat.lock();
102 Self::should_disconnect_for_rate_spam(&mut chat.command_spam_throttler, is_operator)
103 };
104
105 if should_disconnect {
106 self.disconnect(translations::DISCONNECT_SPAM.msg());
107 }
108 }
109
110 fn detect_chat_rate_spam(&self) {
111 let is_operator = self.is_operator();
112 let should_disconnect = {
113 let mut chat = self.chat.lock();
114 Self::should_disconnect_for_rate_spam(&mut chat.chat_spam_throttler, is_operator)
115 };
116
117 if should_disconnect {
118 self.disconnect(translations::DISCONNECT_SPAM.msg());
119 }
120 }
121
122 pub fn get_and_increment_messages_received(&self) -> i32 {
124 let mut chat = self.chat.lock();
125 let val = chat.messages_received;
126 chat.messages_received += 1;
127 val
128 }
129
130 fn verify_chat_signature(
131 &self,
132 packet: &SChat,
133 ) -> Result<(message_chain::SignedMessageLink, LastSeen), String> {
134 const MESSAGE_EXPIRES_AFTER: Duration = Duration::from_mins(5);
135
136 let mut chat = self.chat.lock();
137 let session = chat.chat_session.clone().ok_or("No chat session")?;
138 let signature = packet.signature.as_ref().ok_or("No signature present")?;
139
140 if session
141 .profile_public_key
142 .data()
143 .has_expired_with_grace(profile_key::EXPIRY_GRACE_PERIOD)
144 {
145 return Err("Profile key has expired".to_string());
146 }
147
148 let chain = chat.message_chain.as_mut().ok_or("No message chain")?;
149
150 if chain.is_broken() {
151 return Err("Message chain is broken".to_string());
152 }
153
154 let timestamp =
155 UNIX_EPOCH + Duration::from_millis(packet.timestamp.try_into().unwrap_or(0));
156
157 let now = SystemTime::now();
158 let message_age = now
159 .duration_since(timestamp)
160 .unwrap_or(Duration::from_secs(0));
161
162 if message_age > MESSAGE_EXPIRES_AFTER {
163 return Err(format!(
164 "Message expired (age: {}s, max: 300s)",
165 message_age.as_secs()
166 ));
167 }
168
169 let last_seen_signatures = chat
170 .message_validator
171 .apply_update(packet.acknowledged, packet.offset, packet.checksum)
172 .map_err(|e| {
173 log::error!("Message acknowledgment validation failed: {e}");
174 e
175 })?;
176
177 let last_seen = LastSeen::new(last_seen_signatures);
178
179 let body = message_chain::SignedMessageBody::new(
180 packet.message.clone(),
181 timestamp,
182 packet.salt,
183 last_seen,
184 );
185
186 let chain = chat.message_chain.as_mut().ok_or("No message chain")?;
187 let link = chain
188 .validate_and_advance(&body)
189 .map_err(|e| format!("Chain validation failed: {e}"))?;
190
191 let updater = message_chain::MessageSignatureUpdater::new(&link, &body);
192 let validator = session.profile_public_key.create_signature_validator();
193
194 let is_valid = SignatureValidator::validate(&validator, &updater, signature)
195 .map_err(|e| format!("Signature validation error: {e}"))?;
196
197 if is_valid {
198 Ok((link, body.last_seen.clone()))
199 } else {
200 Err("Invalid signature".to_string())
201 }
202 }
203
204 pub fn handle_chat(&self, packet: SChat, player: Arc<Player>) {
206 let chat_message = packet.message.clone();
207
208 let verification_result = if let Some(_signature) = &packet.signature {
209 match self.verify_chat_signature(&packet) {
210 Ok((link, last_seen)) => Some(Ok((link, last_seen))),
211 Err(err) => {
212 log::warn!(
213 "Player {} sent message with invalid signature: {err}",
214 self.gameprofile.name
215 );
216 Some(Err(err))
217 }
218 }
219 } else {
220 None
221 };
222
223 if self.config.enforce_secure_chat {
224 match &verification_result {
225 Some(Ok(_)) => {}
226 Some(Err(err)) => {
227 self.disconnect(format!("Chat message validation failed: {err}"));
228 return;
229 }
230 None => {
231 self.disconnect(
232 "Secure chat is enforced on this server, but your message was not signed",
233 );
234 return;
235 }
236 }
237 }
238
239 let signature = if matches!(verification_result, Some(Ok(_))) {
240 packet.signature.map(|sig| Box::new(sig) as Box<[u8]>)
241 } else {
242 None
243 };
244
245 let sender_index = {
246 let mut chat = player.chat.lock();
247 let idx = chat.messages_sent;
248 chat.messages_sent += 1;
249 idx
250 };
251
252 let registry_id = vanilla_chat_types::CHAT.id() as i32;
253
254 let chat_packet = CPlayerChat::new(
255 0,
256 player.gameprofile.id,
257 sender_index,
258 signature.clone(),
259 chat_message.clone(),
260 packet.timestamp,
261 packet.salt,
262 Box::new([]),
263 Some(TextComponent::plain(chat_message.clone())),
264 FilterType::PassThrough,
265 ChatTypeBound {
266 registry_id,
267 sender_name: TextComponent::plain(player.gameprofile.name.clone())
268 .insertion(player.gameprofile.name.clone())
269 .click_event(ClickEvent::suggest_command(format!(
270 "/tell {} ",
271 player.gameprofile.name
272 )))
273 .hover_event(HoverEvent::show_entity(
274 "minecraft:player",
275 self.uuid(),
276 Some(player.gameprofile.name.clone()),
277 )),
278 target_name: None,
279 },
280 );
281
282 steel_utils::chat!(player.gameprofile.name.clone(), "{}", chat_message);
283 if let Some(sig_box) = &signature
284 && sig_box.len() == 256
285 {
286 let mut sig_array = [0u8; 256];
287 sig_array.copy_from_slice(&sig_box[..]);
288
289 let last_seen = if let Some(Ok((_, ref last_seen))) = verification_result {
290 last_seen.clone()
291 } else {
292 LastSeen::default()
293 };
294
295 for world in self.server().worlds.values() {
296 world.broadcast_chat(
297 chat_packet.clone(),
298 Arc::clone(&player),
299 last_seen.clone(),
300 Some(&sig_array),
301 );
302 }
303 } else {
304 for world in self.server().worlds.values() {
305 world.broadcast_unsigned_chat(chat_packet.clone());
306 }
307 }
308
309 self.detect_chat_rate_spam();
310 }
311
312 pub fn send_message(&self, text: &TextComponent) {
314 self.send_packet(CSystemChat::new(text, false, self));
315 }
316
317 pub fn set_chat_session(&self, session: RemoteChatSession) {
321 let chain = SignedMessageChain::new(self.gameprofile.id, session.session_id);
322
323 let session_data = session.as_data();
324 let protocol_data = match session_data.to_protocol_data() {
325 Ok(data) => data,
326 Err(err) => {
327 log::error!(
328 "Failed to convert chat session to protocol data for {}: {:?}",
329 self.gameprofile.name,
330 err
331 );
332 let mut chat = self.chat.lock();
333 chat.chat_session = Some(session);
334 chat.message_chain = Some(chain);
335 return;
336 }
337 };
338
339 {
340 let mut chat = self.chat.lock();
341 chat.chat_session = Some(session);
342 chat.message_chain = Some(chain);
343 }
344
345 log::info!(
346 "Player {} initialized signed chat session",
347 self.gameprofile.name
348 );
349
350 let update_packet =
351 CPlayerInfoUpdate::update_chat_session(self.gameprofile.id, protocol_data);
352 self.server().broadcast_to_online(update_packet);
353 }
354
355 pub fn chat_session(&self) -> Option<RemoteChatSession> {
357 self.chat.lock().chat_session.clone()
358 }
359
360 pub fn has_chat_session(&self) -> bool {
362 self.chat.lock().chat_session.is_some()
363 }
364
365 pub fn handle_chat_session_update(&self, packet: SChatSessionUpdate) {
369 log::info!("Player {} sent chat session update", self.gameprofile.name);
370
371 let expires_at = UNIX_EPOCH + Duration::from_millis(packet.expires_at as u64);
372
373 let public_key = match public_key_from_bytes(&packet.public_key) {
374 Ok(key) => key,
375 Err(err) => {
376 log::warn!(
377 "Player {} sent invalid public key: {err}",
378 self.gameprofile.name
379 );
380 if self.config.enforce_secure_chat {
381 log::error!(
382 "Player {} kicked for invalid public key",
383 self.gameprofile.name
384 );
385 self.disconnect("Invalid profile public key");
386 }
387 return;
388 }
389 };
390
391 let profile_key_data =
392 profile_key::ProfilePublicKeyData::new(expires_at, public_key, packet.key_signature);
393
394 let validator = Box::new(NoValidation) as Box<dyn SignatureValidator>;
395
396 let session_data = profile_key::RemoteChatSessionData {
397 session_id: packet.session_id,
398 profile_public_key: profile_key_data,
399 };
400
401 match session_data.validate(self.gameprofile.id, &*validator) {
402 Ok(session) => {
403 self.set_chat_session(session);
404 }
405 Err(err) => {
406 log::warn!(
407 "Player {} sent invalid chat session: {err}",
408 self.gameprofile.name
409 );
410 if self.config.enforce_secure_chat {
411 self.disconnect(format!("Chat session validation failed: {err}"));
412 }
413 }
414 }
415 }
416
417 pub fn handle_chat_ack(&self, packet: SChatAck) {
419 if let Err(err) = self
420 .chat
421 .lock()
422 .message_validator
423 .apply_offset(packet.offset.0)
424 {
425 log::warn!(
426 "Player {} sent invalid chat acknowledgment: {err}",
427 self.gameprofile.name
428 );
429 }
430 }
431}
432
433#[cfg(test)]
434mod tests {
435 use super::{ChatState, Player};
436
437 #[test]
438 fn operators_are_exempt_from_both_spam_disconnects() {
439 let mut chat = ChatState::new(1, 1);
440
441 assert!(!Player::should_disconnect_for_rate_spam(
442 &mut chat.command_spam_throttler,
443 true,
444 ));
445 assert!(!Player::should_disconnect_for_rate_spam(
446 &mut chat.chat_spam_throttler,
447 true,
448 ));
449 }
450
451 #[test]
452 fn non_operators_still_trigger_both_spam_disconnects() {
453 let mut chat = ChatState::new(1, 1);
454
455 assert!(Player::should_disconnect_for_rate_spam(
456 &mut chat.command_spam_throttler,
457 false,
458 ));
459 assert!(Player::should_disconnect_for_rate_spam(
460 &mut chat.chat_spam_throttler,
461 false,
462 ));
463 }
464}