Expose signed player chat metadata through PlayerChatEvent

Velocity already parses and validates signed Minecraft player chat internally, but PlayerChatEvent currently exposes only the player and plaintext message to plugins.

Carry the original signed-message metadata across the public API boundary so ecosystem plugins can inspect modern Minecraft signed chat without depending on internal packet classes.

This change is backward compatible and does not alter validation, forwarding, cancellation or rewrite behavior.
This commit is contained in:
jens 2026-07-12 02:40:44 +02:00
parent 4bd30bac33
commit dc4bc221d5
10 changed files with 597 additions and 4 deletions

View file

@ -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 ResultedEvent<PlayerChatEvent.Chat
private final Player player;
private final String message;
private final MessageInfo messageInfo;
private ChatResult result;
/**
@ -33,8 +35,21 @@ public final class PlayerChatEvent implements ResultedEvent<PlayerChatEvent.Chat
* @param message the message being sent
*/
public PlayerChatEvent(Player player, String message) {
this(player, message, MessageInfo.unsigned());
}
/**
* Constructs a PlayerChatEvent.
*
* @param player the player sending the message
* @param message the compatibility plaintext view of the message being sent
* @param messageInfo information about the original client-submitted message
* @since 3.6.0
*/
public PlayerChatEvent(Player player, String message, MessageInfo messageInfo) {
this.player = Preconditions.checkNotNull(player, "player");
this.message = Preconditions.checkNotNull(message, "message");
this.messageInfo = Preconditions.checkNotNull(messageInfo, "messageInfo");
this.result = ChatResult.allowed();
}
@ -46,6 +61,24 @@ public final class PlayerChatEvent implements ResultedEvent<PlayerChatEvent.Chat
return message;
}
/**
* Returns information about the original Minecraft chat message submitted by the client.
*
* <p>The 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.</p>
*
* <p>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.</p>
*
* @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 ResultedEvent<PlayerChatEvent.Chat
return new ChatResult(true, message);
}
}
/**
* Describes the signing state and original signed body of a player chat message.
*
* <p>This object represents the message as submitted by the Minecraft client, before any plugin
* result can deny or replace the forwarded text.</p>
*
* @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.
*
* <p>The signed message body is the original signed body and is separate from any later
* {@link ChatResult} rewrite.</p>
*
* @return the original signed message, if present
* @since 3.6.0
*/
public Optional<SignedMessage> 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
}
}

View file

@ -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};
}
};
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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();
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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()));
}
}

View file

@ -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<PlayerChatEvent> future = eventManager.fire(toSend);
CompletableFuture<MinecraftPacket> chatFuture;

View file

@ -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) {

View file

@ -44,7 +44,8 @@ public class LegacyChatHandler implements ChatHandler<LegacyChatPacket> {
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;

View file

@ -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<SessionPlayerChatPacket>
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<PlayerChatEvent> eventFuture = eventManager.fire(toSend);
chatQueue.queuePacket(
newLastSeenMessages -> eventFuture

View file

@ -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() {

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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);
}
}