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); + } +}