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