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}