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