Repository: Pumpkin-MC/Pumpkin
Pull request #692: Various essential chat improvements
Author: login: aaneeley
Created: 2025-04-02T02:40:45Z | Merged: 2025-04-05T20:23:25Z

<!-- Empty or bad Descriptions are not welcome, Don't waste my time -->

## Description
Chat "works" but it has a lot of errors and its buggy especially with multiple online players. This PR is meant to more accurately mirror vanilla chat handling logic.

## Progress
- [x] Correctly implement message signature cache and last seen messages
- [x] Chat message validation & error handling
- [x] Chat session validation & error handling

## Testing

Please follow our [Coding Guidelines](https://github.com/Pumpkin-MC/Pumpkin/blob/master/CONTRIBUTING.md#coding-guidelines)



Diff:
diff --git a/pumpkin-config/src/lib.rs b/pumpkin-config/src/lib.rs
index 9cbd43910a..82ad114170 100644
--- a/pumpkin-config/src/lib.rs
+++ b/pumpkin-config/src/lib.rs
@@ -257,5 +257,11 @@ impl LoadConfiguration for BasicConfiguration {
                 "When online mode is enabled, encryption must be enabled"
             )
         }
+        if self.allow_chat_reports {
+            assert!(
+                self.online_mode,
+                "When allow_chat_reports is enabled, online_mode must be enabled"
+            )
+        }
     }
 }
diff --git a/pumpkin-config/src/networking/auth.rs b/pumpkin-config/src/networking/auth.rs
index 322d9e0325..3119b70a7a 100644
--- a/pumpkin-config/src/networking/auth.rs
+++ b/pumpkin-config/src/networking/auth.rs
@@ -11,6 +11,8 @@ pub struct AuthenticationConfig {
     pub read_timeout: u32,
     pub prevent_proxy_connections: bool,
     pub prevent_proxy_connection_auth_url: Option<String>,
+    /// Drasl and Mojang both call their public keys endpoint the "services url"
+    pub services_url: Option<String>,
     /// Player profile handling.
     pub player_profile: PlayerProfileConfig,
     /// Texture handling.
@@ -26,6 +28,7 @@ impl Default for AuthenticationConfig {
             textures: Default::default(),
             url: None,
             prevent_proxy_connection_auth_url: None,
+            services_url: None,
             connect_timeout: 5000,
             read_timeout: 5000,
         }
diff --git a/pumpkin-protocol/Cargo.toml b/pumpkin-protocol/Cargo.toml
index 838a849a8a..e3e843b7ea 100644
--- a/pumpkin-protocol/Cargo.toml
+++ b/pumpkin-protocol/Cargo.toml
@@ -32,6 +32,7 @@ cfb8 = "0.8"
 async-compression = { version = "0.4.20", features = ["tokio", "zlib"] }
 
 take_mut = "0.2.2"
+bitflags = "2.9.0"
 
 [dev-dependencies]
 # Validate correctness
diff --git a/pumpkin-protocol/src/client/play/player_info_update.rs b/pumpkin-protocol/src/client/play/player_info_update.rs
index 554c6887ee..3d56f846fe 100644
--- a/pumpkin-protocol/src/client/play/player_info_update.rs
+++ b/pumpkin-protocol/src/client/play/player_info_update.rs
@@ -1,5 +1,6 @@
 use std::io::Write;
 
+use bitflags::bitflags;
 use pumpkin_data::packet::clientbound::PLAY_PLAYER_INFO_UPDATE;
 use pumpkin_macros::packet;
 
@@ -10,9 +11,23 @@ use crate::{
 
 use super::PlayerAction;
 
+bitflags! {
+    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
+    pub struct PlayerInfoFlags: u8 {
+        const ADD_PLAYER            = 0x01;
+        const INITIALIZE_CHAT       = 0x02;
+        const UPDATE_GAME_MODE      = 0x04;
+        const UPDATE_LISTED         = 0x08;
+        const UPDATE_LATENCY        = 0x10;
+        const UPDATE_DISPLAY_NAME   = 0x20;
+        const UPDATE_LIST_PRIORITY  = 0x40;
+        const UPDATE_HAT            = 0x80;
+    }
+}
+
 #[packet(PLAY_PLAYER_INFO_UPDATE)]
 pub struct CPlayerInfoUpdate<'a> {
-    pub actions: i8,
+    pub actions: u8,
     pub players: &'a [Player<'a>],
 }
 
@@ -22,7 +37,7 @@ pub struct Player<'a> {
 }
 
 impl<'a> CPlayerInfoUpdate<'a> {
-    pub fn new(actions: i8, players: &'a [Player<'a>]) -> Self {
+    pub fn new(actions: u8, players: &'a [Player<'a>]) -> Self {
         Self { actions, players }
     }
 }
