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;
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
35/// All chat-related state for a player.
36///
37/// Stored behind a single `SyncMutex` on `Player`. The fields were previously
38/// individual atomics/mutexes but are always accessed within short critical
39/// sections per-player, so a single lock is simpler with no real contention cost.
40pub struct ChatState {
41    /// Counter for chat messages sent BY this player.
42    pub messages_sent: i32,
43    /// Counter for chat messages received BY this player.
44    pub messages_received: i32,
45    /// Message signature cache for tracking chat messages.
46    pub signature_cache: MessageCache,
47    /// Validator for client acknowledgements of messages we've sent.
48    pub message_validator: LastSeenMessagesValidator,
49    /// Remote chat session containing the player's public key (if signed chat is enabled).
50    pub chat_session: Option<RemoteChatSession>,
51    /// Message chain state for tracking signed message sequence.
52    pub message_chain: Option<SignedMessageChain>,
53    chat_spam_throttler: TickThrottler,
54    command_spam_throttler: TickThrottler,
55}
56
57impl ChatState {
58    /// Creates empty chat state with the configured Vanilla spam thresholds.
59    #[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    /// Decays the per player chat and command spam counters once per server tick
82    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        // TODO: Also exempt the singleplayer owner once Steel models that state.
94        !throttler.is_under_threshold() && !is_operator
95    }
96
97    /// Applies Vanilla command spam accounting after a command is handled
98    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    /// Gets the next `messages_received` counter and increments it
123    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    /// Handles a chat message from the player.
205    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    /// Sends a system message to the player.
313    pub fn send_message(&self, text: &TextComponent) {
314        self.send_packet(CSystemChat::new(text, false, self));
315    }
316
317    /// Updates the player's chat session and initializes the message chain.
318    ///
319    /// This should be called when receiving a `ChatSessionUpdate` packet from the client.
320    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    /// Gets a reference to the player's chat session if present
356    pub fn chat_session(&self) -> Option<RemoteChatSession> {
357        self.chat.lock().chat_session.clone()
358    }
359
360    /// Checks if the player has a valid chat session
361    pub fn has_chat_session(&self) -> bool {
362        self.chat.lock().chat_session.is_some()
363    }
364
365    /// Handles a chat session update packet from the client.
366    ///
367    /// This validates the player's profile key and initializes signed chat if valid.
368    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    /// Handles a chat acknowledgment packet from the client.
418    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}