1pub mod message_chain;
7mod message_validator;
8pub mod profile_key;
9mod signature_cache;
10
11pub use message_validator::LastSeenMessagesValidator;
12pub use signature_cache::{LastSeen, MessageCache};
13
14use std::sync::Arc;
15use std::time::{Duration, SystemTime, UNIX_EPOCH};
16
17use steel_crypto::{SignatureValidator, public_key_from_bytes};
18use steel_protocol::packets::game::{
19 CPlayerChat, CPlayerInfoUpdate, CSystemChat, ChatTypeBound, FilterType, SChat, SChatAck,
20 SChatSessionUpdate,
21};
22use steel_registry::{RegistryEntry, vanilla_chat_types};
23use steel_utils::translations;
24use text_components::Modifier;
25use text_components::TextComponent;
26use text_components::format::Color;
27use text_components::interactivity::{ClickEvent, HoverEvent};
28
29use crate::entity::Entity;
30use crate::player::Player;
31use crate::player::spam_throttler::TickThrottler;
32use message_chain::SignedMessageChain;
33use profile_key::RemoteChatSession;
34
35const MESSAGE_EXPIRES_AFTER_SERVER: Duration = Duration::from_mins(5);
37
38pub struct ChatState {
44 pub messages_sent: i32,
46 pub messages_received: i32,
48 pub signature_cache: MessageCache,
50 pub message_validator: LastSeenMessagesValidator,
52 pub chat_session: Option<RemoteChatSession>,
54 pub message_chain: Option<SignedMessageChain>,
56 chat_spam_throttler: TickThrottler,
57 command_spam_throttler: TickThrottler,
58}
59
60enum ChatSessionUpdateOutcome {
61 Unchanged,
62 MissingServiceKeys,
63 ExpiryDowngrade,
64 Accepted(RemoteChatSession),
65 Invalid(profile_key::ValidationError),
66}
67
68fn validate_chat_session_update(
69 old_profile_key: Option<&profile_key::ProfilePublicKeyData>,
70 new_session: profile_key::RemoteChatSessionData,
71 profile_id: uuid::Uuid,
72 validator: Option<&dyn SignatureValidator>,
73) -> ChatSessionUpdateOutcome {
74 if old_profile_key == Some(&new_session.profile_public_key) {
75 return ChatSessionUpdateOutcome::Unchanged;
76 }
77 if old_profile_key
78 .is_some_and(|old_key| new_session.profile_public_key.expires_at < old_key.expires_at)
79 {
80 return ChatSessionUpdateOutcome::ExpiryDowngrade;
81 }
82 let Some(validator) = validator else {
83 return ChatSessionUpdateOutcome::MissingServiceKeys;
84 };
85
86 match new_session.validate(profile_id, validator) {
87 Ok(session) => ChatSessionUpdateOutcome::Accepted(session),
88 Err(error) => ChatSessionUpdateOutcome::Invalid(error),
89 }
90}
91
92impl ChatState {
93 #[must_use]
95 pub fn new(chat_spam_threshold_seconds: i32, command_spam_threshold_seconds: i32) -> Self {
96 Self {
97 messages_sent: 0,
98 messages_received: 0,
99 signature_cache: MessageCache::new(),
100 message_validator: LastSeenMessagesValidator::new(),
101 chat_session: None,
102 message_chain: None,
103 chat_spam_throttler: TickThrottler::new(
104 20,
105 chat_spam_threshold_seconds.wrapping_mul(20),
106 ),
107 command_spam_throttler: TickThrottler::new(
108 20,
109 command_spam_threshold_seconds.wrapping_mul(20),
110 ),
111 }
112 }
113}
114
115impl Player {
116 pub fn tick_throttlers(&self) {
118 let mut chat = self.chat().lock();
119 chat.chat_spam_throttler.tick();
120 chat.command_spam_throttler.tick();
121 drop(chat);
122
123 self.session.drop_spam_throttler.lock().tick();
124 }
125
126 const fn should_disconnect_for_rate_spam(
127 throttler: &mut TickThrottler,
128 is_operator: bool,
129 ) -> bool {
130 throttler.increment();
131 !throttler.is_under_threshold() && !is_operator
133 }
134
135 pub fn detect_command_rate_spam(&self) {
137 let is_operator = self.is_operator();
138 let should_disconnect = {
139 let mut chat = self.chat().lock();
140 Self::should_disconnect_for_rate_spam(&mut chat.command_spam_throttler, is_operator)
141 };
142
143 if should_disconnect {
144 self.disconnect(translations::DISCONNECT_SPAM.msg());
145 }
146 }
147
148 fn detect_chat_rate_spam(&self) {
149 let is_operator = self.is_operator();
150 let should_disconnect = {
151 let mut chat = self.chat().lock();
152 Self::should_disconnect_for_rate_spam(&mut chat.chat_spam_throttler, is_operator)
153 };
154
155 if should_disconnect {
156 self.disconnect(translations::DISCONNECT_SPAM.msg());
157 }
158 }
159
160 pub fn get_and_increment_messages_received(&self) -> i32 {
162 let mut chat = self.chat().lock();
163 let val = chat.messages_received;
164 chat.messages_received += 1;
165 val
166 }
167
168 fn verify_chat_signature(
169 &self,
170 packet: &SChat,
171 ) -> Result<(message_chain::SignedMessageLink, LastSeen), String> {
172 let mut chat = self.chat().lock();
173 let session = chat.chat_session.clone().ok_or("No chat session")?;
174 let signature = packet.signature.as_ref().ok_or("No signature present")?;
175
176 if session
177 .profile_public_key
178 .data()
179 .has_expired_with_grace(profile_key::EXPIRY_GRACE_PERIOD)
180 {
181 return Err("Profile key has expired".to_string());
182 }
183
184 let chain = chat.message_chain.as_mut().ok_or("No message chain")?;
185
186 if chain.is_broken() {
187 return Err("Message chain is broken".to_string());
188 }
189
190 let timestamp =
191 UNIX_EPOCH + Duration::from_millis(packet.timestamp.try_into().unwrap_or(0));
192
193 let now = SystemTime::now();
194 let message_age = now
195 .duration_since(timestamp)
196 .unwrap_or(Duration::from_secs(0));
197
198 if message_age > MESSAGE_EXPIRES_AFTER_SERVER {
199 return Err(format!(
200 "Message expired (age: {}s, max: {}s)",
201 message_age.as_secs(),
202 MESSAGE_EXPIRES_AFTER_SERVER.as_secs()
203 ));
204 }
205
206 let last_seen_signatures = chat
207 .message_validator
208 .apply_update(packet.acknowledged, packet.offset, packet.checksum)
209 .map_err(|e| {
210 log::error!("Message acknowledgment validation failed: {e}");
211 e
212 })?;
213
214 let last_seen = LastSeen::new(last_seen_signatures);
215
216 let body = message_chain::SignedMessageBody::new(
217 packet.message.clone(),
218 timestamp,
219 packet.salt,
220 last_seen,
221 );
222
223 let chain = chat.message_chain.as_mut().ok_or("No message chain")?;
224 let link = chain
225 .validate_and_advance(&body)
226 .map_err(|e| format!("Chain validation failed: {e}"))?;
227
228 let updater = message_chain::MessageSignatureUpdater::new(&link, &body);
229 let validator = session.profile_public_key.create_signature_validator();
230
231 let is_valid = SignatureValidator::validate(&validator, &updater, signature)
232 .map_err(|e| format!("Signature validation error: {e}"))?;
233
234 if is_valid {
235 Ok((link, body.last_seen.clone()))
236 } else {
237 Err("Invalid signature".to_string())
238 }
239 }
240
241 pub fn handle_chat(&self, packet: SChat, player: Arc<Player>) {
243 player.reset_last_action_time();
244 let chat_message = packet.message.clone();
245
246 let verification_result = if let Some(_signature) = &packet.signature {
247 match self.verify_chat_signature(&packet) {
248 Ok((link, last_seen)) => Some(Ok((link, last_seen))),
249 Err(err) => {
250 log::warn!(
251 "Player {} sent message with invalid signature: {err}",
252 self.gameprofile.name
253 );
254 Some(Err(err))
255 }
256 }
257 } else {
258 None
259 };
260
261 if self.server().enforces_secure_chat() {
262 match &verification_result {
263 Some(Ok(_)) => {}
264 Some(Err(err)) => {
265 self.disconnect(format!("Chat message validation failed: {err}"));
266 return;
267 }
268 None => {
269 self.disconnect(
270 "Secure chat is enforced on this server, but your message was not signed",
271 );
272 return;
273 }
274 }
275 }
276
277 let signature = if matches!(verification_result, Some(Ok(_))) {
278 packet.signature.map(|sig| Box::new(sig) as Box<[u8]>)
279 } else {
280 None
281 };
282
283 let sender_index = {
284 let mut chat = player.chat().lock();
285 let idx = chat.messages_sent;
286 chat.messages_sent += 1;
287 idx
288 };
289
290 let registry_id = vanilla_chat_types::CHAT.id() as i32;
291
292 let chat_packet = CPlayerChat::new(
293 0,
294 player.gameprofile.id,
295 sender_index,
296 signature.clone(),
297 chat_message.clone(),
298 packet.timestamp,
299 packet.salt,
300 Box::new([]),
301 Some(TextComponent::plain(chat_message.clone())),
302 FilterType::PassThrough,
303 ChatTypeBound {
304 registry_id,
305 sender_name: TextComponent::plain(player.gameprofile.name.clone())
306 .insertion(player.gameprofile.name.clone())
307 .click_event(ClickEvent::suggest_command(format!(
308 "/tell {} ",
309 player.gameprofile.name
310 )))
311 .hover_event(HoverEvent::show_entity(
312 "minecraft:player",
313 self.uuid(),
314 Some(player.gameprofile.name.clone()),
315 )),
316 target_name: None,
317 },
318 );
319
320 steel_utils::chat!(player.gameprofile.name.clone(), "{}", chat_message);
321 if let Some(sig_box) = &signature
322 && sig_box.len() == 256
323 {
324 let mut sig_array = [0u8; 256];
325 sig_array.copy_from_slice(&sig_box[..]);
326
327 let last_seen = if let Some(Ok((_, ref last_seen))) = verification_result {
328 last_seen.clone()
329 } else {
330 LastSeen::default()
331 };
332
333 for world in self.server().worlds.values() {
334 world.broadcast_chat(
335 chat_packet.clone(),
336 Arc::clone(&player),
337 last_seen.clone(),
338 Some(&sig_array),
339 );
340 }
341 } else {
342 for world in self.server().worlds.values() {
343 world.broadcast_unsigned_chat(chat_packet.clone());
344 }
345 }
346
347 self.detect_chat_rate_spam();
348 }
349
350 pub fn send_message(&self, text: &TextComponent) {
352 self.send_packet(CSystemChat::new(text, false, self));
353 }
354
355 pub fn send_overlay_message(&self, text: &TextComponent) {
357 self.send_packet(CSystemChat::new(text, true, self));
358 }
359
360 pub(crate) fn send_build_limit_too_high_message(&self, limit: i32) {
362 let limit = TextComponent::plain(limit.to_string());
363 let message = translations::BUILD_TOO_HIGH
364 .message([limit])
365 .color(Color::Red);
366 self.send_overlay_message(&message);
367 }
368
369 pub fn set_chat_session(&self, session: RemoteChatSession) {
373 let chain = SignedMessageChain::new(self.gameprofile.id, session.session_id);
374
375 let session_data = session.as_data();
376 let protocol_data = match session_data.to_protocol_data() {
377 Ok(data) => data,
378 Err(err) => {
379 log::error!(
380 "Failed to convert chat session to protocol data for {}: {:?}",
381 self.gameprofile.name,
382 err
383 );
384 let mut chat = self.chat().lock();
385 chat.chat_session = Some(session);
386 chat.message_chain = Some(chain);
387 return;
388 }
389 };
390
391 {
392 let mut chat = self.chat().lock();
393 chat.chat_session = Some(session);
394 chat.message_chain = Some(chain);
395 }
396
397 log::info!(
398 "Player {} initialized signed chat session",
399 self.gameprofile.name
400 );
401
402 let update_packet =
403 CPlayerInfoUpdate::update_chat_session(self.gameprofile.id, protocol_data);
404 self.server().broadcast_to_online(update_packet);
405 }
406
407 pub fn chat_session(&self) -> Option<RemoteChatSession> {
409 self.chat().lock().chat_session.clone()
410 }
411
412 pub fn has_chat_session(&self) -> bool {
414 self.chat().lock().chat_session.is_some()
415 }
416
417 pub fn handle_chat_session_update(&self, packet: SChatSessionUpdate) {
421 log::info!("Player {} sent chat session update", self.gameprofile.name);
422
423 let expires_at = profile_key::system_time_from_millis(packet.expires_at);
424
425 let public_key = match public_key_from_bytes(&packet.public_key) {
426 Ok(key) => key,
427 Err(err) => {
428 log::warn!(
429 "Player {} sent invalid public key: {err}",
430 self.gameprofile.name
431 );
432 self.disconnect(
433 translations::MULTIPLAYER_DISCONNECT_INVALID_PUBLIC_KEY_SIGNATURE.msg(),
434 );
435 return;
436 }
437 };
438
439 let profile_key_data =
440 profile_key::ProfilePublicKeyData::new(expires_at, public_key, packet.key_signature);
441
442 let session_data = profile_key::RemoteChatSessionData {
443 session_id: packet.session_id,
444 profile_public_key: profile_key_data,
445 };
446
447 let old_profile_key = self
448 .chat_session()
449 .map(|session| session.profile_public_key.data().clone());
450 let validator = self.server().profile_key_signature_validator();
451 match validate_chat_session_update(
452 old_profile_key.as_ref(),
453 session_data,
454 self.gameprofile.id,
455 validator
456 .as_deref()
457 .map(|validator| validator as &dyn SignatureValidator),
458 ) {
459 ChatSessionUpdateOutcome::Unchanged => {}
460 ChatSessionUpdateOutcome::MissingServiceKeys => {
461 log::warn!(
462 "Ignoring chat session from {} due to missing services public key",
463 self.gameprofile.name
464 );
465 }
466 ChatSessionUpdateOutcome::ExpiryDowngrade => {
467 self.disconnect(translations::MULTIPLAYER_DISCONNECT_EXPIRED_PUBLIC_KEY.msg());
468 }
469 ChatSessionUpdateOutcome::Accepted(session) => self.set_chat_session(session),
470 ChatSessionUpdateOutcome::Invalid(error) => {
471 log::warn!(
472 "Player {} sent invalid chat session: {error}",
473 self.gameprofile.name
474 );
475 self.disconnect(
476 translations::MULTIPLAYER_DISCONNECT_INVALID_PUBLIC_KEY_SIGNATURE.msg(),
477 );
478 }
479 }
480 }
481
482 pub fn handle_chat_ack(&self, packet: SChatAck) {
484 if let Err(err) = self
485 .chat()
486 .lock()
487 .message_validator
488 .apply_offset(packet.offset.0)
489 {
490 log::warn!(
491 "Player {} sent invalid chat acknowledgment: {err}",
492 self.gameprofile.name
493 );
494 }
495 }
496}
497
498#[cfg(test)]
499mod tests {
500 use steel_crypto::{
501 CryptError, SignatureValidator, generate_key_pair, signature::SignatureUpdater,
502 };
503 use uuid::Uuid;
504
505 use super::{
506 ChatSessionUpdateOutcome, ChatState, Player, profile_key, validate_chat_session_update,
507 };
508
509 struct FixedValidator(bool);
510
511 impl SignatureValidator for FixedValidator {
512 fn validate(
513 &self,
514 _updater: &dyn SignatureUpdater,
515 _signature: &[u8],
516 ) -> Result<bool, CryptError> {
517 Ok(self.0)
518 }
519 }
520
521 fn session(expires_at_millis: i64) -> profile_key::RemoteChatSessionData {
522 let (_, public_key) = generate_key_pair().expect("test player key should generate");
523 profile_key::RemoteChatSessionData {
524 session_id: Uuid::new_v4(),
525 profile_public_key: profile_key::ProfilePublicKeyData::new(
526 profile_key::system_time_from_millis(expires_at_millis),
527 public_key,
528 vec![1],
529 ),
530 }
531 }
532
533 #[test]
534 fn operators_are_exempt_from_both_spam_disconnects() {
535 let mut chat = ChatState::new(1, 1);
536
537 assert!(!Player::should_disconnect_for_rate_spam(
538 &mut chat.command_spam_throttler,
539 true,
540 ));
541 assert!(!Player::should_disconnect_for_rate_spam(
542 &mut chat.chat_spam_throttler,
543 true,
544 ));
545 }
546
547 #[test]
548 fn non_operators_still_trigger_both_spam_disconnects() {
549 let mut chat = ChatState::new(1, 1);
550
551 assert!(Player::should_disconnect_for_rate_spam(
552 &mut chat.command_spam_throttler,
553 false,
554 ));
555 assert!(Player::should_disconnect_for_rate_spam(
556 &mut chat.chat_spam_throttler,
557 false,
558 ));
559 }
560
561 #[test]
562 fn unchanged_profile_key_does_not_reset_the_session() {
563 let current = session(2);
564 let new_session = profile_key::RemoteChatSessionData {
565 session_id: Uuid::new_v4(),
566 profile_public_key: current.profile_public_key.clone(),
567 };
568
569 assert!(matches!(
570 validate_chat_session_update(
571 Some(¤t.profile_public_key),
572 new_session,
573 Uuid::new_v4(),
574 None,
575 ),
576 ChatSessionUpdateOutcome::Unchanged
577 ));
578 }
579
580 #[test]
581 fn expiry_downgrade_precedes_service_key_availability() {
582 let current = session(2);
583
584 assert!(matches!(
585 validate_chat_session_update(
586 Some(¤t.profile_public_key),
587 session(1),
588 Uuid::new_v4(),
589 None,
590 ),
591 ChatSessionUpdateOutcome::ExpiryDowngrade
592 ));
593 }
594
595 #[test]
596 fn missing_service_keys_ignore_new_session() {
597 assert!(matches!(
598 validate_chat_session_update(None, session(1), Uuid::new_v4(), None),
599 ChatSessionUpdateOutcome::MissingServiceKeys
600 ));
601 }
602
603 #[test]
604 fn service_signature_result_controls_session_acceptance() {
605 assert!(matches!(
606 validate_chat_session_update(
607 None,
608 session(1),
609 Uuid::new_v4(),
610 Some(&FixedValidator(true)),
611 ),
612 ChatSessionUpdateOutcome::Accepted(_)
613 ));
614 assert!(matches!(
615 validate_chat_session_update(
616 None,
617 session(1),
618 Uuid::new_v4(),
619 Some(&FixedValidator(false)),
620 ),
621 ChatSessionUpdateOutcome::Invalid(profile_key::ValidationError::InvalidSignature)
622 ));
623 }
624}