feat(stealth): route concealed public chat as system messages
Preserve final moderation, selected recipients, viewer-aware formatting, native content filters and client chat settings. Retain profile/chat sessions through tab-list status updates. Verify real native pipeline, packet codecs, RSA signed message chains and acknowledgement bookkeeping without changing authentication settings. No deployment is included.
This commit is contained in:
@@ -14,7 +14,13 @@ Artifacts are written to `build/libs/purpur-stealth-<version>.jar`. Gitea CI ver
|
|||||||
|
|
||||||
Dealing uncancelled positive damage to another player ends the attacker's concealed session and tells them: **Your stealth was broken because you hurt another player.** Melee, player-attributed projectiles and potions qualify; misses, cancelled/zero hits, self-damage, attacking mobs and merely taking damage do not. Identity and prior sleep-count participation are restored, including withdrawing Eye projections. Progress, unlocks and unrelated invisibility remain intact; re-entry uses the existing qualifying logout/login process.
|
Dealing uncancelled positive damage to another player ends the attacker's concealed session and tells them: **Your stealth was broken because you hurt another player.** Melee, player-attributed projectiles and potions qualify; misses, cancelled/zero hits, self-damage, attacking mobs and merely taking damage do not. Identity and prior sleep-count participation are restored, including withdrawing Eye projections. Progress, unlocks and unrelated invisibility remain intact; re-entry uses the existing qualifying logout/login process.
|
||||||
|
|
||||||
Delayed poison/wither use explicitly observed application sources and read-only native effect-layer snapshots, including hidden-layer restoration and rejected/cosmetic replacements. No nearby-player or wall-clock guess is made. Provenance is runtime-only and discarded on victim disconnect; unknown effects loaded after reconnect/restart are not assigned an invented attacker. The separately tracked signed-chat validation/system-message work remains pending.
|
Delayed poison/wither use explicitly observed application sources and read-only native effect-layer snapshots, including hidden-layer restoration and rejected/cosmetic replacements. No nearby-player or wall-clock guess is made. Provenance is runtime-only and discarded on victim disconnect; unknown effects loaded after reconnect/restart are not assigned an invented attacker. Public chat uses the anonymous system-message route below.
|
||||||
|
|
||||||
|
## Anonymous public chat
|
||||||
|
|
||||||
|
Concealed players' public chat is delivered as unsigned system messages, normally **[Stealth] Anonymous: hello**. The final Paper chat pipeline retains selected recipients, cancellation and moderated content, preserves original per-viewer formatting, and enforces native content filtering and client chat visibility. Plain output removes identifying interactive metadata and anonymizes the sender's name/UUID. Private commands/messages are not rerouted. Ordinary senders keep native signed chat.
|
||||||
|
|
||||||
|
Tab hiding uses `UPDATE_LISTED=false`, not profile deletion; server initialization updates cannot relist concealed players for other observers. Existing profile and chat-session data remain available when normal signed chat resumes. No secure-profile/authentication settings are changed. Native signature, packet and acknowledgement regressions cover the route; live-client validation remains a separate follow-up. System messages do not provide signed-player authenticity or equivalent reporting support.
|
||||||
|
|
||||||
## Eye of True Seeing
|
## Eye of True Seeing
|
||||||
|
|
||||||
@@ -36,6 +42,6 @@ The repository and checkout are now `purpur-stealth` (previously remote `spigot-
|
|||||||
|
|
||||||
Replace the old plugin JAR when installing the new distribution; never load both JARs together. Old tags and `spigot-stealth-*` release assets are preserved. Repository rename redirects have been checked against the previously deployed v1.5.0 download. Publishing a release does not authorize deployment.
|
Replace the old plugin JAR when installing the new distribution; never load both JARs together. Old tags and `spigot-stealth-*` release assets are preserved. Repository rename redirects have been checked against the previously deployed v1.5.0 download. Publishing a release does not authorize deployment.
|
||||||
|
|
||||||
The v2.0.0 migration itself left the Eye, combat reveal and concealed-chat stories pending. Subsequent Eye acquisition, local revelation and combat-session breaking are described above; system-chat routing remains a separate unfinished story. Runtime plugin metadata is unchanged.
|
The v2.0.0 migration itself left the Eye, combat reveal and concealed-chat stories pending. Subsequent Eye acquisition, local revelation, combat-session breaking and anonymous system-chat routing are described above. Runtime plugin metadata is unchanged.
|
||||||
|
|
||||||
See the [canonical project](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/purpur-stealth/index.md), [stories](https://git.garvis.dev/dmg/somc-okf/src/branch/main/user-stories/purpur-stealth/index.md), and [development cycle](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/development-cycle.md).
|
See the [canonical project](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/purpur-stealth/index.md), [stories](https://git.garvis.dev/dmg/somc-okf/src/branch/main/user-stories/purpur-stealth/index.md), and [development cycle](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/development-cycle.md).
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package games.dmg.spigotstealth;
|
||||||
|
|
||||||
|
import java.lang.reflect.Method;
|
||||||
|
import net.kyori.adventure.audience.Audience;
|
||||||
|
import net.kyori.adventure.chat.SignedMessage;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
|
||||||
|
/** Preserves the native per-recipient text-filter decision when selecting system transport. */
|
||||||
|
final class NativeChatFiltering {
|
||||||
|
record Result(boolean applies, String text) { }
|
||||||
|
private final Method handle, shouldFilter, nativeMessage, mask, empty, apply, visibility;
|
||||||
|
private final Object fullVisibility;
|
||||||
|
|
||||||
|
NativeChatFiltering() {
|
||||||
|
try {
|
||||||
|
var player = Class.forName("net.minecraft.server.level.ServerPlayer");
|
||||||
|
var message = Class.forName("net.minecraft.network.chat.PlayerChatMessage");
|
||||||
|
var filter = Class.forName("net.minecraft.network.chat.FilterMask");
|
||||||
|
handle = Class.forName("org.bukkit.craftbukkit.entity.CraftPlayer").getMethod("getHandle");
|
||||||
|
shouldFilter = player.getMethod("shouldFilterMessageTo", player);
|
||||||
|
visibility = player.getMethod("getChatVisibility");
|
||||||
|
fullVisibility = Class.forName("net.minecraft.world.entity.player.ChatVisiblity").getField("FULL").get(null);
|
||||||
|
nativeMessage = Class.forName("net.minecraft.network.chat.PlayerChatMessage$AdventureView").getMethod("playerChatMessage");
|
||||||
|
mask = message.getMethod("filterMask");
|
||||||
|
empty = filter.getMethod("isEmpty");
|
||||||
|
apply = filter.getMethod("apply", String.class);
|
||||||
|
} catch (ReflectiveOperationException failure) { throw new IllegalStateException("Unsupported native chat filtering layout", failure); }
|
||||||
|
}
|
||||||
|
|
||||||
|
Result filter(Player sender, Audience audience, SignedMessage message) throws ReflectiveOperationException {
|
||||||
|
int depth = 0;
|
||||||
|
while (audience instanceof net.kyori.adventure.audience.ForwardingAudience.Single single) {
|
||||||
|
if (++depth > 64) { throw new IllegalStateException("Invalid forwarding audience chain"); }
|
||||||
|
audience = single.audience();
|
||||||
|
}
|
||||||
|
if (!(audience instanceof Player recipient)) { return new Result(false, null); }
|
||||||
|
Object target = handle.invoke(recipient);
|
||||||
|
// Public chat must not slip through a client's commands-only/hidden setting merely because transport is SYSTEM.
|
||||||
|
if (visibility.invoke(target) != fullVisibility) { return new Result(true, null); }
|
||||||
|
if (!(boolean) shouldFilter.invoke(handle.invoke(sender), target)) { return new Result(false, null); }
|
||||||
|
Object filter = mask.invoke(nativeMessage.invoke(message));
|
||||||
|
return (boolean) empty.invoke(filter) ? new Result(false, null) : new Result(true, (String) apply.invoke(filter, message.message()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ import java.util.Objects;
|
|||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import org.bukkit.entity.Player;
|
import org.bukkit.entity.Player;
|
||||||
|
|
||||||
/** ProtocolLib adapter for tab-list-only removal and restoration packets. */
|
/** Changes tab listing without deleting the client profile or signed-chat session. */
|
||||||
public final class ProtocolLibTabListController implements TabListController {
|
public final class ProtocolLibTabListController implements TabListController {
|
||||||
private final ProtocolManager protocolManager;
|
private final ProtocolManager protocolManager;
|
||||||
|
|
||||||
@@ -24,8 +24,11 @@ public final class ProtocolLibTabListController implements TabListController {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void remove(Player observer, UUID targetPlayerId) {
|
public void remove(Player observer, UUID targetPlayerId) {
|
||||||
PacketContainer packet = protocolManager.createPacket(PacketType.Play.Server.PLAYER_INFO_REMOVE);
|
PacketContainer packet = protocolManager.createPacket(PacketType.Play.Server.PLAYER_INFO);
|
||||||
packet.getUUIDLists().write(0, List.of(targetPlayerId));
|
packet.getPlayerInfoActions().write(0, EnumSet.of(EnumWrappers.PlayerInfoAction.UPDATE_LISTED));
|
||||||
|
PlayerInfoData data = new PlayerInfoData(targetPlayerId, 0, false, EnumWrappers.NativeGameMode.SURVIVAL,
|
||||||
|
null, null, (WrappedRemoteChatSessionData) null);
|
||||||
|
packet.getPlayerInfoDataLists().write(1, List.of(data));
|
||||||
protocolManager.sendServerPacket(observer, packet);
|
protocolManager.sendServerPacket(observer, packet);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,7 +36,6 @@ public final class ProtocolLibTabListController implements TabListController {
|
|||||||
public void add(Player observer, Player target) {
|
public void add(Player observer, Player target) {
|
||||||
PacketContainer packet = protocolManager.createPacket(PacketType.Play.Server.PLAYER_INFO);
|
PacketContainer packet = protocolManager.createPacket(PacketType.Play.Server.PLAYER_INFO);
|
||||||
packet.getPlayerInfoActions().write(0, EnumSet.of(
|
packet.getPlayerInfoActions().write(0, EnumSet.of(
|
||||||
EnumWrappers.PlayerInfoAction.ADD_PLAYER,
|
|
||||||
EnumWrappers.PlayerInfoAction.UPDATE_GAME_MODE,
|
EnumWrappers.PlayerInfoAction.UPDATE_GAME_MODE,
|
||||||
EnumWrappers.PlayerInfoAction.UPDATE_LISTED,
|
EnumWrappers.PlayerInfoAction.UPDATE_LISTED,
|
||||||
EnumWrappers.PlayerInfoAction.UPDATE_LATENCY,
|
EnumWrappers.PlayerInfoAction.UPDATE_LATENCY,
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ public final class SpigotStealthPlugin extends JavaPlugin {
|
|||||||
identityPresentation = bukkitPresentation;
|
identityPresentation = bukkitPresentation;
|
||||||
protocolManager.addPacketListener(new ProtocolLibIdentityMaskingListener(
|
protocolManager.addPacketListener(new ProtocolLibIdentityMaskingListener(
|
||||||
this, bukkitPresentation::concealedNames, getLogger()::warning));
|
this, bukkitPresentation::concealedNames, getLogger()::warning));
|
||||||
|
protocolManager.addPacketListener(new TabListMaskingListener(this, sessions::concealedPlayerIds, getLogger()::warning));
|
||||||
protocolManager.addPacketListener(new ProtocolLibServerListPingListener(
|
protocolManager.addPacketListener(new ProtocolLibServerListPingListener(
|
||||||
this, sessions::concealedPlayerIds, getLogger()::warning));
|
this, sessions::concealedPlayerIds, getLogger()::warning));
|
||||||
getServer().getPluginManager().registerEvents(
|
getServer().getPluginManager().registerEvents(
|
||||||
@@ -175,6 +176,8 @@ public final class SpigotStealthPlugin extends JavaPlugin {
|
|||||||
try {
|
try {
|
||||||
eyeReveal = new EyeRevealRuntime(this, eyeEquipment, sessions::isConcealed, protocolManager);
|
eyeReveal = new EyeRevealRuntime(this, eyeEquipment, sessions::isConcealed, protocolManager);
|
||||||
eyeReveal.start();
|
eyeReveal.start();
|
||||||
|
getServer().getPluginManager().registerEvents(new StealthSystemChat(sessions::isConcealed,
|
||||||
|
failure -> getLogger().warning("Anonymous public-chat delivery failed closed: " + rootMessage(failure))), this);
|
||||||
java.util.function.Consumer<Throwable> combatFailure = failure -> getLogger().warning("Unable to complete combat reveal: " + rootMessage(failure));
|
java.util.function.Consumer<Throwable> combatFailure = failure -> getLogger().warning("Unable to complete combat reveal: " + rootMessage(failure));
|
||||||
combatPotions = new PotionDamageListener(getServer()::getPlayer, combatFailure);
|
combatPotions = new PotionDamageListener(getServer()::getPlayer, combatFailure);
|
||||||
getServer().getPluginManager().registerEvents(combatPotions, this);
|
getServer().getPluginManager().registerEvents(combatPotions, this);
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package games.dmg.spigotstealth;
|
||||||
|
|
||||||
|
import io.papermc.paper.event.player.ChatEvent;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.function.Predicate;
|
||||||
|
import org.bukkit.event.EventHandler;
|
||||||
|
import org.bukkit.event.EventPriority;
|
||||||
|
import org.bukkit.event.Listener;
|
||||||
|
|
||||||
|
/** Public-chat transport selection after moderation; private command messages are not handled here. */
|
||||||
|
public final class StealthSystemChat implements Listener {
|
||||||
|
private final Predicate<UUID> concealed;
|
||||||
|
private final NativeChatFiltering filtering = new NativeChatFiltering();
|
||||||
|
private final java.util.function.Consumer<Throwable> failures;
|
||||||
|
public StealthSystemChat(Predicate<UUID> concealed) {
|
||||||
|
this(concealed, failure -> System.getLogger(StealthSystemChat.class.getName()).log(System.Logger.Level.WARNING, "System chat delivery failed closed", failure));
|
||||||
|
}
|
||||||
|
public StealthSystemChat(Predicate<UUID> concealed, java.util.function.Consumer<Throwable> failures) {
|
||||||
|
this.concealed = java.util.Objects.requireNonNull(concealed);
|
||||||
|
this.failures = java.util.Objects.requireNonNull(failures);
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||||
|
public void onChat(ChatEvent event) {
|
||||||
|
if (event.isCancelled() || !concealed.test(event.getPlayer().getUniqueId())) { return; }
|
||||||
|
// Transport adaptation only, after the ordinary moderation priorities. Do not cancel or deliver here:
|
||||||
|
// Paper must still make its final cancellation decision before invoking any recipient.
|
||||||
|
var renderer = event.renderer();
|
||||||
|
var hiddenIdentity = java.util.regex.Pattern.compile(java.util.regex.Pattern.quote(event.getPlayer().getUniqueId().toString())
|
||||||
|
+ "|(?<![A-Za-z0-9_])" + java.util.regex.Pattern.quote(event.getPlayer().getName()) + "(?![A-Za-z0-9_])", java.util.regex.Pattern.CASE_INSENSITIVE);
|
||||||
|
var recipients = event.viewers().stream().map(viewer -> new SystemRecipient(viewer, hiddenIdentity, event.getPlayer())).toList();
|
||||||
|
event.viewers().clear();
|
||||||
|
event.viewers().addAll(recipients);
|
||||||
|
event.renderer((source, displayName, message, viewer) -> {
|
||||||
|
var original = viewer instanceof SystemRecipient recipient ? recipient.original : viewer;
|
||||||
|
if (renderer instanceof io.papermc.paper.chat.ChatRenderer.Default) {
|
||||||
|
return net.kyori.adventure.text.Component.text("[Stealth] Anonymous: ").append(message);
|
||||||
|
}
|
||||||
|
return renderer.render(source, net.kyori.adventure.text.Component.text("Anonymous"), message, original);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deliberately not ForwardingAudience.Single: Paper unwraps it to the native signed-player path. */
|
||||||
|
private final class SystemRecipient implements net.kyori.adventure.audience.Audience {
|
||||||
|
private final net.kyori.adventure.audience.Audience original;
|
||||||
|
private final java.util.regex.Pattern hiddenIdentity;
|
||||||
|
private final org.bukkit.entity.Player sender;
|
||||||
|
private SystemRecipient(net.kyori.adventure.audience.Audience original, java.util.regex.Pattern hiddenIdentity, org.bukkit.entity.Player sender) {
|
||||||
|
this.original = original;
|
||||||
|
this.hiddenIdentity = hiddenIdentity;
|
||||||
|
this.sender = sender;
|
||||||
|
}
|
||||||
|
@Override public void sendMessage(net.kyori.adventure.chat.SignedMessage message, net.kyori.adventure.chat.ChatType.Bound type) {
|
||||||
|
try {
|
||||||
|
var filtered = filtering.filter(sender, original, message);
|
||||||
|
if (filtered.applies() && filtered.text() == null) { return; }
|
||||||
|
var body = message.unsignedContent() == null ? net.kyori.adventure.text.Component.text(message.message()) : message.unsignedContent();
|
||||||
|
var plain = filtered.applies() ? "[Stealth] Anonymous: " + filtered.text()
|
||||||
|
: net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText().serialize(body);
|
||||||
|
original.sendMessage(net.kyori.adventure.text.Component.text(hiddenIdentity.matcher(plain).replaceAll("Anonymous")));
|
||||||
|
} catch (ReflectiveOperationException | RuntimeException failure) { failures.accept(failure); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package games.dmg.spigotstealth;
|
||||||
|
|
||||||
|
import com.comphenix.protocol.PacketType;
|
||||||
|
import com.comphenix.protocol.events.ListenerPriority;
|
||||||
|
import com.comphenix.protocol.events.PacketAdapter;
|
||||||
|
import com.comphenix.protocol.events.PacketEvent;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
import org.bukkit.plugin.Plugin;
|
||||||
|
|
||||||
|
/** Unlists concealed entries without removing native profiles or chat-session data. */
|
||||||
|
public final class TabListMaskingListener extends PacketAdapter {
|
||||||
|
private final Supplier<Set<UUID>> concealed;
|
||||||
|
private final Consumer<String> warning;
|
||||||
|
private final java.util.concurrent.atomic.AtomicBoolean warned = new java.util.concurrent.atomic.AtomicBoolean();
|
||||||
|
public TabListMaskingListener(Plugin plugin, Supplier<Set<UUID>> concealed, Consumer<String> warning) {
|
||||||
|
super(plugin, ListenerPriority.HIGHEST, PacketType.Play.Server.PLAYER_INFO);
|
||||||
|
this.concealed = java.util.Objects.requireNonNull(concealed);
|
||||||
|
this.warning = java.util.Objects.requireNonNull(warning);
|
||||||
|
}
|
||||||
|
@Override public void onPacketSending(PacketEvent event) {
|
||||||
|
if (event.isCancelled() || event.isPlayerTemporary() || event.getPlayer() == null) { return; }
|
||||||
|
try {
|
||||||
|
var snapshot = concealed.get();
|
||||||
|
if (snapshot.isEmpty()) { return; }
|
||||||
|
Object original = event.getPacket().getHandle();
|
||||||
|
Object projected = project(original, snapshot, event.getPlayer().getUniqueId());
|
||||||
|
if (projected != original) { event.setPacket(new com.comphenix.protocol.events.PacketContainer(PacketType.Play.Server.PLAYER_INFO, projected)); }
|
||||||
|
} catch (ReflectiveOperationException | RuntimeException failure) {
|
||||||
|
if (warned.compareAndSet(false, true)) { warning.accept("Tab listing projection unavailable; native profile/session retained: " + failure.getClass().getSimpleName()); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Object project(Object original, Set<UUID> hidden, UUID viewer) throws ReflectiveOperationException {
|
||||||
|
var packetType = Class.forName("net.minecraft.network.protocol.game.ClientboundPlayerInfoUpdatePacket");
|
||||||
|
var actions = (java.util.EnumSet<?>) packetType.getMethod("actions").invoke(original);
|
||||||
|
if (actions.stream().noneMatch(action -> action.name().equals("UPDATE_LISTED"))) { return original; }
|
||||||
|
var entries = (java.util.List<?>) packetType.getMethod("entries").invoke(original);
|
||||||
|
var projected = new java.util.ArrayList<Object>(entries.size());
|
||||||
|
boolean changed = false;
|
||||||
|
for (var entry : entries) {
|
||||||
|
var type = entry.getClass();
|
||||||
|
UUID id = (UUID) type.getMethod("profileId").invoke(entry);
|
||||||
|
if (!id.equals(viewer) && hidden.contains(id) && (boolean) type.getMethod("listed").invoke(entry)) {
|
||||||
|
var components = type.getRecordComponents();
|
||||||
|
var parameterTypes = new Class<?>[components.length];
|
||||||
|
var values = new Object[components.length];
|
||||||
|
for (int i = 0; i < components.length; i++) {
|
||||||
|
parameterTypes[i] = components[i].getType();
|
||||||
|
values[i] = components[i].getName().equals("listed") ? false : components[i].getAccessor().invoke(entry);
|
||||||
|
}
|
||||||
|
projected.add(type.getConstructor(parameterTypes).newInstance(values));
|
||||||
|
changed = true;
|
||||||
|
} else { projected.add(entry); }
|
||||||
|
}
|
||||||
|
return changed ? packetType.getConstructor(java.util.EnumSet.class, java.util.List.class).newInstance(actions.clone(), java.util.List.copyOf(projected)) : original;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -137,6 +137,8 @@ class EyePluginLifecycleTest {
|
|||||||
assertTrue(listeners.stream().anyMatch(EyePotionListener.class::isInstance));
|
assertTrue(listeners.stream().anyMatch(EyePotionListener.class::isInstance));
|
||||||
assertTrue(listeners.stream().anyMatch(EyeEquipment.class::isInstance));
|
assertTrue(listeners.stream().anyMatch(EyeEquipment.class::isInstance));
|
||||||
assertTrue(listeners.stream().anyMatch(CombatRevealListener.class::isInstance), "plugin startup must install combat breaking");
|
assertTrue(listeners.stream().anyMatch(CombatRevealListener.class::isInstance), "plugin startup must install combat breaking");
|
||||||
|
assertTrue(listeners.stream().anyMatch(StealthSystemChat.class::isInstance), "plugin startup must register anonymous public-chat routing");
|
||||||
|
assertTrue(registeredPackets.stream().anyMatch(TabListMaskingListener.class::isInstance), "plugin startup must preserve profile/session data while hiding tab entries");
|
||||||
assertTrue(listeners.stream().anyMatch(PotionDamageListener.class::isInstance), "plugin startup must observe potion provenance");
|
assertTrue(listeners.stream().anyMatch(PotionDamageListener.class::isInstance), "plugin startup must observe potion provenance");
|
||||||
assertTrue(listeners.stream().anyMatch(EyeRevealEvents.class::isInstance), "plugin startup must register reveal invalidation hooks");
|
assertTrue(listeners.stream().anyMatch(EyeRevealEvents.class::isInstance), "plugin startup must register reveal invalidation hooks");
|
||||||
assertTrue(registeredPackets.stream().anyMatch(EyePacketListener.class::isInstance), "plugin startup must register the per-observer packet gate");
|
assertTrue(registeredPackets.stream().anyMatch(EyePacketListener.class::isInstance), "plugin startup must register the per-observer packet gate");
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package games.dmg.spigotstealth;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.UUID;
|
||||||
|
import net.minecraft.network.chat.*;
|
||||||
|
import net.minecraft.network.protocol.Packet;
|
||||||
|
import net.minecraft.network.protocol.game.ClientboundPlayerChatPacket;
|
||||||
|
import net.minecraft.network.protocol.game.ClientboundSystemChatPacket;
|
||||||
|
import net.minecraft.server.level.ServerPlayer;
|
||||||
|
import net.minecraft.server.network.ServerGamePacketListenerImpl;
|
||||||
|
import org.bukkit.craftbukkit.CraftRegistry;
|
||||||
|
import org.bukkit.craftbukkit.entity.CraftPlayer;
|
||||||
|
|
||||||
|
/** Real native dispatch/codec/signature/ack state, with only the socket and already-authenticated player boundaries doubled. */
|
||||||
|
final class NativeChatWire {
|
||||||
|
final ArrayList<Packet<?>> packets = new ArrayList<>();
|
||||||
|
final LastSeenMessagesValidator serverSeen = new LastSeenMessagesValidator(20);
|
||||||
|
private final LastSeenMessagesTracker clientSeen = new LastSeenMessagesTracker(20);
|
||||||
|
private final MessageSignatureCache clientCache = MessageSignatureCache.createDefault();
|
||||||
|
private int nextChatIndex;
|
||||||
|
|
||||||
|
NativeChatWire(CraftPlayer recipient) throws Exception {
|
||||||
|
var player = recipient.getHandle();
|
||||||
|
set(ServerPlayer.class, player, "chatVisibility", net.minecraft.world.entity.player.ChatVisiblity.FULL);
|
||||||
|
var connection = mock(ServerGamePacketListenerImpl.class);
|
||||||
|
player.connection = connection;
|
||||||
|
when(connection.getCraftPlayer()).thenReturn(recipient);
|
||||||
|
when(recipient.canSeePlayer(any(UUID.class))).thenReturn(true);
|
||||||
|
set(ServerGamePacketListenerImpl.class, connection, "lastSeenMessages", serverSeen);
|
||||||
|
set(ServerGamePacketListenerImpl.class, connection, "messageSignatureCache", MessageSignatureCache.createDefault());
|
||||||
|
doCallRealMethod().when(connection).sendPlayerChatMessage(any(PlayerChatMessage.class), any(ChatType.Bound.class));
|
||||||
|
doCallRealMethod().when(player).sendChatMessage(any(OutgoingChatMessage.class), anyBoolean(), any(ChatType.Bound.class), nullable(Component.class));
|
||||||
|
doCallRealMethod().when(recipient).sendMessage(any(net.kyori.adventure.text.Component.class));
|
||||||
|
doAnswer(call -> { packets.add(call.getArgument(0)); return null; }).when(connection).send(org.mockito.ArgumentMatchers.<Packet<?>>any());
|
||||||
|
}
|
||||||
|
|
||||||
|
void receive(boolean system, UUID session, SignedMessageValidator validator, String expected) throws Exception {
|
||||||
|
assertEquals(1, packets.size(), "exactly one packet for the selected recipient");
|
||||||
|
var buffer = new net.minecraft.network.RegistryFriendlyByteBuf(io.netty.buffer.Unpooled.buffer(), CraftRegistry.getMinecraftRegistry());
|
||||||
|
try {
|
||||||
|
if (system) {
|
||||||
|
var packet = assertInstanceOf(ClientboundSystemChatPacket.class, packets.getFirst());
|
||||||
|
ClientboundSystemChatPacket.STREAM_CODEC.encode(buffer, packet);
|
||||||
|
var decoded = ClientboundSystemChatPacket.STREAM_CODEC.decode(buffer);
|
||||||
|
assertFalse(decoded.overlay());
|
||||||
|
assertEquals("[Stealth] Anonymous: " + expected, decoded.content().getString());
|
||||||
|
} else {
|
||||||
|
var packet = assertInstanceOf(ClientboundPlayerChatPacket.class, packets.getFirst());
|
||||||
|
ClientboundPlayerChatPacket.STREAM_CODEC.encode(buffer, packet);
|
||||||
|
var decoded = ClientboundPlayerChatPacket.STREAM_CODEC.decode(buffer);
|
||||||
|
assertEquals(nextChatIndex++, decoded.globalIndex(), "system messages must not create signed packet-index gaps");
|
||||||
|
var body = decoded.body().unpack(clientCache).orElseThrow();
|
||||||
|
var message = new PlayerChatMessage(new SignedMessageLink(decoded.index(), decoded.sender(), session), decoded.signature(), body,
|
||||||
|
decoded.unsignedContent(), decoded.filterMask());
|
||||||
|
assertEquals(expected, body.content());
|
||||||
|
assertNotNull(validator.updateAndValidate(message), "the retained native client signature chain must accept normal chat again");
|
||||||
|
assertTrue(clientSeen.addPending(decoded.signature(), true));
|
||||||
|
clientCache.push(body, decoded.signature());
|
||||||
|
}
|
||||||
|
} finally { buffer.release(); }
|
||||||
|
var acknowledgement = clientSeen.generateAndApplyUpdate();
|
||||||
|
assertEquals(acknowledgement.lastSeen(), serverSeen.applyUpdate(acknowledgement.update()));
|
||||||
|
assertEquals(20, serverSeen.trackedMessagesCount(), "no invisible signed packets may accumulate in server pending state");
|
||||||
|
packets.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void set(Class<?> type, Object receiver, String name, Object value) throws Exception {
|
||||||
|
var field = type.getDeclaredField(name);
|
||||||
|
field.setAccessible(true);
|
||||||
|
field.set(receiver, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,9 +45,17 @@ class NativeCombatTabRestoreTest {
|
|||||||
when(protocol.createPacket(any(PacketType.class))).thenAnswer(call -> new PacketContainer(call.getArgument(0)));
|
when(protocol.createPacket(any(PacketType.class))).thenAnswer(call -> new PacketContainer(call.getArgument(0)));
|
||||||
var sent = new ArrayList<PacketContainer>();
|
var sent = new ArrayList<PacketContainer>();
|
||||||
doAnswer(call -> { sent.add(call.getArgument(1)); return null; }).when(protocol).sendServerPacket(eq(observer), any(PacketContainer.class));
|
doAnswer(call -> { sent.add(call.getArgument(1)); return null; }).when(protocol).sendServerPacket(eq(observer), any(PacketContainer.class));
|
||||||
new ProtocolLibTabListController(protocol).add(observer, target);
|
var controller = new ProtocolLibTabListController(protocol);
|
||||||
|
controller.remove(observer, id);
|
||||||
|
var hidden = assertInstanceOf(ClientboundPlayerInfoUpdatePacket.class, sent.getFirst().getHandle(),
|
||||||
|
"tab hiding must not remove the profile/chat session needed for later signed messages");
|
||||||
|
assertEquals(java.util.EnumSet.of(ClientboundPlayerInfoUpdatePacket.Action.UPDATE_LISTED), hidden.actions());
|
||||||
|
assertEquals(id, hidden.entries().getFirst().profileId());
|
||||||
|
assertFalse(hidden.entries().getFirst().listed());
|
||||||
|
sent.clear();
|
||||||
|
controller.add(observer, target);
|
||||||
var packet = assertInstanceOf(ClientboundPlayerInfoUpdatePacket.class, sent.getFirst().getHandle());
|
var packet = assertInstanceOf(ClientboundPlayerInfoUpdatePacket.class, sent.getFirst().getHandle());
|
||||||
assertTrue(packet.actions().contains(ClientboundPlayerInfoUpdatePacket.Action.ADD_PLAYER));
|
assertFalse(packet.actions().contains(ClientboundPlayerInfoUpdatePacket.Action.ADD_PLAYER), "restoration must retain the existing profile/chat session");
|
||||||
assertTrue(packet.actions().contains(ClientboundPlayerInfoUpdatePacket.Action.UPDATE_LISTED));
|
assertTrue(packet.actions().contains(ClientboundPlayerInfoUpdatePacket.Action.UPDATE_LISTED));
|
||||||
var entry = packet.entries().getFirst();
|
var entry = packet.entries().getFirst();
|
||||||
assertEquals(id, entry.profileId());
|
assertEquals(id, entry.profileId());
|
||||||
@@ -61,7 +69,14 @@ class NativeCombatTabRestoreTest {
|
|||||||
ClientboundPlayerInfoUpdatePacket.STREAM_CODEC.encode(buffer, packet);
|
ClientboundPlayerInfoUpdatePacket.STREAM_CODEC.encode(buffer, packet);
|
||||||
var decoded = ClientboundPlayerInfoUpdatePacket.STREAM_CODEC.decode(buffer);
|
var decoded = ClientboundPlayerInfoUpdatePacket.STREAM_CODEC.decode(buffer);
|
||||||
assertEquals(packet.actions(), decoded.actions());
|
assertEquals(packet.actions(), decoded.actions());
|
||||||
assertEquals(packet.entries(), decoded.entries());
|
var restored = decoded.entries().getFirst();
|
||||||
|
assertEquals(entry.profileId(), restored.profileId());
|
||||||
|
assertEquals(entry.listed(), restored.listed());
|
||||||
|
assertEquals(entry.latency(), restored.latency());
|
||||||
|
assertEquals(entry.gameMode(), restored.gameMode());
|
||||||
|
assertEquals(entry.displayName(), restored.displayName());
|
||||||
|
assertNull(restored.profile(), "no profile replacement is encoded");
|
||||||
|
assertNull(restored.chatSession(), "no chat-session replacement is encoded");
|
||||||
} finally { buffer.release(); }
|
} finally { buffer.release(); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
package games.dmg.spigotstealth;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
import io.papermc.paper.adventure.ChatProcessor;
|
||||||
|
import io.papermc.paper.event.player.AsyncChatEvent;
|
||||||
|
import io.papermc.paper.event.player.ChatEvent;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
import net.kyori.adventure.text.Component;
|
||||||
|
import net.minecraft.network.chat.PlayerChatMessage;
|
||||||
|
import net.minecraft.server.MinecraftServer;
|
||||||
|
import net.minecraft.server.level.ServerLevel;
|
||||||
|
import net.minecraft.server.level.ServerPlayer;
|
||||||
|
import org.bukkit.Bukkit;
|
||||||
|
import org.bukkit.craftbukkit.CraftRegistry;
|
||||||
|
import org.bukkit.craftbukkit.CraftServer;
|
||||||
|
import org.bukkit.craftbukkit.CraftWorld;
|
||||||
|
import org.bukkit.craftbukkit.entity.CraftPlayer;
|
||||||
|
import org.bukkit.event.EventHandler;
|
||||||
|
import org.bukkit.event.HandlerList;
|
||||||
|
import org.bukkit.plugin.EventExecutor;
|
||||||
|
import org.bukkit.plugin.PluginManager;
|
||||||
|
import org.bukkit.plugin.RegisteredListener;
|
||||||
|
import org.bukkit.plugin.java.JavaPlugin;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
|
||||||
|
class NativeSystemChatTest {
|
||||||
|
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||||
|
|
||||||
|
@org.junit.jupiter.params.ParameterizedTest
|
||||||
|
@org.junit.jupiter.params.provider.ValueSource(strings = {"local", "cancelled", "late-cancelled", "ordinary", "metadata", "custom-renderer", "transition", "filtered", "partially-filtered", "commands-only", "chat-hidden", "signed-chain", "async-main", "legacy", "legacy-cancelled"})
|
||||||
|
@SuppressWarnings("try")
|
||||||
|
void actualPaperPipelinePreservesModerationAndAnonymousSystemDelivery(String scenario) throws Exception {
|
||||||
|
var server = mock(CraftServer.class);
|
||||||
|
var console = mock(org.bukkit.command.ConsoleCommandSender.class);
|
||||||
|
try (var platform = mockStatic(Bukkit.class, call -> switch (call.getMethod().getName()) {
|
||||||
|
case "getServer" -> server;
|
||||||
|
case "getConsoleSender" -> console;
|
||||||
|
case "isPrimaryThread" -> true;
|
||||||
|
default -> call.callRealMethod();
|
||||||
|
})) {
|
||||||
|
var sender = mock(CraftPlayer.class);
|
||||||
|
var recipient = mock(CraftPlayer.class);
|
||||||
|
var outsider = mock(CraftPlayer.class);
|
||||||
|
UUID senderId = UUID.randomUUID();
|
||||||
|
when(sender.getUniqueId()).thenReturn(senderId);
|
||||||
|
when(sender.getName()).thenReturn("Secret");
|
||||||
|
when(sender.displayName()).thenReturn(Component.text("Secret"));
|
||||||
|
when(sender.getDisplayName()).thenReturn("Secret");
|
||||||
|
var nativeSender = mock(ServerPlayer.class);
|
||||||
|
var nativeRecipient = mock(ServerPlayer.class);
|
||||||
|
var nativeOutsider = mock(ServerPlayer.class);
|
||||||
|
when(sender.getHandle()).thenReturn(nativeSender);
|
||||||
|
when(recipient.getHandle()).thenReturn(nativeRecipient);
|
||||||
|
when(outsider.getHandle()).thenReturn(nativeOutsider);
|
||||||
|
when(nativeSender.getBukkitEntity()).thenReturn(sender);
|
||||||
|
when(nativeRecipient.getBukkitEntity()).thenReturn(recipient);
|
||||||
|
when(nativeRecipient.getChatVisibility()).thenReturn(scenario.equals("commands-only") ? net.minecraft.world.entity.player.ChatVisiblity.SYSTEM
|
||||||
|
: scenario.equals("chat-hidden") ? net.minecraft.world.entity.player.ChatVisiblity.HIDDEN : net.minecraft.world.entity.player.ChatVisiblity.FULL);
|
||||||
|
when(nativeOutsider.getBukkitEntity()).thenReturn(outsider);
|
||||||
|
var world = mock(CraftWorld.class);
|
||||||
|
var nativeWorld = mock(ServerLevel.class);
|
||||||
|
when(sender.getWorld()).thenReturn(world);
|
||||||
|
when(world.getHandle()).thenReturn(nativeWorld);
|
||||||
|
when(nativeSender.level()).thenReturn(nativeWorld);
|
||||||
|
when(nativeWorld.registryAccess()).thenReturn(CraftRegistry.getMinecraftRegistry());
|
||||||
|
var config = mock(io.papermc.paper.configuration.WorldConfiguration.class);
|
||||||
|
config.scoreboards = mock(io.papermc.paper.configuration.WorldConfiguration.Scoreboards.class);
|
||||||
|
when(nativeWorld.paperConfig()).thenReturn(config);
|
||||||
|
var nativeServer = mock(MinecraftServer.class);
|
||||||
|
nativeServer.server = server;
|
||||||
|
nativeServer.console = console;
|
||||||
|
var playerList = mock(net.minecraft.server.players.PlayerList.class);
|
||||||
|
when(nativeServer.getPlayerList()).thenReturn(playerList);
|
||||||
|
when(playerList.getPlayers()).thenReturn(List.of(nativeSender, nativeRecipient, nativeOutsider));
|
||||||
|
var plugin = mock(JavaPlugin.class);
|
||||||
|
when(plugin.isEnabled()).thenReturn(true);
|
||||||
|
var concealed = new java.util.concurrent.atomic.AtomicBoolean(!scenario.equals("ordinary"));
|
||||||
|
var listener = new StealthSystemChat(id -> senderId.equals(id) && concealed.get());
|
||||||
|
var method = StealthSystemChat.class.getMethod("onChat", ChatEvent.class);
|
||||||
|
var annotation = method.getAnnotation(EventHandler.class);
|
||||||
|
ChatEvent.getHandlerList().register(new RegisteredListener(listener, EventExecutor.create(method, ChatEvent.class), annotation.priority(), plugin, annotation.ignoreCancelled()));
|
||||||
|
org.bukkit.event.Listener legacy = new org.bukkit.event.Listener() { };
|
||||||
|
if (scenario.startsWith("legacy")) {
|
||||||
|
org.bukkit.event.player.AsyncPlayerChatEvent.getHandlerList().register(new RegisteredListener(legacy, (registered, event) -> {
|
||||||
|
var chat = (org.bukkit.event.player.AsyncPlayerChatEvent) event;
|
||||||
|
chat.getRecipients().retainAll(java.util.Set.of(recipient));
|
||||||
|
chat.setMessage("moderated");
|
||||||
|
chat.setFormat("[Local] %1$s: %2$s");
|
||||||
|
if (scenario.equals("legacy-cancelled")) { chat.setCancelled(true); }
|
||||||
|
}, org.bukkit.event.EventPriority.HIGHEST, plugin, false));
|
||||||
|
}
|
||||||
|
var manager = mock(PluginManager.class);
|
||||||
|
when(server.getPluginManager()).thenReturn(manager);
|
||||||
|
doAnswer(call -> {
|
||||||
|
var event = call.getArgument(0, org.bukkit.event.Event.class);
|
||||||
|
if (event instanceof AsyncChatEvent chat && !scenario.equals("async-main")) {
|
||||||
|
chat.viewers().retainAll(java.util.Set.of(recipient)); // Ordinary local-chat/moderation stage.
|
||||||
|
if (scenario.equals("cancelled")) { chat.setCancelled(true); }
|
||||||
|
if (scenario.equals("metadata")) {
|
||||||
|
chat.message(Component.text("hello Secret").hoverEvent(net.kyori.adventure.text.event.HoverEvent.showEntity(
|
||||||
|
net.kyori.adventure.key.Key.key("minecraft:player"), senderId, Component.text("Secret")))
|
||||||
|
.clickEvent(net.kyori.adventure.text.event.ClickEvent.suggestCommand("/msg Secret hi")).insertion(senderId.toString()));
|
||||||
|
}
|
||||||
|
if (scenario.equals("custom-renderer")) {
|
||||||
|
chat.renderer((source, display, message, viewer) -> {
|
||||||
|
assertSame(recipient, viewer, "viewer-aware formatting must see the original recipient");
|
||||||
|
return Component.text("[Local] ").append(display).append(Component.text(": ")).append(message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (event instanceof ChatEvent chat && scenario.equals("async-main")) {
|
||||||
|
assertFalse(chat.isAsynchronous());
|
||||||
|
chat.viewers().retainAll(java.util.Set.of(recipient));
|
||||||
|
}
|
||||||
|
for (var registered : event.getHandlers().getRegisteredListeners()) { registered.callEvent(event); }
|
||||||
|
if (event instanceof ChatEvent chat && scenario.equals("late-cancelled")) { chat.setCancelled(true); }
|
||||||
|
return null;
|
||||||
|
}).when(manager).callEvent(any());
|
||||||
|
try {
|
||||||
|
if (scenario.equals("signed-chain")) {
|
||||||
|
verifySignedChain(nativeServer, nativeSender, recipient, senderId, concealed);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var message = PlayerChatMessage.unsigned(senderId, "hello");
|
||||||
|
if (scenario.endsWith("filtered")) {
|
||||||
|
when(nativeSender.shouldFilterMessageTo(nativeRecipient)).thenReturn(true);
|
||||||
|
var mask = new net.minecraft.network.chat.FilterMask(5);
|
||||||
|
mask.setFiltered(0);
|
||||||
|
message = message.filter(scenario.equals("filtered") ? net.minecraft.network.chat.FilterMask.FULLY_FILTERED : mask);
|
||||||
|
}
|
||||||
|
if (scenario.equals("async-main")) {
|
||||||
|
var queue = new java.util.concurrent.LinkedBlockingQueue<Runnable>();
|
||||||
|
var queueField = MinecraftServer.class.getDeclaredField("processQueue");
|
||||||
|
queueField.setAccessible(true);
|
||||||
|
queueField.set(nativeServer, queue);
|
||||||
|
Thread main = Thread.currentThread();
|
||||||
|
doAnswer(call -> { assertSame(main, Thread.currentThread(), "system delivery must stay on the main event thread"); return null; }).when(recipient).sendMessage(any(Component.class));
|
||||||
|
var processor = new ChatProcessor(nativeServer, nativeSender, message, true);
|
||||||
|
var completed = java.util.concurrent.CompletableFuture.runAsync(processor::process);
|
||||||
|
var work = queue.poll(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||||
|
assertNotNull(work, "native asynchronous chat must queue its synchronous event");
|
||||||
|
work.run();
|
||||||
|
completed.get(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||||
|
} else { new ChatProcessor(nativeServer, nativeSender, message, false).process(); }
|
||||||
|
if (scenario.equals("ordinary")) {
|
||||||
|
verify(recipient, never()).sendMessage(any(Component.class));
|
||||||
|
verify(nativeRecipient).sendChatMessage(any(net.minecraft.network.chat.OutgoingChatMessage.class), anyBoolean(), any(net.minecraft.network.chat.ChatType.Bound.class), nullable(net.minecraft.network.chat.Component.class));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (scenario.contains("cancelled") || java.util.Set.of("filtered", "commands-only", "chat-hidden").contains(scenario)) { verify(recipient, never()).sendMessage(any(Component.class)); }
|
||||||
|
else {
|
||||||
|
String expected = scenario.equals("legacy") ? "[Local] Anonymous: moderated" : scenario.equals("partially-filtered") ? "[Stealth] Anonymous: #ello" : scenario.equals("custom-renderer") ? "[Local] Anonymous: hello" : "[Stealth] Anonymous: hello" + (scenario.equals("metadata") ? " Anonymous" : "");
|
||||||
|
verify(recipient).sendMessage(Component.text(expected));
|
||||||
|
}
|
||||||
|
verify(nativeRecipient, never()).sendChatMessage(any(net.minecraft.network.chat.OutgoingChatMessage.class), anyBoolean(), any(net.minecraft.network.chat.ChatType.Bound.class), any());
|
||||||
|
verify(outsider, never()).sendMessage(any(Component.class));
|
||||||
|
verify(console, never()).sendMessage(any(Component.class));
|
||||||
|
verify(sender, never()).sendMessage(any(Component.class));
|
||||||
|
verify(nativeOutsider, never()).sendChatMessage(any(net.minecraft.network.chat.OutgoingChatMessage.class), anyBoolean(), any(net.minecraft.network.chat.ChatType.Bound.class), any());
|
||||||
|
if (scenario.equals("transition")) {
|
||||||
|
concealed.set(false);
|
||||||
|
new ChatProcessor(nativeServer, nativeSender, PlayerChatMessage.unsigned(senderId, "normal"), false).process();
|
||||||
|
verify(nativeRecipient).sendChatMessage(any(net.minecraft.network.chat.OutgoingChatMessage.class), anyBoolean(), any(net.minecraft.network.chat.ChatType.Bound.class), nullable(net.minecraft.network.chat.Component.class));
|
||||||
|
verify(recipient, times(1)).sendMessage(any(Component.class));
|
||||||
|
}
|
||||||
|
} finally { HandlerList.unregisterAll(listener); HandlerList.unregisterAll(legacy); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void verifySignedChain(MinecraftServer server, ServerPlayer sender, CraftPlayer recipient, UUID senderId,
|
||||||
|
java.util.concurrent.atomic.AtomicBoolean concealed) throws Exception {
|
||||||
|
var generator = java.security.KeyPairGenerator.getInstance("RSA");
|
||||||
|
generator.initialize(2048);
|
||||||
|
var keys = generator.generateKeyPair();
|
||||||
|
var publicKey = new net.minecraft.world.entity.player.ProfilePublicKey(new net.minecraft.world.entity.player.ProfilePublicKey.Data(
|
||||||
|
java.time.Instant.now().plusSeconds(3600), keys.getPublic(), new byte[0]));
|
||||||
|
UUID session = UUID.randomUUID();
|
||||||
|
var encoder = new net.minecraft.network.chat.SignedMessageChain(senderId, session)
|
||||||
|
.encoder(net.minecraft.util.Signer.from(keys.getPrivate(), "SHA256withRSA"));
|
||||||
|
var decoder = new net.minecraft.network.chat.SignedMessageChain(senderId, session).decoder(publicKey);
|
||||||
|
var receiver = new net.minecraft.network.chat.SignedMessageValidator.KeyBased(publicKey.createSignatureValidator(), publicKey.data()::hasExpired);
|
||||||
|
var wire = new NativeChatWire(recipient);
|
||||||
|
for (int index = 0; index < 66; index++) {
|
||||||
|
boolean anonymous = index > 0 && index < 65;
|
||||||
|
concealed.set(anonymous);
|
||||||
|
String text = "hello " + index;
|
||||||
|
var body = new net.minecraft.network.chat.SignedMessageBody(text, java.time.Instant.now(), index + 1L, net.minecraft.network.chat.LastSeenMessages.EMPTY);
|
||||||
|
var signature = encoder.pack(body);
|
||||||
|
var message = decoder.unpack(signature, body);
|
||||||
|
assertTrue(message.verify(publicKey.createSignatureValidator()));
|
||||||
|
assertEquals(index, message.link().index());
|
||||||
|
new ChatProcessor(server, sender, message, false).process();
|
||||||
|
wire.receive(anonymous, session, receiver, text);
|
||||||
|
}
|
||||||
|
var invalid = new net.minecraft.network.chat.SignedMessageChain(senderId, session).decoder(publicKey);
|
||||||
|
assertThrows(net.minecraft.network.chat.SignedMessageChain.DecodeException.class,
|
||||||
|
() -> invalid.unpack(new net.minecraft.network.chat.MessageSignature(new byte[256]), net.minecraft.network.chat.SignedMessageBody.unsigned("tampered")));
|
||||||
|
assertThrows(net.minecraft.network.chat.LastSeenMessagesValidator.ValidationException.class,
|
||||||
|
() -> new net.minecraft.network.chat.LastSeenMessagesValidator(20).applyOffset(Integer.MAX_VALUE));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package games.dmg.spigotstealth;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
import com.comphenix.protocol.PacketType;
|
||||||
|
import com.comphenix.protocol.events.PacketContainer;
|
||||||
|
import com.comphenix.protocol.events.PacketEvent;
|
||||||
|
import java.util.EnumSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
import net.minecraft.network.protocol.game.ClientboundPlayerInfoUpdatePacket;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class NativeTabMaskingTest {
|
||||||
|
private static net.minecraft.network.chat.RemoteChatSession.Data session;
|
||||||
|
@BeforeAll static void bootstrap() throws Exception {
|
||||||
|
NativeRuntime.bootstrap();
|
||||||
|
var generator = java.security.KeyPairGenerator.getInstance("RSA");
|
||||||
|
generator.initialize(2048);
|
||||||
|
session = new net.minecraft.network.chat.RemoteChatSession.Data(UUID.randomUUID(), new net.minecraft.world.entity.player.ProfilePublicKey.Data(
|
||||||
|
java.time.Instant.now().plusSeconds(3600).truncatedTo(java.time.temporal.ChronoUnit.MILLIS), generator.generateKeyPair().getPublic(), new byte[0]));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@SuppressWarnings("try")
|
||||||
|
void serverInitializationKeepsProfileAndChatStateButCannotRelistAConcealedPlayer() {
|
||||||
|
var server = mock(org.bukkit.craftbukkit.CraftServer.class);
|
||||||
|
try (var platform = mockStatic(org.bukkit.Bukkit.class, call -> switch (call.getMethod().getName()) {
|
||||||
|
case "getServer" -> server;
|
||||||
|
case "isPrimaryThread" -> true;
|
||||||
|
case "getVersion" -> "Purpur 2618 (MC: 26.2)";
|
||||||
|
case "getMinecraftVersion" -> "26.2";
|
||||||
|
case "getBukkitVersion" -> "26.2-R0.1-SNAPSHOT";
|
||||||
|
default -> call.callRealMethod();
|
||||||
|
})) {
|
||||||
|
UUID hiddenId = UUID.randomUUID(), otherId = UUID.randomUUID(), viewerId = UUID.randomUUID();
|
||||||
|
var viewer = mock(org.bukkit.entity.Player.class);
|
||||||
|
when(viewer.getUniqueId()).thenReturn(viewerId);
|
||||||
|
var snapshot = new java.util.concurrent.atomic.AtomicReference<>(Set.of(hiddenId));
|
||||||
|
var warnings = new java.util.ArrayList<String>();
|
||||||
|
var listener = new TabListMaskingListener(mock(org.bukkit.plugin.Plugin.class), snapshot::get, warnings::add);
|
||||||
|
var hidden = entry(hiddenId, "Hidden");
|
||||||
|
var other = entry(otherId, "Other");
|
||||||
|
var original = new ClientboundPlayerInfoUpdatePacket(EnumSet.allOf(ClientboundPlayerInfoUpdatePacket.Action.class), List.of(hidden, other));
|
||||||
|
var packet = new PacketContainer(PacketType.Play.Server.PLAYER_INFO, original);
|
||||||
|
var event = PacketEvent.fromServer(this, packet, viewer);
|
||||||
|
listener.onPacketSending(event);
|
||||||
|
var projected = assertInstanceOf(ClientboundPlayerInfoUpdatePacket.class, event.getPacket().getHandle());
|
||||||
|
assertFalse(projected.entries().getFirst().listed(), "native initialization must not undo concealment");
|
||||||
|
assertEquals(original.actions(), projected.actions());
|
||||||
|
var expected = new ClientboundPlayerInfoUpdatePacket.Entry(hiddenId, hidden.profile(), false, hidden.latency(), hidden.gameMode(), hidden.displayName(), hidden.showHat(), hidden.listOrder(), hidden.chatSession());
|
||||||
|
assertEquals(expected, projected.entries().getFirst());
|
||||||
|
assertSame(other, projected.entries().get(1));
|
||||||
|
assertTrue(original.entries().getFirst().listed());
|
||||||
|
var buffer = new net.minecraft.network.RegistryFriendlyByteBuf(io.netty.buffer.Unpooled.buffer(), org.bukkit.craftbukkit.CraftRegistry.getMinecraftRegistry());
|
||||||
|
try {
|
||||||
|
ClientboundPlayerInfoUpdatePacket.STREAM_CODEC.encode(buffer, projected);
|
||||||
|
var decoded = ClientboundPlayerInfoUpdatePacket.STREAM_CODEC.decode(buffer);
|
||||||
|
assertEquals(projected.actions(), decoded.actions());
|
||||||
|
assertEquals(projected.entries(), decoded.entries(), "profile/session and all other native fields survive the wire codec");
|
||||||
|
} finally { buffer.release(); }
|
||||||
|
when(viewer.getUniqueId()).thenReturn(hiddenId);
|
||||||
|
var self = PacketEvent.fromServer(this, packet, viewer);
|
||||||
|
listener.onPacketSending(self);
|
||||||
|
assertSame(packet, self.getPacket(), "retain existing self-tab behavior");
|
||||||
|
snapshot.set(Set.of());
|
||||||
|
var ended = PacketEvent.fromServer(this, packet, viewer);
|
||||||
|
listener.onPacketSending(ended);
|
||||||
|
assertSame(packet, ended.getPacket());
|
||||||
|
assertTrue(warnings.isEmpty(), warnings.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ClientboundPlayerInfoUpdatePacket.Entry entry(UUID id, String name) {
|
||||||
|
return new ClientboundPlayerInfoUpdatePacket.Entry(id, new com.mojang.authlib.GameProfile(id, name), true, 37,
|
||||||
|
net.minecraft.world.level.GameType.SURVIVAL, net.minecraft.network.chat.Component.literal(name), true, 8,
|
||||||
|
session);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user