diff --git a/api/src/main/java/com/velocitypowered/api/event/player/PlayerChatEvent.java b/api/src/main/java/com/velocitypowered/api/event/player/PlayerChatEvent.java
index dd1fae29b..3b8636a0c 100644
--- a/api/src/main/java/com/velocitypowered/api/event/player/PlayerChatEvent.java
+++ b/api/src/main/java/com/velocitypowered/api/event/player/PlayerChatEvent.java
@@ -11,6 +11,7 @@ import com.google.common.base.Preconditions;
import com.velocitypowered.api.event.ResultedEvent;
import com.velocitypowered.api.event.annotation.AwaitingEvent;
import com.velocitypowered.api.proxy.Player;
+import com.velocitypowered.api.proxy.crypto.SignedMessage;
import java.util.Optional;
import org.checkerframework.checker.nullness.qual.NonNull;
import org.checkerframework.checker.nullness.qual.Nullable;
@@ -24,6 +25,7 @@ public final class PlayerChatEvent implements ResultedEventThe returned metadata describes the original protocol message. It is not affected by
+ * later plugin changes to the {@link ChatResult}. {@link #getMessage()} remains the
+ * compatibility plaintext view used by existing plugins.
+ *
+ * A signed message may be absent for unsigned or legacy chat. Its presence does not make
+ * cancelling or rewriting signed chat protocol-safe; the existing modern Minecraft signed-chat
+ * restrictions still apply.
+ *
+ * @return original message information
+ * @since 3.6.0
+ */
+ public MessageInfo getMessageInfo() {
+ return messageInfo;
+ }
+
@Override
public ChatResult getResult() {
return result;
@@ -131,4 +164,113 @@ public final class PlayerChatEvent implements ResultedEventThis object represents the message as submitted by the Minecraft client, before any plugin
+ * result can deny or replace the forwarded text.
+ *
+ * @since 3.6.0
+ */
+ public static final class MessageInfo {
+
+ private static final MessageInfo SIGNED = new MessageInfo(SignedState.SIGNED, null);
+ private static final MessageInfo UNSIGNED = new MessageInfo(SignedState.UNSIGNED, null);
+ private static final MessageInfo LEGACY = new MessageInfo(SignedState.LEGACY, null);
+
+ private final SignedState signedState;
+ private final @Nullable SignedMessage signedMessage;
+
+ private MessageInfo(SignedState signedState, @Nullable SignedMessage signedMessage) {
+ this.signedState = Preconditions.checkNotNull(signedState, "signedState");
+ this.signedMessage = signedMessage;
+ }
+
+ /**
+ * Returns the signing state of the original client-submitted chat message.
+ *
+ * @return the signed state
+ * @since 3.6.0
+ */
+ public SignedState getSignedState() {
+ return signedState;
+ }
+
+ /**
+ * Returns the original Minecraft signed message, if the client submitted one.
+ *
+ * The signed message body is the original signed body and is separate from any later
+ * {@link ChatResult} rewrite.
+ *
+ * @return the original signed message, if present
+ * @since 3.6.0
+ */
+ public Optional getSignedMessage() {
+ return Optional.ofNullable(signedMessage);
+ }
+
+ /**
+ * Creates message information for a signed Minecraft chat message.
+ *
+ * @param signedMessage the original client-submitted signed message
+ * @return signed message information
+ * @since 3.6.0
+ */
+ public static MessageInfo signed(SignedMessage signedMessage) {
+ return new MessageInfo(SignedState.SIGNED, Preconditions.checkNotNull(signedMessage,
+ "signedMessage"));
+ }
+
+ /**
+ * Creates message information for a signed Minecraft chat message without a complete
+ * public signed-message representation.
+ *
+ * @return signed message information
+ * @since 3.6.0
+ */
+ public static MessageInfo signed() {
+ return SIGNED;
+ }
+
+ /**
+ * Creates message information for unsigned modern Minecraft chat.
+ *
+ * @return unsigned message information
+ * @since 3.6.0
+ */
+ public static MessageInfo unsigned() {
+ return UNSIGNED;
+ }
+
+ /**
+ * Creates message information for legacy chat that has no modern signed-chat metadata.
+ *
+ * @return legacy message information
+ * @since 3.6.0
+ */
+ public static MessageInfo legacy() {
+ return LEGACY;
+ }
+ }
+
+ /**
+ * Represents the signing state of the original player chat message.
+ *
+ * @since 3.6.0
+ */
+ public enum SignedState {
+ /**
+ * The client submitted a modern Minecraft signed chat message.
+ */
+ SIGNED,
+ /**
+ * The client submitted modern Minecraft chat without a message signature.
+ */
+ UNSIGNED,
+ /**
+ * The protocol version does not provide modern signed-chat metadata.
+ */
+ LEGACY
+ }
}
diff --git a/api/src/test/java/com/velocitypowered/api/event/player/PlayerChatEventTest.java b/api/src/test/java/com/velocitypowered/api/event/player/PlayerChatEventTest.java
new file mode 100644
index 000000000..a4619c139
--- /dev/null
+++ b/api/src/test/java/com/velocitypowered/api/event/player/PlayerChatEventTest.java
@@ -0,0 +1,95 @@
+/*
+ * Copyright (C) 2026 Velocity Contributors
+ *
+ * The Velocity API is licensed under the terms of the MIT License. For more details,
+ * reference the LICENSE file in the api top-level directory.
+ */
+
+package com.velocitypowered.api.event.player;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.velocitypowered.api.proxy.Player;
+import com.velocitypowered.api.proxy.crypto.SignedMessage;
+import java.lang.reflect.Proxy;
+import java.security.KeyPairGenerator;
+import java.security.PublicKey;
+import java.time.Instant;
+import java.util.UUID;
+import org.junit.jupiter.api.Test;
+
+class PlayerChatEventTest {
+
+ @Test
+ void oldConstructorKeepsPlaintextBehavior() {
+ PlayerChatEvent event = new PlayerChatEvent(dummyPlayer(), "hello");
+
+ assertEquals("hello", event.getMessage());
+ assertSame(PlayerChatEvent.ChatResult.allowed(), event.getResult());
+ assertEquals(PlayerChatEvent.SignedState.UNSIGNED, event.getMessageInfo().getSignedState());
+ assertFalse(event.getMessageInfo().getSignedMessage().isPresent());
+ }
+
+ @Test
+ void messageInfoExposesOriginalSignedMessage() throws Exception {
+ SignedMessage signedMessage = signedMessage("original");
+ PlayerChatEvent event = new PlayerChatEvent(dummyPlayer(), "original",
+ PlayerChatEvent.MessageInfo.signed(signedMessage));
+
+ event.setResult(PlayerChatEvent.ChatResult.message("changed"));
+
+ assertEquals("original", event.getMessage());
+ assertEquals(PlayerChatEvent.SignedState.SIGNED, event.getMessageInfo().getSignedState());
+ assertTrue(event.getMessageInfo().getSignedMessage().isPresent());
+ assertSame(signedMessage, event.getMessageInfo().getSignedMessage().orElseThrow());
+ assertEquals("original", event.getMessageInfo().getSignedMessage().orElseThrow().getMessage());
+ }
+
+ private static Player dummyPlayer() {
+ return (Player) Proxy.newProxyInstance(Player.class.getClassLoader(), new Class>[] {Player.class},
+ (proxy, method, args) -> {
+ if (method.getName().equals("toString")) {
+ return "dummy";
+ }
+ throw new UnsupportedOperationException(method.getName());
+ });
+ }
+
+ private static SignedMessage signedMessage(String message) throws Exception {
+ PublicKey key = KeyPairGenerator.getInstance("RSA").generateKeyPair().getPublic();
+ return new SignedMessage() {
+ @Override
+ public String getMessage() {
+ return message;
+ }
+
+ @Override
+ public UUID getSignerUuid() {
+ return new UUID(0, 1);
+ }
+
+ @Override
+ public boolean isPreviewSigned() {
+ return false;
+ }
+
+ @Override
+ public PublicKey getSigner() {
+ return key;
+ }
+
+ @Override
+ public Instant getExpiryTemporal() {
+ return Instant.EPOCH;
+ }
+
+ @Override
+ public byte[] getSignature() {
+ return new byte[] {1};
+ }
+ };
+ }
+}
diff --git a/proxy/src/main/java/com/velocitypowered/proxy/crypto/SignedPlayerMessage.java b/proxy/src/main/java/com/velocitypowered/proxy/crypto/SignedPlayerMessage.java
new file mode 100644
index 000000000..d236f8574
--- /dev/null
+++ b/proxy/src/main/java/com/velocitypowered/proxy/crypto/SignedPlayerMessage.java
@@ -0,0 +1,96 @@
+/*
+ * Copyright (C) 2026 Velocity Contributors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.velocitypowered.proxy.crypto;
+
+import com.google.common.base.Preconditions;
+import com.velocitypowered.api.proxy.crypto.SignedMessage;
+import java.security.PublicKey;
+import java.time.Instant;
+import java.util.UUID;
+import org.checkerframework.checker.nullness.qual.Nullable;
+
+/**
+ * Immutable representation of a client-submitted signed player chat message.
+ */
+public final class SignedPlayerMessage implements SignedMessage {
+
+ private final String message;
+ private final PublicKey signer;
+ private final UUID signerUuid;
+ private final Instant expiryTemporal;
+ private final byte[] signature;
+ private final @Nullable byte[] salt;
+ private final boolean previewSigned;
+
+ /**
+ * Creates an immutable signed player message from protocol data.
+ *
+ * @param message the original signed message body
+ * @param signer the public key that signed the message
+ * @param signerUuid the player UUID that signed the message
+ * @param expiryTemporal the expiry time associated with the signing key or message
+ * @param signature the message signature
+ * @param salt the message salt, if present
+ * @param previewSigned whether the signature applies to a signed preview
+ */
+ public SignedPlayerMessage(String message, PublicKey signer, UUID signerUuid,
+ Instant expiryTemporal, byte[] signature, @Nullable byte[] salt, boolean previewSigned) {
+ this.message = Preconditions.checkNotNull(message, "message");
+ this.signer = Preconditions.checkNotNull(signer, "signer");
+ this.signerUuid = Preconditions.checkNotNull(signerUuid, "signerUuid");
+ this.expiryTemporal = Preconditions.checkNotNull(expiryTemporal, "expiryTemporal");
+ this.signature = Preconditions.checkNotNull(signature, "signature").clone();
+ this.salt = salt == null ? null : salt.clone();
+ this.previewSigned = previewSigned;
+ }
+
+ @Override
+ public String getMessage() {
+ return message;
+ }
+
+ @Override
+ public UUID getSignerUuid() {
+ return signerUuid;
+ }
+
+ @Override
+ public boolean isPreviewSigned() {
+ return previewSigned;
+ }
+
+ @Override
+ public PublicKey getSigner() {
+ return signer;
+ }
+
+ @Override
+ public Instant getExpiryTemporal() {
+ return expiryTemporal;
+ }
+
+ @Override
+ public byte[] getSignature() {
+ return signature.clone();
+ }
+
+ @Override
+ public @Nullable byte[] getSalt() {
+ return salt == null ? null : salt.clone();
+ }
+}
diff --git a/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/chat/PlayerChatMessageInfo.java b/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/chat/PlayerChatMessageInfo.java
new file mode 100644
index 000000000..3b40b4ea1
--- /dev/null
+++ b/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/chat/PlayerChatMessageInfo.java
@@ -0,0 +1,66 @@
+/*
+ * Copyright (C) 2026 Velocity Contributors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.velocitypowered.proxy.protocol.packet.chat;
+
+import com.velocitypowered.api.event.player.PlayerChatEvent;
+import com.velocitypowered.api.proxy.crypto.IdentifiedKey;
+import com.velocitypowered.proxy.connection.client.ConnectedPlayer;
+import com.velocitypowered.proxy.crypto.SignedPlayerMessage;
+import com.velocitypowered.proxy.protocol.packet.chat.keyed.KeyedPlayerChatPacket;
+import com.velocitypowered.proxy.protocol.packet.chat.session.SessionPlayerChatPacket;
+
+/**
+ * Creates public chat metadata from internal chat packets.
+ */
+public final class PlayerChatMessageInfo {
+
+ private PlayerChatMessageInfo() {
+ }
+
+ public static PlayerChatEvent.MessageInfo fromSessionPacket(ConnectedPlayer player,
+ SessionPlayerChatPacket packet) {
+ if (!packet.isSigned()) {
+ return PlayerChatEvent.MessageInfo.unsigned();
+ }
+
+ IdentifiedKey key = player.getIdentifiedKey();
+ if (key == null) {
+ return PlayerChatEvent.MessageInfo.signed();
+ }
+
+ return PlayerChatEvent.MessageInfo.signed(new SignedPlayerMessage(packet.getMessage(),
+ key.getSignedPublicKey(), player.getUniqueId(), key.getExpiryTemporal(),
+ packet.getSignature(), packet.getSaltBytes(), false));
+ }
+
+ public static PlayerChatEvent.MessageInfo fromKeyedPacket(ConnectedPlayer player,
+ KeyedPlayerChatPacket packet) {
+ if (packet.isUnsigned()) {
+ return PlayerChatEvent.MessageInfo.unsigned();
+ }
+
+ IdentifiedKey key = player.getIdentifiedKey();
+ if (key == null || packet.getExpiry() == null) {
+ return PlayerChatEvent.MessageInfo.signed();
+ }
+
+ return PlayerChatEvent.MessageInfo.signed(new SignedPlayerMessage(packet.getMessage(),
+ key.getSignedPublicKey(), player.getUniqueId(), packet.getExpiry(),
+ packet.getSignature(), packet.getSalt(), packet.isSignedPreview()));
+ }
+}
diff --git a/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/chat/keyed/KeyedChatHandler.java b/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/chat/keyed/KeyedChatHandler.java
index f8fc906ab..e3bd81e56 100644
--- a/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/chat/keyed/KeyedChatHandler.java
+++ b/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/chat/keyed/KeyedChatHandler.java
@@ -24,6 +24,7 @@ import com.velocitypowered.proxy.VelocityServer;
import com.velocitypowered.proxy.connection.client.ConnectedPlayer;
import com.velocitypowered.proxy.protocol.MinecraftPacket;
import com.velocitypowered.proxy.protocol.packet.chat.ChatQueue;
+import com.velocitypowered.proxy.protocol.packet.chat.PlayerChatMessageInfo;
import java.util.concurrent.CompletableFuture;
import java.util.function.Function;
import net.kyori.adventure.text.Component;
@@ -68,7 +69,8 @@ public class KeyedChatHandler implements
public void handlePlayerChatInternal(KeyedPlayerChatPacket packet) {
ChatQueue chatQueue = this.player.getChatQueue();
EventManager eventManager = this.server.getEventManager();
- PlayerChatEvent toSend = new PlayerChatEvent(player, packet.getMessage());
+ PlayerChatEvent toSend = new PlayerChatEvent(player, packet.getMessage(),
+ PlayerChatMessageInfo.fromKeyedPacket(player, packet));
CompletableFuture future = eventManager.fire(toSend);
CompletableFuture chatFuture;
diff --git a/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/chat/keyed/KeyedPlayerChatPacket.java b/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/chat/keyed/KeyedPlayerChatPacket.java
index 74fa88f5a..ef342d2e1 100644
--- a/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/chat/keyed/KeyedPlayerChatPacket.java
+++ b/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/chat/keyed/KeyedPlayerChatPacket.java
@@ -73,6 +73,14 @@ public class KeyedPlayerChatPacket implements MinecraftPacket {
return signedPreview;
}
+ public byte[] getSignature() {
+ return signature == null ? EncryptionUtils.EMPTY : signature.clone();
+ }
+
+ public byte[] getSalt() {
+ return salt == null ? EncryptionUtils.EMPTY : salt.clone();
+ }
+
@Override
public void decode(ByteBuf buf, ProtocolUtils.Direction direction,
ProtocolVersion protocolVersion) {
diff --git a/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/chat/legacy/LegacyChatHandler.java b/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/chat/legacy/LegacyChatHandler.java
index b7a641ca9..52fbf9f8b 100644
--- a/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/chat/legacy/LegacyChatHandler.java
+++ b/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/chat/legacy/LegacyChatHandler.java
@@ -44,7 +44,8 @@ public class LegacyChatHandler implements ChatHandler {
if (serverConnection == null) {
return;
}
- this.server.getEventManager().fire(new PlayerChatEvent(this.player, packet.getMessage()))
+ this.server.getEventManager().fire(new PlayerChatEvent(this.player, packet.getMessage(),
+ PlayerChatEvent.MessageInfo.legacy()))
.whenComplete((chatEvent, throwable) -> {
if (!chatEvent.getResult().isAllowed()) {
return;
diff --git a/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/chat/session/SessionChatHandler.java b/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/chat/session/SessionChatHandler.java
index 74b5747f9..54bc975b5 100644
--- a/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/chat/session/SessionChatHandler.java
+++ b/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/chat/session/SessionChatHandler.java
@@ -26,6 +26,7 @@ import com.velocitypowered.proxy.VelocityServer;
import com.velocitypowered.proxy.connection.client.ConnectedPlayer;
import com.velocitypowered.proxy.protocol.packet.chat.ChatHandler;
import com.velocitypowered.proxy.protocol.packet.chat.ChatQueue;
+import com.velocitypowered.proxy.protocol.packet.chat.PlayerChatMessageInfo;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
@@ -52,7 +53,8 @@ public class SessionChatHandler implements ChatHandler
public void handlePlayerChatInternal(SessionPlayerChatPacket packet) {
ChatQueue chatQueue = this.player.getChatQueue();
EventManager eventManager = this.server.getEventManager();
- PlayerChatEvent toSend = new PlayerChatEvent(player, packet.getMessage());
+ PlayerChatEvent toSend = new PlayerChatEvent(player, packet.getMessage(),
+ PlayerChatMessageInfo.fromSessionPacket(player, packet));
CompletableFuture eventFuture = eventManager.fire(toSend);
chatQueue.queuePacket(
newLastSeenMessages -> eventFuture
diff --git a/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/chat/session/SessionPlayerChatPacket.java b/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/chat/session/SessionPlayerChatPacket.java
index 8a00452c6..1303b50e0 100644
--- a/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/chat/session/SessionPlayerChatPacket.java
+++ b/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/chat/session/SessionPlayerChatPacket.java
@@ -17,6 +17,7 @@
package com.velocitypowered.proxy.protocol.packet.chat.session;
+import com.google.common.primitives.Longs;
import com.velocitypowered.api.network.ProtocolVersion;
import com.velocitypowered.proxy.connection.MinecraftSessionHandler;
import com.velocitypowered.proxy.protocol.MinecraftPacket;
@@ -54,7 +55,11 @@ public class SessionPlayerChatPacket implements MinecraftPacket {
}
public byte[] getSignature() {
- return signature;
+ return signature.clone();
+ }
+
+ public byte[] getSaltBytes() {
+ return Longs.toByteArray(salt);
}
public LastSeenMessages getLastSeenMessages() {
diff --git a/proxy/src/test/java/com/velocitypowered/proxy/protocol/packet/chat/PlayerChatMessageInfoTest.java b/proxy/src/test/java/com/velocitypowered/proxy/protocol/packet/chat/PlayerChatMessageInfoTest.java
new file mode 100644
index 000000000..c26766c6d
--- /dev/null
+++ b/proxy/src/test/java/com/velocitypowered/proxy/protocol/packet/chat/PlayerChatMessageInfoTest.java
@@ -0,0 +1,176 @@
+/*
+ * Copyright (C) 2026 Velocity Contributors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.velocitypowered.proxy.protocol.packet.chat;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.velocitypowered.api.event.player.PlayerChatEvent;
+import com.velocitypowered.api.proxy.crypto.IdentifiedKey;
+import com.velocitypowered.api.proxy.crypto.SignedMessage;
+import com.velocitypowered.proxy.connection.client.ConnectedPlayer;
+import com.velocitypowered.proxy.protocol.packet.chat.keyed.KeyedChatHandler;
+import com.velocitypowered.proxy.protocol.packet.chat.keyed.KeyedPlayerChatPacket;
+import com.velocitypowered.proxy.protocol.packet.chat.session.SessionPlayerChatPacket;
+import java.lang.reflect.Field;
+import java.security.KeyPairGenerator;
+import java.security.PublicKey;
+import java.time.Instant;
+import java.util.UUID;
+import net.kyori.adventure.text.Component;
+import org.apache.logging.log4j.Logger;
+import org.junit.jupiter.api.Test;
+
+class PlayerChatMessageInfoTest {
+
+ private static final UUID PLAYER_ID = new UUID(0, 42);
+ private static final Instant EXPIRY = Instant.ofEpochMilli(123456789L);
+
+ @Test
+ void sessionSignedChatPreservesOriginalSignedMessage() throws Exception {
+ byte[] signature = new byte[] {1, 2, 3};
+ SessionPlayerChatPacket packet = sessionPacket("signed body", true, 7L, signature);
+ PlayerChatEvent.MessageInfo info = PlayerChatMessageInfo.fromSessionPacket(player(), packet);
+
+ assertEquals(PlayerChatEvent.SignedState.SIGNED, info.getSignedState());
+ SignedMessage signedMessage = info.getSignedMessage().orElseThrow();
+ assertEquals("signed body", signedMessage.getMessage());
+ assertEquals(PLAYER_ID, signedMessage.getSignerUuid());
+ assertEquals(EXPIRY, signedMessage.getExpiryTemporal());
+ assertArrayEquals(signature, signedMessage.getSignature());
+ assertArrayEquals(packet.getSaltBytes(), signedMessage.getSalt());
+ }
+
+ @Test
+ void keyedSignedChatPreservesSignatureSaltAndPreviewFlag() throws Exception {
+ byte[] signature = new byte[] {4, 5, 6};
+ byte[] salt = new byte[] {7, 8};
+ KeyedPlayerChatPacket packet = keyedPacket("keyed body", false, EXPIRY, signature, salt, true);
+ PlayerChatEvent.MessageInfo info = PlayerChatMessageInfo.fromKeyedPacket(player(), packet);
+
+ assertEquals(PlayerChatEvent.SignedState.SIGNED, info.getSignedState());
+ SignedMessage signedMessage = info.getSignedMessage().orElseThrow();
+ assertEquals("keyed body", signedMessage.getMessage());
+ assertArrayEquals(signature, signedMessage.getSignature());
+ assertArrayEquals(salt, signedMessage.getSalt());
+ assertTrue(signedMessage.isPreviewSigned());
+ }
+
+ @Test
+ void unsignedModernChatDoesNotExposeSignedMessage() throws Exception {
+ SessionPlayerChatPacket packet = sessionPacket("unsigned body", false, 0L, new byte[0]);
+ PlayerChatEvent.MessageInfo info = PlayerChatMessageInfo.fromSessionPacket(player(), packet);
+
+ assertEquals(PlayerChatEvent.SignedState.UNSIGNED, info.getSignedState());
+ assertFalse(info.getSignedMessage().isPresent());
+ }
+
+ @Test
+ void signedChatWithoutKeyStillReportsSignedState() throws Exception {
+ ConnectedPlayer player = mock(ConnectedPlayer.class);
+ SessionPlayerChatPacket packet = sessionPacket("signed body", true, 7L, new byte[] {1});
+
+ PlayerChatEvent.MessageInfo info = PlayerChatMessageInfo.fromSessionPacket(player, packet);
+
+ assertEquals(PlayerChatEvent.SignedState.SIGNED, info.getSignedState());
+ assertFalse(info.getSignedMessage().isPresent());
+ }
+
+ @Test
+ void legacyChatHasNoFabricatedSignedMessage() {
+ PlayerChatEvent.MessageInfo info = PlayerChatEvent.MessageInfo.legacy();
+
+ assertEquals(PlayerChatEvent.SignedState.LEGACY, info.getSignedState());
+ assertFalse(info.getSignedMessage().isPresent());
+ }
+
+ @Test
+ void signedMessageDefensivelyCopiesMutableSignatureData() throws Exception {
+ byte[] signature = new byte[] {9, 10, 11};
+ SessionPlayerChatPacket packet = sessionPacket("signed body", true, 7L, signature);
+ SignedMessage signedMessage = PlayerChatMessageInfo.fromSessionPacket(player(), packet)
+ .getSignedMessage()
+ .orElseThrow();
+
+ signature[0] = 99;
+ byte[] exposed = signedMessage.getSignature();
+ exposed[1] = 88;
+
+ assertArrayEquals(new byte[] {9, 10, 11}, signedMessage.getSignature());
+ }
+
+ @Test
+ void signedChatCancelAndRewriteSafetyStillDisconnectsPlayer() {
+ ConnectedPlayer player = mock(ConnectedPlayer.class);
+ Logger logger = mock(Logger.class);
+ when(player.getUsername()).thenReturn("player");
+
+ KeyedChatHandler.invalidCancel(logger, player);
+ KeyedChatHandler.invalidChange(logger, player);
+
+ verify(player, times(2)).disconnect(Component.text("A proxy plugin caused an illegal protocol state. "
+ + "Contact your network administrator."));
+ }
+
+ private static ConnectedPlayer player() throws Exception {
+ ConnectedPlayer player = mock(ConnectedPlayer.class);
+ IdentifiedKey key = mock(IdentifiedKey.class);
+ PublicKey publicKey = KeyPairGenerator.getInstance("RSA").generateKeyPair().getPublic();
+
+ when(key.getSignedPublicKey()).thenReturn(publicKey);
+ when(key.getExpiryTemporal()).thenReturn(EXPIRY);
+ when(player.getIdentifiedKey()).thenReturn(key);
+ when(player.getUniqueId()).thenReturn(PLAYER_ID);
+ return player;
+ }
+
+ private static SessionPlayerChatPacket sessionPacket(String message, boolean signed, long salt,
+ byte[] signature) throws Exception {
+ SessionPlayerChatPacket packet = new SessionPlayerChatPacket();
+ set(packet, "message", message);
+ set(packet, "signed", signed);
+ set(packet, "salt", salt);
+ set(packet, "signature", signature);
+ set(packet, "timestamp", Instant.EPOCH);
+ return packet;
+ }
+
+ private static KeyedPlayerChatPacket keyedPacket(String message, boolean unsigned,
+ Instant expiry, byte[] signature, byte[] salt, boolean signedPreview) throws Exception {
+ KeyedPlayerChatPacket packet = new KeyedPlayerChatPacket();
+ set(packet, "message", message);
+ set(packet, "unsigned", unsigned);
+ set(packet, "expiry", expiry);
+ set(packet, "signature", signature);
+ set(packet, "salt", salt);
+ set(packet, "signedPreview", signedPreview);
+ return packet;
+ }
+
+ private static void set(Object target, String fieldName, Object value) throws Exception {
+ Field field = target.getClass().getDeclaredField(fieldName);
+ field.setAccessible(true);
+ field.set(target, value);
+ }
+}