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::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
36/// All chat-related state for a player.
37///
38/// Stored behind a single `SyncMutex` on `PlayerSession`. The fields were previously
39/// individual atomics/mutexes but are always accessed within short critical
40/// sections per-player, so a single lock is simpler with no real contention cost.
41pub struct ChatState {
42    /// Counter for chat messages sent BY this player.
43    pub messages_sent: i32,
44    /// Counter for chat messages received BY this player.
45    pub messages_received: i32,
46    /// Message signature cache for tracking chat messages.
47    pub signature_cache: MessageCache,
48    /// Validator for client acknowledgements of messages we've sent.
49    pub message_validator: LastSeenMessagesValidator,
50    /// Remote chat session containing the player's public key (if signed chat is enabled).
51    pub chat_session: Option<RemoteChatSession>,
52    /// Message chain state for tracking signed message sequence.
53    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    /// Creates empty chat state with the configured Vanilla spam thresholds.
92    #[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    /// Decays the throttlers of the player once per server tick.
115    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        // TODO: Also exempt the singleplayer owner once Steel models that state.
130        !throttler.is_under_threshold() && !is_operator
131    }
132
133    /// Applies Vanilla command spam accounting after a command is handled
134    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    /// Gets the next `messages_received` counter and increments it
159    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    /// Handles a chat message from the player.
240    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    /// Sends a system message to the player.
344    pub fn send_message(&self, text: &TextComponent) {
345        self.send_packet(CSystemChat::new(text, false, self));
346    }
347
348    /// Sends an overlay system message to the player
349    pub fn send_overlay_message(&self, text: &TextComponent) {
350        self.send_packet(CSystemChat::new(text, true, self));
351    }
352
353    /// Sends vanilla's red upper build-height limit overlay.
354    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    /// Updates the player's chat session and initializes the message chain.
363    ///
364    /// This should be called when receiving a `ChatSessionUpdate` packet from the client.
365    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    /// Gets a reference to the player's chat session if present
401    pub fn chat_session(&self) -> Option<RemoteChatSession> {
402        self.chat().lock().chat_session.clone()
403    }
404
405    /// Checks if the player has a valid chat session
406    pub fn has_chat_session(&self) -> bool {
407        self.chat().lock().chat_session.is_some()
408    }
409
410    /// Handles a chat session update packet from the client.
411    ///
412    /// This validates the player's profile key and initializes signed chat if valid.
413    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    /// Handles a chat acknowledgment packet from the client.
479    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(&current.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(&current.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}