Skip to main content

steel_core/player/chat/
signature_cache.rs

1//! Chat message signature tracking for secure chat validation.
2
3use std::collections::VecDeque;
4
5use steel_protocol::packets::game::PreviousMessage as PreviousMessageEntry;
6
7/// Maximum number of cached signatures (Vanilla: 128)
8const MAX_CACHED_SIGNATURES: usize = 128;
9
10/// Maximum number of previous messages to track (Vanilla: 20)
11const MAX_PREVIOUS_MESSAGES: usize = 20;
12
13/// Tracks the last seen message signatures by a player
14#[derive(Debug, Clone, Default)]
15pub struct LastSeen(Vec<Box<[u8]>>);
16
17impl LastSeen {
18    /// Creates a new `LastSeen` from a vector of signatures
19    #[must_use]
20    pub const fn new(signatures: Vec<Box<[u8]>>) -> Self {
21        Self(signatures)
22    }
23
24    /// Gets the underlying vector of signatures
25    #[must_use]
26    pub fn as_slice(&self) -> &[Box<[u8]>] {
27        &self.0
28    }
29
30    /// Gets the number of tracked signatures
31    #[must_use]
32    pub const fn len(&self) -> usize {
33        self.0.len()
34    }
35
36    /// Checks if there are no tracked signatures
37    #[must_use]
38    pub const fn is_empty(&self) -> bool {
39        self.0.is_empty()
40    }
41}
42
43/// Message signature cache for a player
44#[derive(Debug)]
45pub struct MessageCache {
46    /// Max 128 cached message signatures. Most recent FIRST.
47    /// Server should (when possible) reference indexes in this (recipient's) cache
48    /// instead of sending full signatures in last seen.
49    /// Must be 1:1 with client's signature cache.
50    full_cache: VecDeque<Box<[u8]>>,
51
52    /// Max 20 last seen messages by the sender. Most Recent LAST
53    pub last_seen: LastSeen,
54}
55
56impl Default for MessageCache {
57    fn default() -> Self {
58        Self {
59            full_cache: VecDeque::with_capacity(MAX_CACHED_SIGNATURES),
60            last_seen: LastSeen::default(),
61        }
62    }
63}
64
65impl MessageCache {
66    /// Creates a new message cache
67    #[must_use]
68    pub fn new() -> Self {
69        Self::default()
70    }
71
72    /// Reconstructs the `LastSeen` from a `BitSet` acknowledgment.
73    ///
74    /// The `offset` indicates how many old messages to skip (not used for unpacking,
75    /// but used by the validator), and the `acknowledged` `BitSet` indicates
76    /// which of the last 20 messages were seen.
77    ///
78    /// Returns None if the cache doesn't contain the required messages.
79    ///
80    /// Note: The offset is primarily used by the `LastSeenMessagesValidator` to advance
81    /// the tracking window. For unpacking acknowledged messages, we just need to look
82    /// at which bits are set in the acknowledged bitset and retrieve those signatures
83    /// from the cache at the corresponding indices.
84    #[must_use]
85    pub fn unpack_acknowledged(
86        &self,
87        _offset: i32,
88        acknowledged: [u8; 3], // FixedBitSet(20) = 3 bytes
89    ) -> Option<LastSeen> {
90        // Parse the 20-bit BitSet from 3 bytes
91        let mut bits = [false; MAX_PREVIOUS_MESSAGES];
92        for (i, bit) in bits.iter_mut().enumerate() {
93            let byte_index = i / 8;
94            let bit_index = i % 8;
95            *bit = (acknowledged[byte_index] & (1 << bit_index)) != 0;
96        }
97
98        // Collect acknowledged signatures from the cache
99        let mut signatures = Vec::new();
100
101        // Iterate through the bits to find acknowledged messages
102        // The cache is ordered with most recent messages first (index 0)
103        // The acknowledged bitset maps directly to cache indices
104        for (i, &is_acknowledged) in bits.iter().enumerate() {
105            if !is_acknowledged {
106                continue; // This message was not acknowledged
107            }
108
109            // The index in the acknowledged bitset corresponds to the cache index
110            let cache_index = i;
111
112            if cache_index >= self.full_cache.len() {
113                // Cache doesn't have this message
114                log::warn!(
115                    "Cache miss: trying to access index {} but cache only has {} entries",
116                    cache_index,
117                    self.full_cache.len()
118                );
119                return None;
120            }
121
122            signatures.push(self.full_cache[cache_index].clone());
123        }
124
125        Some(LastSeen(signatures))
126    }
127
128    /// Cache signatures from senders that the recipient hasn't seen yet.
129    /// Not used for caching seen messages. Only for non-indexed signatures from senders.
130    pub fn cache_signatures(&mut self, signatures: &[Box<[u8]>]) {
131        for sig in signatures.iter().rev() {
132            if self.full_cache.contains(sig) {
133                continue;
134            }
135            // If the cache is maxed, and someone sends a signature older than the oldest in cache, ignore it
136            if self.full_cache.len() < MAX_CACHED_SIGNATURES {
137                self.full_cache.push_back(sig.clone()); // Recipient never saw this message so it must be older than the oldest in cache
138            }
139        }
140    }
141
142    /// Adds a seen signature to `last_seen` and `full_cache`.
143    pub fn add_seen_signature(&mut self, signature: &[u8]) {
144        if self.last_seen.0.len() >= MAX_PREVIOUS_MESSAGES {
145            self.last_seen.0.remove(0);
146        }
147        self.last_seen.0.push(signature.into());
148
149        // This probably doesn't need to be a loop, but better safe than sorry
150        while self.full_cache.len() >= MAX_CACHED_SIGNATURES {
151            self.full_cache.pop_back();
152        }
153        self.full_cache.push_front(signature.into()); // Since recipient saw this message it will be most recent in cache
154    }
155
156    /// Pushes signatures into the cache using vanilla's algorithm.
157    /// This should be called AFTER sending a chat packet to a recipient.
158    ///
159    /// The signatures are pushed in order: all lastSeen signatures first, then the current message signature.
160    /// This matches vanilla's MessageSignatureCache.push(SignedMessageBody, `MessageSignature`) behavior.
161    ///
162    /// # Panics
163    /// Panics if the deque is empty while attempting to pop (should never happen as we check `!deque.is_empty()`).
164    pub fn push(&mut self, last_seen_signatures: &LastSeen, current_signature: Option<&[u8; 256]>) {
165        use rustc_hash::FxHashSet;
166        use std::collections::VecDeque;
167
168        log::debug!(
169            "push: adding {} lastSeen + {} current = {} total signatures to cache (current cache size: {})",
170            last_seen_signatures.len(),
171            i32::from(current_signature.is_some()),
172            last_seen_signatures.len() + usize::from(current_signature.is_some()),
173            self.full_cache.len()
174        );
175
176        // Build a deque with all signatures to push: lastSeen + current
177        // Vanilla: addAll(list) then add(signature)
178        let mut deque: VecDeque<Box<[u8]>> = VecDeque::new();
179
180        // Add all lastSeen signatures (in order)
181        for sig in last_seen_signatures.as_slice() {
182            deque.push_back(sig.clone());
183        }
184
185        // Add current signature if present
186        if let Some(sig) = current_signature {
187            deque.push_back(Box::new(*sig));
188        }
189
190        // Create a set of all signatures we're pushing for O(1) lookup
191        let push_set: FxHashSet<Box<[u8]>> = deque.iter().cloned().collect();
192
193        // Vanilla's push algorithm:
194        // for(int i = 0; !deque.isEmpty() && i < this.entries.length; ++i) {
195        //     MessageSignature old = this.entries[i];
196        //     this.entries[i] = deque.removeLast();  // Take from end (most recent)
197        //     if (old != null && !set.contains(old)) {
198        //         deque.addFirst(old);  // Re-add to front if not in push set
199        //     }
200        // }
201
202        // Convert VecDeque to fixed-size array-like structure
203        // We need to ensure cache has exactly 128 slots
204        let mut new_cache = VecDeque::with_capacity(MAX_CACHED_SIGNATURES);
205
206        let mut i = 0;
207        while !deque.is_empty() && i < MAX_CACHED_SIGNATURES {
208            // Get old entry at position i
209            let old_entry = self.full_cache.get(i).cloned();
210
211            // Take most recent from deque (from back)
212            let new_entry = deque
213                .pop_back()
214                .expect("deque should not be empty due to loop condition");
215            new_cache.push_back(new_entry);
216
217            // If old entry exists and is not in our push set, add it back to process
218            if let Some(old) = old_entry
219                && !push_set.contains(&old)
220            {
221                deque.push_front(old);
222            }
223
224            i += 1;
225        }
226
227        self.full_cache = new_cache;
228        log::debug!("push: cache updated, new size: {}", self.full_cache.len());
229    }
230
231    /// Convert the sender's `last_seen` signatures to IDs if the recipient has them in their cache.
232    /// Otherwise, the full signature is sent. (ID:0 indicates full signature is being sent)
233    #[must_use]
234    pub fn index_previous_messages(
235        &self,
236        sender_last_seen: &LastSeen,
237    ) -> Box<[PreviousMessageEntry]> {
238        let mut indexed = Vec::new();
239
240        log::debug!(
241            "index_previous_messages: sender has {} lastSeen signatures, recipient cache size: {}",
242            sender_last_seen.len(),
243            self.full_cache.len()
244        );
245
246        for (i, signature) in sender_last_seen.as_slice().iter().enumerate() {
247            let index = self.full_cache.iter().position(|s| s == signature);
248
249            if let Some(index) = index {
250                log::debug!(
251                    "  lastSeen[{}]: found in cache at index {} -> sending ID={}",
252                    i,
253                    index,
254                    index + 1
255                );
256                indexed.push(PreviousMessageEntry {
257                    // Send ID reference to recipient's cache (index + 1 because 0 is reserved for full signature)
258                    id: 1 + index as i32,
259                    signature: None,
260                });
261            } else {
262                log::debug!("  lastSeen[{i}]: NOT in cache -> sending full signature (ID=0)");
263                indexed.push(PreviousMessageEntry {
264                    // Send ID as 0 for full signature
265                    id: 0,
266                    signature: Some(signature.clone()),
267                });
268            }
269        }
270        indexed.into_boxed_slice()
271    }
272}