Skip to main content

steel_core/player/chat/
mod.rs

1//! Chat and messaging state for a player.
2//!
3//! Groups the fields related to secure chat: message counters, signature cache,
4//! message validator, chat session, and message chain.
5
6pub 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
35/// Vanilla `PlayerChatMessage.MESSAGE_EXPIRES_AFTER_SERVER`.
36const MESSAGE_EXPIRES_AFTER_SERVER: Duration = Duration::from_mins(5);
37
38/// All chat-related state for a player.
39///
40/// Stored behind a single `SyncMutex` on `PlayerSession`. The fields were previously
41/// individual atomics/mutexes but are always accessed within short critical
42/// sections per-player, so a single lock is simpler with no real contention cost.
43pub struct ChatState {
44    /// Counter for chat messages sent BY this player.
45    pub messages_sent: i32,
46    /// Counter for chat messages received BY this player.
47    pub messages_received: i32,
48    /// Message signature cache for tracking chat messages.
49    pub signature_cache: MessageCache,
50    /// Validator for client acknowledgements of messages we've sent.
51    pub message_validator: LastSeenMessagesValidator,
52    /// Remote chat session containing the player's public key (if signed chat is enabled).
53    pub chat_session: Option<RemoteChatSession>,
54    /// Message chain state for tracking signed message sequence.
55    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    /// Creates empty chat state with the configured Vanilla spam thresholds.
94    #[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    /// Decays the throttlers of the player once per server tick.
117    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        // TODO: Also exempt the singleplayer owner once Steel models that state.
132        !throttler.is_under_threshold() && !is_operator
133    }
134
135    /// Applies Vanilla command spam accounting after a command is handled
136    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    /// Gets the next `messages_received` counter and increments it
161    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    /// Handles a chat message from the player.
242    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    /// Sends a system message to the player.
351    pub fn send_message(&self, text: &TextComponent) {
352        self.send_packet(CSystemChat::new(text, false, self));
353    }
354
355    /// Sends an overlay system message to the player
356    pub fn send_overlay_message(&self, text: &TextComponent) {
357        self.send_packet(CSystemChat::new(text, true, self));
358    }
359
360    /// Sends vanilla's red upper build-height limit overlay.
361    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    /// Updates the player's chat session and initializes the message chain.
370    ///
371    /// This should be called when receiving a `ChatSessionUpdate` packet from the client.
372    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    /// Gets a reference to the player's chat session if present
408    pub fn chat_session(&self) -> Option<RemoteChatSession> {
409        self.chat().lock().chat_session.clone()
410    }
411
412    /// Checks if the player has a valid chat session
413    pub fn has_chat_session(&self) -> bool {
414        self.chat().lock().chat_session.is_some()
415    }
416
417    /// Handles a chat session update packet from the client.
418    ///
419    /// This validates the player's profile key and initializes signed chat if valid.
420    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    /// Handles a chat acknowledgment packet from the client.
483    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(&current.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(&current.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}