Skip to main content

steel_core/player/chat/
message_chain.rs

1//! Message chain management for signed chat.
2//!
3//! Ported from net/minecraft/network/chat/SignedMessageLink.java,
4//! SignedMessageBody.java, and SignedMessageChain.java
5
6use std::time::{SystemTime, UNIX_EPOCH};
7
8use steel_crypto::{CryptError, SignatureUpdater, signature::SignatureOutput};
9use thiserror::Error;
10use uuid::Uuid;
11
12use super::signature_cache::LastSeen;
13
14/// A link in the signed message chain.
15///
16/// Equivalent to `SignedMessageLink` in Minecraft.
17#[derive(Clone, Debug, PartialEq, Eq)]
18pub struct SignedMessageLink {
19    /// Message index in the sequence (starts at 0)
20    pub index: i32,
21    /// UUID of the player sending the message
22    pub sender: Uuid,
23    /// UUID of the player's chat session
24    pub session_id: Uuid,
25}
26
27impl SignedMessageLink {
28    /// Creates a new message link
29    #[must_use]
30    pub const fn new(index: i32, sender: Uuid, session_id: Uuid) -> Self {
31        Self {
32            index,
33            sender,
34            session_id,
35        }
36    }
37
38    /// Creates an unsigned message link (`session_id` = nil UUID)
39    #[must_use]
40    pub const fn unsigned(sender: Uuid) -> Self {
41        Self::root(sender, Uuid::from_u128(0))
42    }
43
44    /// Creates the root (first) link in a chain
45    #[must_use]
46    pub const fn root(sender: Uuid, session_id: Uuid) -> Self {
47        Self {
48            index: 0,
49            sender,
50            session_id,
51        }
52    }
53
54    /// Updates signature data with this link's information
55    pub fn update_signature(&self, output: &mut dyn SignatureOutput) -> Result<(), CryptError> {
56        // Sender UUID (16 bytes, as u128 big-endian)
57        output.update(&self.sender.as_u128().to_be_bytes())?;
58
59        // Session ID (16 bytes, as u128 big-endian)
60        output.update(&self.session_id.as_u128().to_be_bytes())?;
61
62        // Message index (4 bytes, i32 big-endian)
63        output.update(&self.index.to_be_bytes())?;
64
65        Ok(())
66    }
67
68    /// Checks if this link is a descendant of another (same sender/session, higher index)
69    #[must_use]
70    pub fn is_descendant_of(&self, other: &SignedMessageLink) -> bool {
71        self.index > other.index
72            && self.sender == other.sender
73            && self.session_id == other.session_id
74    }
75
76    /// Advances to the next link in the chain (returns `None` if at max)
77    #[must_use]
78    pub const fn advance(&self) -> Option<Self> {
79        if self.index == i32::MAX {
80            None
81        } else {
82            Some(Self {
83                index: self.index + 1,
84                sender: self.sender,
85                session_id: self.session_id,
86            })
87        }
88    }
89}
90
91/// The body of a signed message containing content and metadata.
92///
93/// Equivalent to `SignedMessageBody` in Minecraft.
94#[derive(Clone, Debug)]
95pub struct SignedMessageBody {
96    /// The message content (max 256 UTF-8 chars)
97    pub content: String,
98    /// When the message was created
99    pub time_stamp: SystemTime,
100    /// Random salt for uniqueness
101    pub salt: i64,
102    /// Previously seen message signatures
103    pub last_seen: LastSeen,
104}
105
106impl SignedMessageBody {
107    /// Creates a new signed message body
108    #[must_use]
109    pub const fn new(
110        content: String,
111        time_stamp: SystemTime,
112        salt: i64,
113        last_seen: LastSeen,
114    ) -> Self {
115        Self {
116            content,
117            time_stamp,
118            salt,
119            last_seen,
120        }
121    }
122
123    /// Creates an unsigned message body (salt = 0, no last seen)
124    #[must_use]
125    pub fn unsigned(content: String) -> Self {
126        Self {
127            content,
128            time_stamp: SystemTime::now(),
129            salt: 0,
130            last_seen: LastSeen::default(),
131        }
132    }
133
134    /// Updates signature data with this body's information
135    pub fn update_signature(&self, output: &mut dyn SignatureOutput) -> Result<(), CryptError> {
136        // Salt (8 bytes, i64 big-endian)
137        output.update(&self.salt.to_be_bytes())?;
138
139        // Timestamp as epoch seconds (8 bytes, i64 big-endian)
140        let epoch_seconds = self
141            .time_stamp
142            .duration_since(UNIX_EPOCH)
143            .unwrap_or_default()
144            .as_secs() as i64;
145        output.update(&epoch_seconds.to_be_bytes())?;
146
147        // Content length (4 bytes, i32 big-endian)
148        let content_bytes = self.content.as_bytes();
149        output.update(&(content_bytes.len() as i32).to_be_bytes())?;
150
151        // Content (UTF-8 bytes)
152        output.update(content_bytes)?;
153
154        // Last seen signatures
155        update_last_seen_signature(&self.last_seen, output)?;
156
157        Ok(())
158    }
159}
160
161/// Helper to update signature with last seen messages
162fn update_last_seen_signature(
163    last_seen: &LastSeen,
164    output: &mut dyn SignatureOutput,
165) -> Result<(), CryptError> {
166    // Number of signatures (4 bytes, i32 big-endian)
167    output.update(&(last_seen.len() as i32).to_be_bytes())?;
168
169    // All signature bytes
170    for signature in last_seen.as_slice() {
171        output.update(signature)?;
172    }
173
174    Ok(())
175}
176
177/// Errors that can occur during message chain operations
178#[derive(Debug, Error)]
179pub enum ChainError {
180    /// Profile key is missing
181    #[error("Missing profile key")]
182    MissingProfileKey,
183
184    /// Chain is broken
185    #[error("Chain is broken")]
186    ChainBroken,
187
188    /// Profile key has expired
189    #[error("Profile key has expired")]
190    ExpiredProfileKey,
191
192    /// Invalid signature
193    #[error("Invalid signature")]
194    InvalidSignature,
195
196    /// Message out of order
197    #[error("Message out of order")]
198    OutOfOrderChat,
199
200    /// Message has expired
201    #[error("Message has expired")]
202    MessageExpired,
203
204    /// Cryptographic error
205    #[error("Cryptographic error: {0}")]
206    CryptoError(#[from] CryptError),
207}
208
209/// Manages the message chain state for a player.
210///
211/// Equivalent to `SignedMessageChain` in Minecraft.
212#[derive(Debug)]
213pub struct SignedMessageChain {
214    /// The next expected link in the chain (None if chain is broken)
215    next_link: Option<SignedMessageLink>,
216    /// Timestamp of the last message (for ordering validation)
217    last_timestamp: SystemTime,
218}
219
220impl SignedMessageChain {
221    /// Creates a new message chain
222    #[must_use]
223    pub const fn new(sender: Uuid, session_id: Uuid) -> Self {
224        Self {
225            next_link: Some(SignedMessageLink::root(sender, session_id)),
226            last_timestamp: UNIX_EPOCH,
227        }
228    }
229
230    /// Gets the current link if the chain is not broken
231    #[must_use]
232    pub const fn next_link(&self) -> Option<&SignedMessageLink> {
233        self.next_link.as_ref()
234    }
235
236    /// Checks if the chain is broken
237    #[must_use]
238    pub const fn is_broken(&self) -> bool {
239        self.next_link.is_none()
240    }
241
242    /// Breaks the chain (sets `next_link` to None)
243    pub const fn break_chain(&mut self) {
244        self.next_link = None;
245    }
246
247    /// Validates and advances the chain with a new message.
248    ///
249    /// Returns the link that was used for this message.
250    ///
251    /// # Errors
252    /// Returns `ChainError` if validation fails
253    pub fn validate_and_advance(
254        &mut self,
255        body: &SignedMessageBody,
256    ) -> Result<SignedMessageLink, ChainError> {
257        // Check chain not broken
258        let link = self.next_link.clone().ok_or(ChainError::ChainBroken)?;
259
260        // Check timestamp ordering (must be >= last)
261        if body.time_stamp < self.last_timestamp {
262            self.break_chain();
263            return Err(ChainError::OutOfOrderChat);
264        }
265
266        // Update state
267        self.last_timestamp = body.time_stamp;
268        self.next_link = link.advance();
269
270        Ok(link)
271    }
272
273    /// Resets the chain to a new session
274    pub const fn reset(&mut self, sender: Uuid, session_id: Uuid) {
275        self.next_link = Some(SignedMessageLink::root(sender, session_id));
276        self.last_timestamp = UNIX_EPOCH;
277    }
278}
279
280/// Helper struct to update signature with complete message data
281pub struct MessageSignatureUpdater<'a> {
282    link: &'a SignedMessageLink,
283    body: &'a SignedMessageBody,
284}
285
286impl<'a> MessageSignatureUpdater<'a> {
287    /// Creates a new message signature updater
288    #[must_use]
289    pub const fn new(link: &'a SignedMessageLink, body: &'a SignedMessageBody) -> Self {
290        Self { link, body }
291    }
292}
293
294impl SignatureUpdater for MessageSignatureUpdater<'_> {
295    fn update(&self, output: &mut dyn SignatureOutput) -> Result<(), CryptError> {
296        // Version number (always 1 as a 4-byte int, 00 00 00 01)
297        output.update(&1i32.to_be_bytes())?;
298
299        // Link data
300        self.link.update_signature(output)?;
301
302        // Body data
303        self.body.update_signature(output)?;
304
305        Ok(())
306    }
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312
313    #[test]
314    fn test_link_advance() {
315        let link = SignedMessageLink::root(Uuid::new_v4(), Uuid::new_v4());
316        assert_eq!(link.index, 0);
317
318        let next = link.advance().expect("Should advance from index 0");
319        assert_eq!(next.index, 1);
320        assert_eq!(next.sender, link.sender);
321        assert_eq!(next.session_id, link.session_id);
322    }
323
324    #[test]
325    fn test_link_advance_at_max() {
326        let link = SignedMessageLink::new(i32::MAX, Uuid::new_v4(), Uuid::new_v4());
327        assert!(link.advance().is_none());
328    }
329
330    #[test]
331    fn test_link_descendant() {
332        let link1 = SignedMessageLink::root(Uuid::new_v4(), Uuid::new_v4());
333        let link2 = link1.advance().expect("Should advance from root");
334        let link3 = link2.advance().expect("Should advance from index 1");
335
336        assert!(link2.is_descendant_of(&link1));
337        assert!(link3.is_descendant_of(&link2));
338        assert!(link3.is_descendant_of(&link1));
339        assert!(!link1.is_descendant_of(&link2));
340    }
341
342    #[test]
343    fn test_chain_validation() {
344        let sender = Uuid::new_v4();
345        let session = Uuid::new_v4();
346        let mut chain = SignedMessageChain::new(sender, session);
347
348        let body = SignedMessageBody::unsigned("Hello".to_string());
349        let link = chain
350            .validate_and_advance(&body)
351            .expect("First message should validate");
352        assert_eq!(link.index, 0);
353
354        let body2 = SignedMessageBody::unsigned("World".to_string());
355        let link2 = chain
356            .validate_and_advance(&body2)
357            .expect("Second message should validate");
358        assert_eq!(link2.index, 1);
359    }
360
361    #[test]
362    fn test_chain_out_of_order() {
363        let sender = Uuid::new_v4();
364        let session = Uuid::new_v4();
365        let mut chain = SignedMessageChain::new(sender, session);
366
367        let body1 = SignedMessageBody::unsigned("First".to_string());
368        chain
369            .validate_and_advance(&body1)
370            .expect("First message should validate");
371
372        // Create a message with an older timestamp
373        let body2 =
374            SignedMessageBody::new("Second".to_string(), UNIX_EPOCH, 0, LastSeen::default());
375
376        let result = chain.validate_and_advance(&body2);
377        assert!(matches!(result, Err(ChainError::OutOfOrderChat)));
378        assert!(chain.is_broken());
379    }
380}