@@ -31,7 +46,7 @@ impl ClientPacket for CPlayerInfoUpdate<'_> {
     fn write_packet_data(&self, write: impl Write) -> Result<(), WritingError> {
         let mut write = write;
 
-        write.write_i8_be(self.actions)?;
+        write.write_u8_be(self.actions)?;
         write.write_list::<Player>(self.players, |p, v| {
             p.write_uuid(&v.uuid)?;
             for action in v.actions {
diff --git a/pumpkin-util/src/math/mod.rs b/pumpkin-util/src/math/mod.rs
index 7cf7025ad7..e76445d3a9 100644
--- a/pumpkin-util/src/math/mod.rs
+++ b/pumpkin-util/src/math/mod.rs
@@ -169,3 +169,75 @@ pub fn lerp3(
         lerp2(delta_x, delta_y, x0y0z1, x1y0z1, x0y1z1, x1y1z1),
     )
 }
+
+/// Calculates a Polynomial Rolling Hash
+/// Mojang's checksum algorithm for previous messages
+pub fn polynomial_rolling_hash(signatures: &[Box<[u8]>]) -> u8 {
+    let mut i: i32 = 1;
+
+    for signature in signatures.iter() {
+        i = i.wrapping_mul(31).wrapping_add(java_array_hash(signature)); // Wrap to prevent multiplication overflow
+    }
+
+    let b = (i & 0xFF) as u8; // Take the least significant byte.
+    if b == 0 { 1 } else { b } // Ensure the checksum is never zero.
+}
+
+/// Arrays.hashCode() and String.hashCode() have similar but different implementations.
+fn java_array_hash(data: &[u8]) -> i32 {
+    let mut hash: i32 = 1;
+    for &byte in data {
+        let signed_byte = byte as i32;
+        hash = hash.wrapping_mul(31).wrapping_add(signed_byte);
+    }
+    hash
+}
+
+pub fn java_string_hash(string: &str) -> i32 {
+    let mut result = 0i32;
+    for char_encoding in string.encode_utf16() {
+        result = 31i32
+            .wrapping_mul(result)
+            .wrapping_add(char_encoding as i32);
+    }
+    result
+}
+
+#[test]
+fn test_java_hash() {
+    let values = [
+        ("", 0, 1),
+        ("1", 49, 80),
+        ("TEST", 2571410, 3494931),
+        ("TEST1", 79713759, 108342910),
+        ("TEST0123456789", 506557463, 2014109272),
+        (
+            " !\"#$%&'()*+,-./0123456789:\
+            ;<=>?@ABCDEFGHIJKLMNOPQRST\
+            UVWXYZ[\\]^_`abcdefghijklm\
+            nopqrstuvwxyz{|}~¡¢£¤¥¦§¨©\
+            ª«¬®¯°±²³´µ¶·¸¹º»¼½¾¿ÀÁÂÃÄ\
+            ÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖ×ØÙÚÛÜÝÞ\
+            ßàáâãäåæçèéêëìíîïðñòóôõö÷øùúûüýþ",
+            -1992287231i32,
+            -1606003975i32,
+        ),
+        ("求同存异", 847053876, 1709557670),
+        // This might look weird because hebrew is text is right to left
+        (
+            "אבְּרֵאשִׁ֖ית בָּרָ֣א אֱלֹהִ֑ים אֵ֥ת הַשָּׁמַ֖יִם וְאֵ֥ת הָאָֽרֶץ:",
+            1372570871,
+            -396640725i32,
+        ),
+        ("संस्कृत-", 1748614838, -187482695i32),
+        ("minecraft:offset", -920384768i32, 432924929),
+    ];
+
+    for (string, value, _) in values {
+        assert_eq!(java_string_hash(string), value);
+    }
+
+    for (string, _, value) in values {
+        assert_eq!(java_array_hash(string.as_bytes()), value);
+    }
+}
diff --git a/pumpkin-util/src/random/legacy_rand.rs b/pumpkin-util/src/random/legacy_rand.rs
index e47321fef5..5f4921eefd 100644
--- a/pumpkin-util/src/random/legacy_rand.rs
+++ b/pumpkin-util/src/random/legacy_rand.rs
@@ -1,8 +1,10 @@
 use super::{
     RandomDeriver, RandomDeriverImpl, RandomGenerator, RandomImpl, gaussian::GaussianGenerator,
-    hash_block_pos, java_string_hash,
+    hash_block_pos,
 };
 
+use crate::math::java_string_hash;
+
 pub struct LegacyRand {
     seed: u64,
     internal_next_gaussian: Option<f64>,
diff --git a/pumpkin-util/src/random/mod.rs b/pumpkin-util/src/random/mod.rs
index e4761dda15..dc621ce745 100644
--- a/pumpkin-util/src/random/mod.rs
+++ b/pumpkin-util/src/random/mod.rs
@@ -101,26 +101,9 @@ pub fn hash_block_pos(x: i32, y: i32, z: i32) -> i64 {
     l >> 16
 }
 
-pub fn java_string_hash(string: &str) -> i32 {
-    // All byte values of latin1 align with
-    // the values of U+0000 - U+00FF, making this code
-    // equivalent to both Java hash implementations
-
-    let mut result = 0i32;
-
-    for char_encoding in string.encode_utf16() {
-        result = 31i32
-            .wrapping_mul(result)
-            .wrapping_add(char_encoding as i32);
-    }
-    result
-}
-
 #[cfg(test)]
 mod tests {
 
-    use crate::random::java_string_hash;
-
     use super::hash_block_pos;
 
     #[test]
@@ -140,34 +123,4 @@ mod tests {
             assert_eq!(hash_block_pos(x, y, z), value);
         }
     }
-
-    #[test]
-    fn test_java_string_hash() {
-        let values = [
-            ("", 0),
-            ("1", 49),
-            ("TEST", 2571410),
-            ("TEST1", 79713759),
-            ("TEST0123456789", 506557463),
-            (
-                " !\"#$%&'()*+,-./0123456789:\
-                ;<=>?@ABCDEFGHIJKLMNOPQRST\
-                UVWXYZ[\\]^_`abcdefghijklm\
-                nopqrstuvwxyz{|}~¡¢£¤¥¦§¨©\
-                ª«¬®¯°±²³´µ¶·¸¹º»¼½¾¿ÀÁÂÃÄ\
-                ÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖ×ØÙÚÛÜÝÞ\
-                ßàáâãäåæçèéêëìíîïðñòóôõö÷øùúûüýþ",
-                -1992287231i32,
-            ),
-            ("求同存异", 847053876),
-            // This might look weird because hebrew is text is right to left
-            ("אבְּרֵאשִׁ֖ית בָּרָ֣א אֱלֹהִ֑ים אֵ֥ת הַשָּׁמַ֖יִם וְאֵ֥ת הָאָֽרֶץ:", 1372570871),
-            ("संस्कृत-", 1748614838),
-            ("minecraft:offset", -920384768i32),
-        ];
-
-        for (string, value) in values {
-            assert_eq!(java_string_hash(string), value);
-        }
-    }
 }
diff --git a/pumpkin-world/src/generation/seed.rs b/pumpkin-world/src/generation/seed.rs
index 7220986702..f916d37318 100644
--- a/pumpkin-world/src/generation/seed.rs
+++ b/pumpkin-world/src/generation/seed.rs
@@ -1,4 +1,5 @@
-use pumpkin_util::random::{RandomImpl, get_seed, java_string_hash, legacy_rand::LegacyRand};
+use pumpkin_util::math::java_string_hash;
+use pumpkin_util::random::{RandomImpl, get_seed, legacy_rand::LegacyRand};
 
 #[derive(Clone, Copy)]
 pub struct Seed(pub u64);
diff --git a/pumpkin/Cargo.toml b/pumpkin/Cargo.toml
index ef15a1709d..cbe172a744 100644
--- a/pumpkin/Cargo.toml
+++ b/pumpkin/Cargo.toml
@@ -51,7 +51,7 @@ num-bigint = "0.4"
 rustyline-async = "0.4.5"
 
 # encryption
-rsa = "0.9"
+rsa = { version = "0.9", features = ["sha1"] }
 rsa-der = "0.3"
 
 # authentication
diff --git a/pumpkin/src/entity/player.rs b/pumpkin/src/entity/player.rs
index 702c0cd0c6..8ccb7f3e86 100644
--- a/pumpkin/src/entity/player.rs
+++ b/pumpkin/src/entity/player.rs
@@ -48,7 +48,7 @@ use pumpkin_inventory::player::{
 use pumpkin_macros::send_cancellable;
 use pumpkin_nbt::compound::NbtCompound;
 use pumpkin_nbt::tag::NbtTag;
-use pumpkin_protocol::client::play::CSetHeldItem;
+use pumpkin_protocol::client::play::{CSetHeldItem, PlayerInfoFlags, PreviousMessage};
 use pumpkin_protocol::{
     IdOr, RawPacket, ServerPacket,
     client::play::{
@@ -92,6 +92,9 @@ use pumpkin_world::{cylindrical_chunk_iterator::Cylindrical, item::ItemStack, le
 use tokio::{sync::Mutex, task::JoinHandle};
 use uuid::Uuid;
 
+const MAX_CACHED_SIGNATURES: u8 = 128; // Vanilla: 128
+const MAX_PREVIOUS_MESSAGES: u8 = 20; // Vanilla: 20
+
 enum BatchState {
     Initial,
     Waiting,
@@ -235,6 +238,7 @@ pub struct Player {
     pub chunk_manager: Mutex<ChunkManager>,
     pub has_played_before: AtomicBool,
     pub chat_session: Arc<Mutex<ChatSession>>,
+    pub signature_cache: Mutex<MessageCache>,
 }
 
 impl Player {
@@ -319,6 +323,7 @@ impl Player {
             last_food_saturation: AtomicBool::new(true),
             has_played_before: AtomicBool::new(false),
             chat_session: Arc::new(Mutex::new(ChatSession::default())), // Placeholder value until the player actually sets their session id
+            signature_cache: Mutex::new(MessageCache::default()),
         }
     }
 
@@ -1132,8 +1137,7 @@ impl Player {
                     .read()
                     .await
                     .broadcast_packet_all(&CPlayerInfoUpdate::new(
-                        // TODO: Remove magic number
-                        0x04,
+                        PlayerInfoFlags::UPDATE_GAME_MODE.bits(),
                         &[pumpkin_protocol::client::play::Player {
                             uuid: self.gameprofile.id,
                             actions: &[PlayerAction::UpdateGameMode((gamemode as i32).into())],
@@ -1855,7 +1859,6 @@ impl TryFrom<i32> for ChatMode {
 }
 
 /// Player's current chat session
-#[derive(Debug)]
 pub struct ChatSession {
     pub session_id: uuid::Uuid,
     pub expires_at: i64,
@@ -1863,6 +1866,7 @@ pub struct ChatSession {
     pub signature: Box<[u8]>,
     pub messages_sent: i32,
     pub messages_received: i32,
+    pub signature_cache: Vec<Box<[u8]>>,
 }
 
 impl Default for ChatSession {
@@ -1886,6 +1890,99 @@ impl ChatSession {
             signature: key_signature,
             messages_sent: 0,
             messages_received: 0,
+            signature_cache: Vec::new(),
+        }
+    }
+}
+
+#[derive(Clone, Default)]
+pub struct LastSeen(Vec<Box<[u8]>>);
+
+impl From<LastSeen> for Vec<Box<[u8]>> {
+    fn from(seen: LastSeen) -> Self {
+        seen.0
+    }
+}
+
+impl AsRef<[Box<[u8]>]> for LastSeen {
+    fn as_ref(&self) -> &[Box<[u8]>] {
+        &self.0
+    }
+}
+
+impl LastSeen {
+    /// The sender's `last_seen` signatures are sent as ID's if the recipient has them in their cache.
+    /// Otherwise, the full signature is sent. (ID:0 indicates full signature is being sent)
+    pub async fn indexed_for(&self, recipient: &Arc<Player>) -> Box<[PreviousMessage]> {
+        let mut indexed = Vec::new();
+        for signature in &self.0 {
+            if let Some(index) = recipient
+                .signature_cache
+                .lock()
+                .await
+                .full_cache
+                .iter()
+                .position(|s| s == signature)
+            {
+                indexed.push(PreviousMessage {
+                    // Send ID reference to recipient's cache (index + 1 because 0 is reserved for full signature)
+                    id: VarInt(1 + index as i32),
+                    signature: None,
+                });
+            } else {
+                indexed.push(PreviousMessage {
+                    // Send ID as 0 for full signature
+                    id: VarInt(0),
+                    signature: Some(signature.clone()),
+                });
+            }
+        }
+        indexed.into_boxed_slice()
+    }
+}
+
+pub struct MessageCache {
+    /// max 128 cached message signatures. Most recent FIRST.
+    /// Server should (when possible) reference indexes in this (recipient's) cache instead of sending full signatures in last seen.
+    /// Must be 1:1 with client's signature cache.
+    full_cache: VecDeque<Box<[u8]>>,
+    /// max 20 last seen messages by the sender. Most Recent LAST
+    pub last_seen: LastSeen,
+}
+
+impl Default for MessageCache {
+    fn default() -> Self {
+        Self {
+            full_cache: VecDeque::with_capacity(MAX_CACHED_SIGNATURES as usize),
+            last_seen: LastSeen::default(),
+        }
+    }
+}
+
+impl MessageCache {
+    /// Not used for caching seen messages. Only for non-indexed signatures from senders.
+    pub fn cache_signatures(&mut self, signatures: &[Box<[u8]>]) {
+        for sig in signatures.iter().rev() {
+            if self.full_cache.contains(sig) {
+                continue;
+            }
+            // If the cache is maxed, and someone sends a signature older than the oldest in cache, ignore it
+            if self.full_cache.len() < MAX_CACHED_SIGNATURES as usize {
+                self.full_cache.push_back(sig.clone()); // Recipient never saw this message so it must be older than the oldest in cache
+            }
+        }
+    }
+
+    /// Adds a seen signature to `last_seen` and `full_cache`.
+    pub fn add_seen_signature(&mut self, signature: &[u8]) {
+        if self.last_seen.0.len() >= MAX_PREVIOUS_MESSAGES as usize {
+            self.last_seen.0.remove(0);
+        }
+        self.last_seen.0.push(signature.into());
+        // This probably doesn't need to be a loop, but better safe than sorry
+        while self.full_cache.len() >= MAX_CACHED_SIGNATURES as usize {
+            self.full_cache.pop_back();
         }
+        self.full_cache.push_front(signature.into()); // Since recipient saw this message it will be most recent in cache
     }
 }
diff --git a/pumpkin/src/lib.rs b/pumpkin/src/lib.rs
index b342532cb0..1c394765c5 100644
--- a/pumpkin/src/lib.rs
+++ b/pumpkin/src/lib.rs
@@ -4,6 +4,7 @@
 use crate::net::{Client, lan_broadcast, query, rcon::RCONServer};
 use crate::server::{Server, ticker::Ticker};
 use log::{Level, LevelFilter, Log};
+use net::authentication::fetch_mojang_public_keys;
 use plugin::PluginManager;
 use plugin::server::server_command::ServerCommandEvent;
 use pumpkin_config::{BASIC_CONFIG, advanced_config};
@@ -220,6 +221,13 @@ impl PumpkinServer {
             server.spawn_task(lan_broadcast::start_lan_broadcast(addr));
         }
 
+        if BASIC_CONFIG.allow_chat_reports {
+            let mojang_public_keys = fetch_mojang_public_keys(server.auth_client.as_ref().unwrap())
+                .await
+                .unwrap();
+            *server.mojang_public_keys.lock().await = mojang_public_keys;
+        }
+
         // Ticker
         {
             let ticker_server = server.clone();
diff --git a/pumpkin/src/net/authentication.rs b/pumpkin/src/net/authentication.rs
index f56972dfea..2fb595787e 100644
--- a/pumpkin/src/net/authentication.rs
+++ b/pumpkin/src/net/authentication.rs
@@ -4,6 +4,8 @@ use base64::{Engine, engine::general_purpose};
 use pumpkin_config::{advanced_config, networking::auth::TextureConfig};
 use pumpkin_protocol::Property;
 use reqwest::{StatusCode, Url};
+use rsa::RsaPublicKey;
+use rsa::pkcs8::DecodePublicKey;
 use serde::Deserialize;
 use thiserror::Error;
 use uuid::Uuid;
@@ -28,8 +30,24 @@ pub struct Texture {
     metadata: Option<HashMap<String, String>>,
 }
 
+#[derive(Deserialize, Clone, Debug)]
+pub struct JsonPublicKey {
+    #[serde(rename = "publicKey")]
+    pub public_key: String,
+}
+#[derive(Deserialize, Clone, Debug)]
+pub struct MojangPublicKeys {
+    #[serde(rename = "profilePropertyKeys")]
+    pub profile_property_keys: Vec<JsonPublicKey>,
+    #[serde(rename = "playerCertificateKeys")]
+    pub player_certificate_keys: Vec<JsonPublicKey>,
+    #[serde(rename = "authenticationKeys")]
+    pub authentication_keys: Option<Vec<JsonPublicKey>>,
+}
+
 const MOJANG_AUTHENTICATION_URL: &str = "https://sessionserver.mojang.com/session/minecraft/hasJoined?username={username}&serverId={server_hash}";
 const MOJANG_PREVENT_PROXY_AUTHENTICATION_URL: &str = "https://sessionserver.mojang.com/session/minecraft/hasJoined?username={username}&serverId={server_hash}";
+const MOJANG_SERVICES_URL: &str = "https://api.minecraftservices.com/";
 
 /// Sends a GET request to Mojang's authentication servers to verify a client's Minecraft account.
 ///
@@ -127,6 +145,47 @@ pub fn is_texture_url_valid(url: &Url, config: &TextureConfig) -> Result<(), Tex
     Ok(())
 }
 
+pub async fn fetch_mojang_public_keys(
+    auth_client: &reqwest::Client,
+) -> Result<Vec<RsaPublicKey>, AuthError> {
+    let services_url = advanced_config()
+        .networking
+        .authentication
+        .services_url
+        .as_deref()
+        .unwrap_or(MOJANG_SERVICES_URL);
+
+    let url = format!("{services_url}/publickeys");
+
+    let response = auth_client
+        .get(url)
+        .send()
+        .await
+        .map_err(|_| AuthError::FailedResponse)?;
+
+    match response.status() {
+        StatusCode::OK => {}
+        StatusCode::NO_CONTENT => Err(AuthError::FailedResponse)?,
+        other => Err(AuthError::UnknownStatusCode(other))?,
+    }
+
+    let public_keys: MojangPublicKeys =
+        response.json().await.map_err(|_| AuthError::FailedParse)?;
+
+    let as_rsa_keys = public_keys
+        .player_certificate_keys
+        .into_iter()
+        .map(|key| {
+            let decoded_key = general_purpose::STANDARD
+                .decode(key.public_key.as_bytes())
+                .map_err(|_| AuthError::FailedParse)?;
+            RsaPublicKey::from_public_key_der(&decoded_key).map_err(|_| AuthError::FailedParse)
+        })
+        .collect::<Result<Vec<_>, _>>()?;
+
+    Ok(as_rsa_keys)
+}
+
 #[derive(Error, Debug)]
 pub enum AuthError {
     #[error("Missing auth client")]
diff --git a/pumpkin/src/net/mod.rs b/pumpkin/src/net/mod.rs
index 2629dd07e4..86985299a5 100644
--- a/pumpkin/src/net/mod.rs
+++ b/pumpkin/src/net/mod.rs
@@ -56,7 +56,7 @@ use tokio::{
 use thiserror::Error;
 use tokio_util::task::TaskTracker;
 use uuid::Uuid;
-mod authentication;
+pub mod authentication;
 mod container;
 pub mod lan_broadcast;
 mod packet;
diff --git a/pumpkin/src/net/packet/play.rs b/pumpkin/src/net/packet/play.rs
index e703963447..57d6a8ea2e 100644
--- a/pumpkin/src/net/packet/play.rs
+++ b/pumpkin/src/net/packet/play.rs
@@ -1,5 +1,10 @@
+use rsa::pkcs1v15::{Signature as RsaPkcs1v15Signature, VerifyingKey};
+use rsa::signature::Verifier;
+use sha1::Sha1;
+
 use std::num::NonZeroU8;
 use std::sync::Arc;
+use std::time::{SystemTime, UNIX_EPOCH};
 
 use crate::block;
 use crate::block::registry::BlockActionResult;
@@ -51,6 +56,7 @@ use pumpkin_protocol::{
     },
 };
 use pumpkin_util::math::boundingbox::BoundingBox;
+use pumpkin_util::math::polynomial_rolling_hash;
 use pumpkin_util::math::position::BlockPos;
 use pumpkin_util::text::color::NamedColor;
 use pumpkin_util::{
@@ -65,6 +71,10 @@ use pumpkin_world::item::ItemStack;
 
 use thiserror::Error;
 
+/// In secure chat mode, Player will be kicked if they send a chat message with a timestamp that is older than this (in ms)
+/// Vanilla: 2 minutes
+const CHAT_MESSAGE_MAX_AGE: i64 = 1000 * 60 * 2;
+
 #[derive(Debug, Error)]
 pub enum BlockPlacingError {
     BlockOutOfReach,
@@ -107,6 +117,68 @@ impl PumpkinError for BlockPlacingError {
     }
 }
 
+#[derive(Debug, Error)]
+pub enum ChatError {
+    #[error("sent an oversized message")]
+    OversizedMessage,
+    #[error("sent a message with illegal characters")]
+    IllegalCharacters,
+    #[error("sent a chat with invalid/no signature")]
+    UnsignedChat,
+    #[error("has too many unacknowledged chats queued")]
+    TooManyPendingChats,
+    #[error("sent a chat that couldn't be validated")]
+    ChatValidationFailed,
+    #[error("sent a chat with an out of order timestamp")]
+    OutOfOrderChat,
+    #[error("has an expired public key")]
+    ExpiredPublicKey,
+    #[error("attempted to initialize a session with an invalid public key")]
+    InvalidPublicKey,
+}
+
+impl PumpkinError for ChatError {
+    fn is_kick(&self) -> bool {
+        true
+    }
+
+    fn severity(&self) -> log::Level {
+        log::Level::Warn
+    }
+
+    fn client_kick_reason(&self) -> Option<String> {
+        match self {
+            Self::OversizedMessage => Some("Chat message too long".into()),
+            Self::IllegalCharacters => Some(
+                TextComponent::translate("multiplayer.disconnect.illegal_characters", [])
+                    .get_text(),
+            ),
+            Self::UnsignedChat => Some(
+                TextComponent::translate("multiplayer.disconnect.unsigned_chat", []).get_text(),
+            ),
+            Self::TooManyPendingChats => Some(
+                TextComponent::translate("multiplayer.disconnect.too_many_pending_chats", [])
+                    .get_text(),
+            ),
+            Self::ChatValidationFailed => Some(
+                TextComponent::translate("multiplayer.disconnect.chat_validation_failed", [])
+                    .get_text(),
+            ),
+            Self::OutOfOrderChat => Some(
+                TextComponent::translate("multiplayer.disconnect.out_of_order_chat", []).get_text(),
+            ),
+            Self::ExpiredPublicKey => Some(
+                TextComponent::translate("multiplayer.disconnect.expired_public_key", [])
+                    .get_text(),
+            ),
+            Self::InvalidPublicKey => Some(
+                TextComponent::translate("multiplayer.disconnect.invalid_public_key_signature", [])
+                    .get_text(),
+            ),
+        }
+    }
+}
+
 /// Handles all Play packets sent by a real player.
 /// NEVER TRUST THE CLIENT. HANDLE EVERY ERROR; UNWRAP/EXPECT ARE FORBIDDEN.
 impl Player {
@@ -664,24 +736,26 @@ impl Player {
     }
 
     pub async fn handle_chat_message(self: &Arc<Self>, chat_message: SChatMessage) {
-        let message = chat_message.message.clone();
-        if message.len() > 256 {
-            self.kick(TextComponent::text("Oversized message")).await;
-            return;
-        }
-        if message.chars().any(|c| c == '§' || c < ' ' || c == '\x7F') {
-            self.kick(TextComponent::translate(
-                "multiplayer.disconnect.illegal_characters",
-                [],
-            ))
-            .await;
+        let gameprofile = &self.gameprofile;
+
+        if let Err(err) = self.validate_chat_message(&chat_message).await {
+            log::log!(
+                err.severity(),
+                "{} (uuid {}) {}",
+                gameprofile.name,
+                gameprofile.id,
+                err
+            );
+            if err.is_kick() {
+                if let Some(reason) = err.client_kick_reason() {
+                    self.kick(TextComponent::text(reason)).await;
+                }
+            }
             return;
         }
 
-        let gameprofile = &self.gameprofile;
-
         send_cancellable! {{
-            PlayerChatEvent::new(self.clone(), message.clone(), vec![]);
+            PlayerChatEvent::new(self.clone(), chat_message.message.clone(), vec![]);
 
             'after: {
                 log::info!("<chat> {}: {}", gameprofile.name, event.message);
@@ -709,6 +783,63 @@ impl Player {
         }}
     }
 
+    /// Runs all vanilla checks for a valid chat message
+    pub async fn validate_chat_message(
+        &self,
+        chat_message: &SChatMessage,
+    ) -> Result<(), ChatError> {
+        // Check for oversized messages
+        if chat_message.message.len() > 256 {
+            return Err(ChatError::OversizedMessage);
+        }
+        // Check for illegal characters
+        if chat_message
+            .message
+            .chars()
+            .any(|c| c == '§' || c < ' ' || c == '\x7F')
+        {
+            return Err(ChatError::IllegalCharacters);
+        }
+        // These checks are only run in secure chat mode
+        if BASIC_CONFIG.allow_chat_reports {
+            // Check for unsigned chat
+            if let Some(signature) = &chat_message.signature {
+                if signature.len() != 256 {
+                    return Err(ChatError::UnsignedChat); // Signature is the wrong length
+                }
+            } else {
+                return Err(ChatError::UnsignedChat); // There is no signature
+            }
+
+            let now = SystemTime::now()
+                .duration_since(UNIX_EPOCH)
+                .unwrap_or_default()
+                .as_millis() as i64;
+
+            // Verify message timestamp
+            if chat_message.timestamp > now || chat_message.timestamp < (now - CHAT_MESSAGE_MAX_AGE)
+            {
+                return Err(ChatError::OutOfOrderChat);
+            }
+
+            // Verify session expiry
+            if self.chat_session.lock().await.expires_at < now {
+                return Err(ChatError::ExpiredPublicKey);
+            }
+
+            // Validate previous signature checksum (new in 1.21.5)
+            // The client can bypass this check by sending 0
+            if chat_message.checksum != 0 {
+                let checksum =
+                    polynomial_rolling_hash(self.signature_cache.lock().await.last_seen.as_ref());
+                if checksum != chat_message.checksum {
+                    return Err(ChatError::ChatValidationFailed);
+                }
+            }
+        }
+        Ok(())
+    }
+
     pub async fn handle_chat_session_update(
         self: &Arc<Self>,
         server: &Server,
@@ -719,6 +850,22 @@ impl Player {
             return;
         }
 
+        if let Err(err) = self.validate_chat_session(server, &session).await {
+            log::log!(
+                err.severity(),
+                "{} (uuid {}) {}",
+                self.gameprofile.name,
+                self.gameprofile.id,
+                err
+            );
+            if err.is_kick() {
+                if let Some(reason) = err.client_kick_reason() {
+                    self.kick(TextComponent::text(reason)).await;
+                }
+            }
+            return;
+        }
+
         // Update the chat session fields
         let mut chat_session = self.chat_session.lock().await; // Await the lock
 
@@ -746,6 +893,49 @@ impl Player {
             .await;
     }
 
+    /// Runs vanilla checks for a valid player session
+    pub async fn validate_chat_session(
+        &self,
+        server: &Server,
+        session: &SPlayerSession,
+    ) -> Result<(), ChatError> {
+        // Verify session expiry
+        let now = SystemTime::now()
+            .duration_since(UNIX_EPOCH)
+            .unwrap_or_default()
+            .as_millis() as i64;
+        if session.expires_at < now {
+            return Err(ChatError::InvalidPublicKey);
+        }
+
+        // Verify signature with RSA-SHA1
+        let mojang_verifying_keys = server
+            .mojang_public_keys
+            .lock()
+            .await
+            .iter()
+            .map(|key| VerifyingKey::<Sha1>::new(key.clone()))
+            .collect::<Vec<_>>();
+
+        let key_signature = RsaPkcs1v15Signature::try_from(session.key_signature.as_ref())
+            .map_err(|_| ChatError::InvalidPublicKey)?;
+
+        let mut signable = Vec::new();
+        signable.extend_from_slice(self.gameprofile.id.as_bytes());
+        signable.extend_from_slice(&session.expires_at.to_be_bytes());
+        signable.extend_from_slice(&session.public_key);
+
+        // Verify that the signable is valid for any one of Mojang's public keys
+        if !mojang_verifying_keys
+            .iter()
+            .any(|key| key.verify(&signable, &key_signature).is_ok())
+        {
+            return Err(ChatError::InvalidPublicKey);
+        }
+
+        Ok(())
+    }
+
     pub async fn handle_client_information(
         self: &Arc<Self>,
         client_information: SClientInformationPlay,
diff --git a/pumpkin/src/server/mod.rs b/pumpkin/src/server/mod.rs
index 5ff9eb13a1..8e32ddfa23 100644
--- a/pumpkin/src/server/mod.rs
+++ b/pumpkin/src/server/mod.rs
@@ -27,6 +27,7 @@ use pumpkin_util::math::vector2::Vector2;
 use pumpkin_util::text::TextComponent;
 use pumpkin_world::dimension::Dimension;
 use rand::prelude::SliceRandom;
+use rsa::RsaPublicKey;
 use std::collections::HashMap;
 use std::net::IpAddr;
 use std::sync::atomic::AtomicU32;
@@ -79,6 +80,9 @@ pub struct Server {
     /// Manages player data storage
     pub player_data_storage: ServerPlayerData,
     tasks: TaskTracker,
+    /// Mojang's public keys, used for chat session signing
+    /// Pulled from Mojang API on startup
+    pub mojang_public_keys: Mutex<Vec<RsaPublicKey>>,
 }
 
 impl Server {
@@ -139,6 +143,7 @@ impl Server {
                 Duration::from_secs(advanced_config().player_data.save_player_cron_interval),
             ),
             tasks: TaskTracker::new(),
+            mojang_public_keys: Mutex::new(Vec::new()),
         }
     }
 
diff --git a/pumpkin/src/world/mod.rs b/pumpkin/src/world/mod.rs
index e36e6bcf7b..84627a1093 100644
--- a/pumpkin/src/world/mod.rs
+++ b/pumpkin/src/world/mod.rs
@@ -38,7 +38,7 @@ use pumpkin_protocol::{
     client::play::{
         CEntityStatus, CGameEvent, CLogin, CMultiBlockUpdate, CPlayerChatMessage,
         CPlayerInfoUpdate, CRemoveEntities, CRemovePlayerInfo, CSoundEffect, CSpawnEntity,
-        FilterType, GameEvent, InitChat, PlayerAction, PreviousMessage,
+        FilterType, GameEvent, InitChat, PlayerAction, PlayerInfoFlags,
     },
     server::play::SChatMessage,
 };
@@ -155,9 +155,6 @@ pub struct World {
     /// A map of unsent block changes, keyed by block position.
     unsent_block_changes: Mutex<HashMap<BlockPos, u16>>,
     // TODO: entities
-    /// A log of the last 20 chat messages (or attempted chat messages) from players.
-    /// Used for previous message acknowledgement.
-    pub chat_log: Mutex<Box<[PreviousMessage]>>,
 }
 
 impl World {
@@ -183,7 +180,6 @@ impl World {
             block_registry,
             sea_level: generation_settings.sea_level,
             unsent_block_changes: Mutex::new(HashMap::new()),
-            chat_log: Mutex::new(Box::new([])),
         }
     }
 
@@ -231,105 +227,47 @@ impl World {
         chat_message: &SChatMessage,
         decorated_message: &TextComponent,
     ) {
-        // Todo: Proper ACK handling, handle signature chain de-sync
-
-        // Don't leave this locked in case sender is also recipient
-        let sender_messages_sent = {
-            let sender_chat_session = sender.chat_session.lock().await;
-            sender_chat_session.messages_sent
+        let messages_sent: i32 = sender.chat_session.lock().await.messages_sent;
+        let sender_last_seen = {
+            let cache = sender.signature_cache.lock().await;
+            cache.last_seen.clone()
         };
 
         let current_players = self.players.read().await;
-
         for (_, recipient) in current_players.iter() {
-            let mut recipient_chat_session = recipient.chat_session.lock().await;
-            let filtered_chat_log = self
-                .get_filtered_chat_log(
-                    chat_message.acknowledged.clone(),
-                    recipient_chat_session.messages_received == 0,
-                )
-                .await;
-
+            let messages_received: i32 = recipient.chat_session.lock().await.messages_received;
             let packet = &CPlayerChatMessage::new(
-                VarInt(recipient_chat_session.messages_received),
+                VarInt(messages_received),
                 sender.gameprofile.id,
-                VarInt(sender_messages_sent),
+                VarInt(messages_sent),
                 chat_message.signature.clone(),
                 chat_message.message.clone(),
                 chat_message.timestamp,
                 chat_message.salt,
-                filtered_chat_log.clone(),
+                sender_last_seen.indexed_for(recipient).await,
                 Some(decorated_message.clone()),
                 FilterType::PassThrough,
                 (RAW + 1).into(), // Custom registry chat_type with no sender name
                 TextComponent::text(""), // Not needed since we're injecting the name in the message for custom formatting
                 None,
             );
-
             recipient.client.enqueue_packet(packet).await;
 
-            recipient_chat_session.messages_received += 1;
-        }
-
-        sender.chat_session.lock().await.messages_sent += 1;
-
-        self.append_to_chat_log(chat_message.signature.clone())
-            .await;
-    }
-
-    /// Adds a message to the chat log and updates ids in the chat log
-    /// id 1 is most recent, id 20 is oldest
-    pub async fn append_to_chat_log(&self, signature: Option<Box<[u8]>>) {
-        let new_message = PreviousMessage {
-            id: VarInt(1),
-            signature,
-        };
-
-        let mut messages = Vec::from(self.chat_log.lock().await.as_ref());
-        messages.push(new_message);
-        if messages.len() > 20 {
-            messages.remove(0);
-        }
-        let messages_len = messages.len();
+            recipient
+                .signature_cache
+                .lock()
+                .await
+                .add_seen_signature(&chat_message.signature.clone().unwrap()); // Unwrap is safe because we check for None in validate_chat_message
 
-        for (i, message) in messages.iter_mut().enumerate() {
-            message.id = VarInt((messages_len - i) as i32);
+            let recipient_signature_cache = &mut recipient.signature_cache.lock().await;
+            if recipient.gameprofile.id != sender.gameprofile.id {
+                // Sender may update recipient on signatures recipient hasn't seen
+                recipient_signature_cache.cache_signatures(sender_last_seen.as_ref());
+            }
+            recipient.chat_session.lock().await.messages_received += 1;
         }
 
-        *self.chat_log.lock().await = messages.into_boxed_slice();
-    }
-
-    /// Returns a log of the last 20 messages, filtered by ACK bitset
-    /// Message ID should only be 0 if signatures are being sent
-    /// Signatures should only be sent in the first chat to each player
-    pub async fn get_filtered_chat_log(
-        &self,
-        acknowledged: Box<[u8]>,
-        first_message: bool,
-    ) -> Box<[PreviousMessage]> {
-        let bitset = format!(
-            "{:04b}{:08b}{:08b}",
-            acknowledged[2], acknowledged[1], acknowledged[0]
-        );
-
-        let filtered_log: Vec<PreviousMessage> = self
-            .chat_log
-            .lock()
-            .await
-            .iter()
-            .filter(|v| bitset.chars().nth(v.id.0 as usize - 1).unwrap_or('0') == '1') // Filter based on bitset
-            .map(|message| {
-                let mut message = message.clone();
-                if first_message {
-                    message.id = VarInt(0);
-                } else {
-                    message.signature = None;
-                }
-                message
-            }) // Clone the messages
-            .collect();
-
-        filtered_log.into_boxed_slice()
+        sender.chat_session.lock().await.messages_sent += 1;
     }
 
     /// Broadcasts a packet to all connected players within the world, excluding the specified players.
@@ -627,8 +565,7 @@ impl World {
         // and also send their info to everyone else.
         log::debug!("Broadcasting player info for {}", player.gameprofile.name);
         self.broadcast_packet_all(&CPlayerInfoUpdate::new(
-            // TODO: Remove magic numbers
-            0x01 | 0x04,
+            (PlayerInfoFlags::ADD_PLAYER | PlayerInfoFlags::UPDATE_GAME_MODE).bits(),
             &[pumpkin_protocol::client::play::Player {
                 uuid: gameprofile.id,
                 actions: &[
@@ -671,10 +608,9 @@ impl World {
                 current_player_data.push((&player.gameprofile.id, player_actions));
             }
 
-            // TODO: Remove magic numbers
-            let mut action_flags = 0x01;
+            let mut action_flags = PlayerInfoFlags::ADD_PLAYER;
             if base_config.allow_chat_reports {
-                action_flags |= 0x02;
+                action_flags |= PlayerInfoFlags::INITIALIZE_CHAT;
             }
 
             let entries = current_player_data
@@ -688,7 +624,7 @@ impl World {
             log::debug!("Sending player info to {}", player.gameprofile.name);
             player
                 .client
-                .enqueue_packet(&CPlayerInfoUpdate::new(action_flags, &entries))
+                .enqueue_packet(&CPlayerInfoUpdate::new(action_flags.bits(), &entries))
                 .await;
         };
 


Reviews:
user: login: kralverde
body: Looks really clean! Just have a couple of nit-picks
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T22:53:13Z
user: login: aaneeley
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T22:58:39Z
user: login: aaneeley
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T23:29:28Z
user: login: aaneeley
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T23:54:12Z
user: login: aaneeley
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T00:13:11Z
user: login: Snowiiii
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-04T06:16:52Z
user: login: Snowiiii
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-04T06:18:44Z
user: login: Snowiiii
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-04T06:19:43Z
user: login: aaneeley
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T15:37:06Z
user: login: aaneeley
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T17:30:55Z

Review comments:
diff_hunk: @@ -169,3 +169,26 @@ pub fn lerp3(
         lerp2(delta_x, delta_y, x0y0z1, x1y0z1, x0y1z1, x1y1z1),
     )
 }
+
+/// Calculates a Polynomial Rolling Hash
+/// Mojang's checksum algorithm for previous messages
+pub fn polynomial_rolling_hash(signatures: &[Box<[u8]>]) -> u8 {
+    let mut i: i32 = 1;
+
+    for signature in signatures.iter() {
+        i = i.wrapping_mul(31).wrapping_add(hash_code(signature)); // Wrap to prevent multiplication overflow
+    }
+
+    let b = (i & 0xFF) as u8; // Take the least significant byte.
+    if b == 0 { 1 } else { b } // Ensure the checksum is never zero.
+}
+
+/// Basically a 1:1 rewrite of Java's Arrays.hashCode()
+fn hash_code(data: &[u8]) -> i32 {
path: pumpkin-util/src/math/mod.rs
user: login: kralverde
body: I think this is also implemented somewhere in `pumpkin_util/random`. It is used in the legacy random string driver. Maybe add a generic "java hash" for use in both places?
created_at: 2025-04-03T22:47:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1886,6 +1890,87 @@ impl ChatSession {
             signature: key_signature,
             messages_sent: 0,
             messages_received: 0,
+            signature_cache: Vec::new(),
+        }
+    }
+}
+
+#[derive(Clone, Default)]
+pub struct LastSeen(Vec<Box<[u8]>>);
+
+impl From<LastSeen> for Vec<Box<[u8]>> {
+    fn from(seen: LastSeen) -> Self {
+        seen.0
+    }
+}
+
+impl AsRef<[Box<[u8]>]> for LastSeen {
+    fn as_ref(&self) -> &[Box<[u8]>] {
+        &self.0
+    }
+}
+
+impl LastSeen {
+    /// The sender's `last_seen` signatures are sent as ID's if the recipient has them in their cache.
+    /// Otherwise, the full signature is sent. (ID:0 indicates full signature is being sent)
+    pub async fn indexed_for(&self, recipient: &Arc<Player>) -> Box<[PreviousMessage]> {
+        let mut indexed = Vec::new();
+        for signature in &self.0 {
+            if let Some(index) = recipient
+                .signature_cache
+                .lock()
+                .await
+                .full_cache
+                .iter()
+                .position(|s| s == signature)
+            {
+                indexed.push(PreviousMessage {
+                    // Send ID reference to recipient's cache (index + 1 because 0 is reserved for full signature)
+                    id: VarInt(1 + index as i32),
+                    signature: None,
+                });
+            } else {
+                indexed.push(PreviousMessage {
+                    // Send ID as 0 for full signature
+                    id: VarInt(0),
+                    signature: Some(signature.clone()),
+                });
+            }
+        }
+        indexed.into_boxed_slice()
+    }
+}
+
+#[derive(Clone, Default)]
+pub struct MessageCache {
+    /// max 128 cached message signatures. Most recent FIRST.
+    /// Server should (when possible) reference indexes in this (recipient's) cache instead of sending full signatures in last seen.
+    /// Must be 1:1 with client's signature cache.
+    full_cache: Vec<Box<[u8]>>,
+    /// max 20 last seen messages by the sender. Most Recent LAST
+    pub last_seen: LastSeen,
+}
+
+impl MessageCache {
+    /// Not used for caching seen messages. Only for non-indexed signatures from senders.
+    pub fn cache_signatures(&mut self, signatures: &[Box<[u8]>]) {
+        for sig in signatures.iter().rev() {
+            if self.full_cache.contains(sig) {
+                // Already cached
+                continue;
+            }
+            self.full_cache.truncate(MAX_CACHED_SIGNATURES as usize);
path: pumpkin/src/entity/player.rs
user: login: kralverde
body: This leaves the `self.full_cache` with a length of `MAX_CACHED_SIGNATURES + 1` right? Also you might want to look into a `VecDeque` for efficient removal of the first element without shifting the elements after.
created_at: 2025-04-03T22:50:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -107,6 +117,77 @@ impl PumpkinError for BlockPlacingError {
     }
 }
 
+#[derive(Debug, Error)]
+pub enum ChatError {
+    #[error("sent an oversized message")]
+    OversizedMessage,
+    #[error("sent a message with illegal characters")]
+    IllegalCharacters,
+    #[error("sent a chat with invalid/no signature")]
+    UnsignedChat,
+    #[error("has too many unacknowledged chats queued")]
+    TooManyPendingChats,
+    #[error("sent a chat that couldn't be validated")]
+    ChatValidationFailed,
+    #[error("sent a chat with an out of order timestamp")]
+    OutOfOrderChat,
+    #[error("has an expired public key")]
+    ExpiredPublicKey,
+    #[error("attempted to initialize a session with an invalid public key")]
+    InvalidPublicKey,
+}
+
+impl PumpkinError for ChatError {
+    fn is_kick(&self) -> bool {
+        true
+    }
+
+    fn severity(&self) -> log::Level {
+        match self {
path: pumpkin/src/net/packet/play.rs
user: login: kralverde
body: Can't this just directly be `Level::Warn`?
created_at: 2025-04-03T22:51:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -107,6 +117,77 @@ impl PumpkinError for BlockPlacingError {
     }
 }
 
+#[derive(Debug, Error)]
+pub enum ChatError {
+    #[error("sent an oversized message")]
+    OversizedMessage,
+    #[error("sent a message with illegal characters")]
+    IllegalCharacters,
+    #[error("sent a chat with invalid/no signature")]
+    UnsignedChat,
+    #[error("has too many unacknowledged chats queued")]
+    TooManyPendingChats,
+    #[error("sent a chat that couldn't be validated")]
+    ChatValidationFailed,
+    #[error("sent a chat with an out of order timestamp")]
+    OutOfOrderChat,
+    #[error("has an expired public key")]
+    ExpiredPublicKey,
+    #[error("attempted to initialize a session with an invalid public key")]
+    InvalidPublicKey,
+}
+
+impl PumpkinError for ChatError {
+    fn is_kick(&self) -> bool {
+        true
+    }
+
+    fn severity(&self) -> log::Level {
+        match self {
path: pumpkin/src/net/packet/play.rs
user: login: aaneeley
body: Good catch. I wrote it like this initially in case I wanted to add some that weren't `Warn` but I never did.
created_at: 2025-04-03T22:58:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -169,3 +169,26 @@ pub fn lerp3(
         lerp2(delta_x, delta_y, x0y0z1, x1y0z1, x0y1z1, x1y1z1),
     )
 }
+
+/// Calculates a Polynomial Rolling Hash
+/// Mojang's checksum algorithm for previous messages
+pub fn polynomial_rolling_hash(signatures: &[Box<[u8]>]) -> u8 {
+    let mut i: i32 = 1;
+
+    for signature in signatures.iter() {
+        i = i.wrapping_mul(31).wrapping_add(hash_code(signature)); // Wrap to prevent multiplication overflow
+    }
+
+    let b = (i & 0xFF) as u8; // Take the least significant byte.
+    if b == 0 { 1 } else { b } // Ensure the checksum is never zero.
+}
+
+/// Basically a 1:1 rewrite of Java's Arrays.hashCode()
+fn hash_code(data: &[u8]) -> i32 {
path: pumpkin-util/src/math/mod.rs
user: login: aaneeley
body: Interestingly Java implements Arrays.hashCode and String.hashCode differently. Arrays.hashCode has a starting value of 1 vs 0 so the functions are not interchangeable as seen here:

![image](https://github.com/user-attachments/assets/5cf9fd5a-7bb3-4572-a4be-95598beefa3a)

I'll see if I can find a clean way to combine the functions
created_at: 2025-04-03T23:29:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -169,3 +169,26 @@ pub fn lerp3(
         lerp2(delta_x, delta_y, x0y0z1, x1y0z1, x0y1z1, x1y1z1),
     )
 }
+
+/// Calculates a Polynomial Rolling Hash
+/// Mojang's checksum algorithm for previous messages
+pub fn polynomial_rolling_hash(signatures: &[Box<[u8]>]) -> u8 {
+    let mut i: i32 = 1;
+
+    for signature in signatures.iter() {
+        i = i.wrapping_mul(31).wrapping_add(hash_code(signature)); // Wrap to prevent multiplication overflow
+    }
+
+    let b = (i & 0xFF) as u8; // Take the least significant byte.
+    if b == 0 { 1 } else { b } // Ensure the checksum is never zero.
+}
+
+/// Basically a 1:1 rewrite of Java's Arrays.hashCode()
+fn hash_code(data: &[u8]) -> i32 {
path: pumpkin-util/src/math/mod.rs
user: login: aaneeley
body: I think combining the functions would just increase complexity rather than decrease it. I'll go ahead a move the both functions/tests to `pumpkin_util/math/mod.rs` and rename them to make it more straightforward.
created_at: 2025-04-03T23:54:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1886,6 +1890,87 @@ impl ChatSession {
             signature: key_signature,
             messages_sent: 0,
             messages_received: 0,
+            signature_cache: Vec::new(),
+        }
+    }
+}
+
+#[derive(Clone, Default)]
+pub struct LastSeen(Vec<Box<[u8]>>);
+
+impl From<LastSeen> for Vec<Box<[u8]>> {
+    fn from(seen: LastSeen) -> Self {
+        seen.0
+    }
+}
+
+impl AsRef<[Box<[u8]>]> for LastSeen {
+    fn as_ref(&self) -> &[Box<[u8]>] {
+        &self.0
+    }
+}
+
+impl LastSeen {
+    /// The sender's `last_seen` signatures are sent as ID's if the recipient has them in their cache.
+    /// Otherwise, the full signature is sent. (ID:0 indicates full signature is being sent)
+    pub async fn indexed_for(&self, recipient: &Arc<Player>) -> Box<[PreviousMessage]> {
+        let mut indexed = Vec::new();
+        for signature in &self.0 {
+            if let Some(index) = recipient
+                .signature_cache
+                .lock()
+                .await
+                .full_cache
+                .iter()
+                .position(|s| s == signature)
+            {
+                indexed.push(PreviousMessage {
+                    // Send ID reference to recipient's cache (index + 1 because 0 is reserved for full signature)
+                    id: VarInt(1 + index as i32),
+                    signature: None,
+                });
+            } else {
+                indexed.push(PreviousMessage {
+                    // Send ID as 0 for full signature
+                    id: VarInt(0),
+                    signature: Some(signature.clone()),
+                });
+            }
+        }
+        indexed.into_boxed_slice()
+    }
+}
+
+#[derive(Clone, Default)]
+pub struct MessageCache {
+    /// max 128 cached message signatures. Most recent FIRST.
+    /// Server should (when possible) reference indexes in this (recipient's) cache instead of sending full signatures in last seen.
+    /// Must be 1:1 with client's signature cache.
+    full_cache: Vec<Box<[u8]>>,
+    /// max 20 last seen messages by the sender. Most Recent LAST
+    pub last_seen: LastSeen,
+}
+
+impl MessageCache {
+    /// Not used for caching seen messages. Only for non-indexed signatures from senders.
+    pub fn cache_signatures(&mut self, signatures: &[Box<[u8]>]) {
+        for sig in signatures.iter().rev() {
+            if self.full_cache.contains(sig) {
+                // Already cached
+                continue;
+            }
+            self.full_cache.truncate(MAX_CACHED_SIGNATURES as usize);
path: pumpkin/src/entity/player.rs
user: login: aaneeley
body: I'll go ahead and implement `VecDeque` for the full cache. I think keeping `last_seen` as a `Vec` is a good idea though because it requires frequent contiguous access.
created_at: 2025-04-04T00:13:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -841,6 +902,54 @@ impl Player {
             .await;
     }
 
+    /// Runs vanilla checks for a valid player session
+    pub async fn validate_chat_session(
+        &self,
+        server: &Server,
+        session: &SPlayerSession,
+    ) -> Result<(), ChatError> {
+        // Verify key length
+        if session.key_signature.len() != 512 {
+            return Err(ChatError::InvalidPublicKey);
+        }
path: pumpkin/src/net/packet/play.rs
user: login: Snowiiii
body: I think this should be done in the packet, so we only read 512 bytes directly
created_at: 2025-04-04T06:16:52Z
author_association: MEMBER
diff_hunk: @@ -38,12 +42,33 @@ use tokio::sync::{Mutex, RwLock};
 use tokio::task::JoinHandle;
 use tokio_util::task::TaskTracker;
 
+use base64::Engine;
+use base64::engine::general_purpose;
+
 mod connection_cache;
 mod key_store;
 pub mod ticker;
+use serde::Deserialize;
 
 pub const CURRENT_MC_VERSION: &str = "1.21.5";
 
+const MOJANG_SERVICES_URL: &str = "https://api.minecraftservices.com/";
+
+#[derive(Deserialize, Clone, Debug)]
+pub struct JsonPublicKey {
+    #[serde(rename = "publicKey")]
+    pub public_key: String,
+}
+#[derive(Deserialize, Clone, Debug)]
+pub struct MojangPublicKeys {
+    #[serde(rename = "profilePropertyKeys")]
+    pub profile_property_keys: Vec<JsonPublicKey>,
+    #[serde(rename = "playerCertificateKeys")]
+    pub player_certificate_keys: Vec<JsonPublicKey>,
+    #[serde(rename = "authenticationKeys")]
path: pumpkin/src/server/mod.rs
user: login: Snowiiii
body: this is the wrong place, this should be in a seperate file like the auth or inside the auth
created_at: 2025-04-04T06:18:43Z
author_association: MEMBER
diff_hunk: @@ -96,6 +96,20 @@ bitflags! {
     }
 }
 
+bitflags! {
+    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
+    pub struct PlayerInfoFlags: u8 {
+        const ADD_PLAYER            = 0x01;
+        const INITIALIZE_CHAT       = 0x02;
+        const UPDATE_GAME_MODE      = 0x04;
+        const UPDATE_LISTED         = 0x08;
+        const UPDATE_LATENCY        = 0x10;
+        const UPDATE_DISPLAY_NAME   = 0x20;
+        const UPDATE_LIST_PRIORITY  = 0x40;
+        const UPDATE_HAT            = 0x80;
+    }
+}
path: pumpkin/src/world/mod.rs
user: login: Snowiiii
body: What???. Why you placed this here
created_at: 2025-04-04T06:19:43Z
author_association: MEMBER
diff_hunk: @@ -96,6 +96,20 @@ bitflags! {
     }
 }
 
+bitflags! {
+    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
+    pub struct PlayerInfoFlags: u8 {
+        const ADD_PLAYER            = 0x01;
+        const INITIALIZE_CHAT       = 0x02;
+        const UPDATE_GAME_MODE      = 0x04;
+        const UPDATE_LISTED         = 0x08;
+        const UPDATE_LATENCY        = 0x10;
+        const UPDATE_DISPLAY_NAME   = 0x20;
+        const UPDATE_LIST_PRIORITY  = 0x40;
+        const UPDATE_HAT            = 0x80;
+    }
+}
path: pumpkin/src/world/mod.rs
user: login: aaneeley
body: You're right. Should probably go in same file as `CPlayerInfo` right?
created_at: 2025-04-04T15:37:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -841,6 +902,54 @@ impl Player {
             .await;
     }
 
+    /// Runs vanilla checks for a valid player session
+    pub async fn validate_chat_session(
+        &self,
+        server: &Server,
+        session: &SPlayerSession,
+    ) -> Result<(), ChatError> {
+        // Verify key length
+        if session.key_signature.len() != 512 {
+            return Err(ChatError::InvalidPublicKey);
+        }
path: pumpkin/src/net/packet/play.rs
user: login: aaneeley
body: It looks like the length isn't technically fixed at 512. It's length prefixed and size *could* change. I'll just remove this check so it doesn't break later if/when Mojang changes the size of `key_signature`
created_at: 2025-04-04T17:30:55Z
author_association: CONTRIBUTOR

PR comments:
user: login: kralverde
created_at: 2025-04-04T05:29:58Z
body: Kind of a nit, but since we control the signature queue, it think it would be best to use `VecDequeue::with_capacity(max_signatures)` and to control insertions such that they only happen after removals to maintain the size of the vec <= max_signatures. This will prevent possible unnecessary overallocation of the vec as it will never need to allocate more memory.
author_association: CONTRIBUTOR
user: login: Snowiiii
created_at: 2025-04-05T20:23:22Z
body: Looks good now. Thank you @aaneeley :+1: 
author_association: MEMBER