From 8b412141da5934259c1b91ef6a894818a0798b7f Mon Sep 17 00:00:00 2001 From: Dylan Garvis Date: Sat, 12 Sep 2026 22:05:08 -0400 Subject: [PATCH] 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. --- README.md | 10 +- .../spigotstealth/NativeChatFiltering.java | 44 ++++ .../ProtocolLibTabListController.java | 10 +- .../spigotstealth/SpigotStealthPlugin.java | 3 + .../dmg/spigotstealth/StealthSystemChat.java | 64 ++++++ .../spigotstealth/TabListMaskingListener.java | 60 ++++++ .../spigotstealth/EyePluginLifecycleTest.java | 2 + .../dmg/spigotstealth/NativeChatWire.java | 74 +++++++ .../NativeCombatTabRestoreTest.java | 21 +- .../spigotstealth/NativeSystemChatTest.java | 203 ++++++++++++++++++ .../spigotstealth/NativeTabMaskingTest.java | 81 +++++++ 11 files changed, 563 insertions(+), 9 deletions(-) create mode 100644 src/main/java/games/dmg/spigotstealth/NativeChatFiltering.java create mode 100644 src/main/java/games/dmg/spigotstealth/StealthSystemChat.java create mode 100644 src/main/java/games/dmg/spigotstealth/TabListMaskingListener.java create mode 100644 src/nativeTest/java/games/dmg/spigotstealth/NativeChatWire.java create mode 100644 src/nativeTest/java/games/dmg/spigotstealth/NativeSystemChatTest.java create mode 100644 src/nativeTest/java/games/dmg/spigotstealth/NativeTabMaskingTest.java diff --git a/README.md b/README.md index 348eb6c..fe53583 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,13 @@ Artifacts are written to `build/libs/purpur-stealth-.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. -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 @@ -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. -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). diff --git a/src/main/java/games/dmg/spigotstealth/NativeChatFiltering.java b/src/main/java/games/dmg/spigotstealth/NativeChatFiltering.java new file mode 100644 index 0000000..06d3192 --- /dev/null +++ b/src/main/java/games/dmg/spigotstealth/NativeChatFiltering.java @@ -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())); + } +} diff --git a/src/main/java/games/dmg/spigotstealth/ProtocolLibTabListController.java b/src/main/java/games/dmg/spigotstealth/ProtocolLibTabListController.java index 92eae7f..50ed31a 100644 --- a/src/main/java/games/dmg/spigotstealth/ProtocolLibTabListController.java +++ b/src/main/java/games/dmg/spigotstealth/ProtocolLibTabListController.java @@ -14,7 +14,7 @@ import java.util.Objects; import java.util.UUID; 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 { private final ProtocolManager protocolManager; @@ -24,8 +24,11 @@ public final class ProtocolLibTabListController implements TabListController { @Override public void remove(Player observer, UUID targetPlayerId) { - PacketContainer packet = protocolManager.createPacket(PacketType.Play.Server.PLAYER_INFO_REMOVE); - packet.getUUIDLists().write(0, List.of(targetPlayerId)); + PacketContainer packet = protocolManager.createPacket(PacketType.Play.Server.PLAYER_INFO); + 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); } @@ -33,7 +36,6 @@ public final class ProtocolLibTabListController implements TabListController { public void add(Player observer, Player target) { PacketContainer packet = protocolManager.createPacket(PacketType.Play.Server.PLAYER_INFO); packet.getPlayerInfoActions().write(0, EnumSet.of( - EnumWrappers.PlayerInfoAction.ADD_PLAYER, EnumWrappers.PlayerInfoAction.UPDATE_GAME_MODE, EnumWrappers.PlayerInfoAction.UPDATE_LISTED, EnumWrappers.PlayerInfoAction.UPDATE_LATENCY, diff --git a/src/main/java/games/dmg/spigotstealth/SpigotStealthPlugin.java b/src/main/java/games/dmg/spigotstealth/SpigotStealthPlugin.java index 71dd77d..3163266 100644 --- a/src/main/java/games/dmg/spigotstealth/SpigotStealthPlugin.java +++ b/src/main/java/games/dmg/spigotstealth/SpigotStealthPlugin.java @@ -123,6 +123,7 @@ public final class SpigotStealthPlugin extends JavaPlugin { identityPresentation = bukkitPresentation; protocolManager.addPacketListener(new ProtocolLibIdentityMaskingListener( this, bukkitPresentation::concealedNames, getLogger()::warning)); + protocolManager.addPacketListener(new TabListMaskingListener(this, sessions::concealedPlayerIds, getLogger()::warning)); protocolManager.addPacketListener(new ProtocolLibServerListPingListener( this, sessions::concealedPlayerIds, getLogger()::warning)); getServer().getPluginManager().registerEvents( @@ -175,6 +176,8 @@ public final class SpigotStealthPlugin extends JavaPlugin { try { eyeReveal = new EyeRevealRuntime(this, eyeEquipment, sessions::isConcealed, protocolManager); 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 combatFailure = failure -> getLogger().warning("Unable to complete combat reveal: " + rootMessage(failure)); combatPotions = new PotionDamageListener(getServer()::getPlayer, combatFailure); getServer().getPluginManager().registerEvents(combatPotions, this); diff --git a/src/main/java/games/dmg/spigotstealth/StealthSystemChat.java b/src/main/java/games/dmg/spigotstealth/StealthSystemChat.java new file mode 100644 index 0000000..75c49e7 --- /dev/null +++ b/src/main/java/games/dmg/spigotstealth/StealthSystemChat.java @@ -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 concealed; + private final NativeChatFiltering filtering = new NativeChatFiltering(); + private final java.util.function.Consumer failures; + public StealthSystemChat(Predicate concealed) { + this(concealed, failure -> System.getLogger(StealthSystemChat.class.getName()).log(System.Logger.Level.WARNING, "System chat delivery failed closed", failure)); + } + public StealthSystemChat(Predicate concealed, java.util.function.Consumer 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()) + + "|(? 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); } + } + } +} diff --git a/src/main/java/games/dmg/spigotstealth/TabListMaskingListener.java b/src/main/java/games/dmg/spigotstealth/TabListMaskingListener.java new file mode 100644 index 0000000..f78124d --- /dev/null +++ b/src/main/java/games/dmg/spigotstealth/TabListMaskingListener.java @@ -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> concealed; + private final Consumer warning; + private final java.util.concurrent.atomic.AtomicBoolean warned = new java.util.concurrent.atomic.AtomicBoolean(); + public TabListMaskingListener(Plugin plugin, Supplier> concealed, Consumer 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 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(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; + } +} diff --git a/src/nativeTest/java/games/dmg/spigotstealth/EyePluginLifecycleTest.java b/src/nativeTest/java/games/dmg/spigotstealth/EyePluginLifecycleTest.java index 8c50543..fe3e928 100644 --- a/src/nativeTest/java/games/dmg/spigotstealth/EyePluginLifecycleTest.java +++ b/src/nativeTest/java/games/dmg/spigotstealth/EyePluginLifecycleTest.java @@ -137,6 +137,8 @@ class EyePluginLifecycleTest { assertTrue(listeners.stream().anyMatch(EyePotionListener.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(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(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"); diff --git a/src/nativeTest/java/games/dmg/spigotstealth/NativeChatWire.java b/src/nativeTest/java/games/dmg/spigotstealth/NativeChatWire.java new file mode 100644 index 0000000..f3d9510 --- /dev/null +++ b/src/nativeTest/java/games/dmg/spigotstealth/NativeChatWire.java @@ -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> 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.>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); + } +} diff --git a/src/nativeTest/java/games/dmg/spigotstealth/NativeCombatTabRestoreTest.java b/src/nativeTest/java/games/dmg/spigotstealth/NativeCombatTabRestoreTest.java index 6194306..a56cc34 100644 --- a/src/nativeTest/java/games/dmg/spigotstealth/NativeCombatTabRestoreTest.java +++ b/src/nativeTest/java/games/dmg/spigotstealth/NativeCombatTabRestoreTest.java @@ -45,9 +45,17 @@ class NativeCombatTabRestoreTest { when(protocol.createPacket(any(PacketType.class))).thenAnswer(call -> new PacketContainer(call.getArgument(0))); var sent = new ArrayList(); 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()); - 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)); var entry = packet.entries().getFirst(); assertEquals(id, entry.profileId()); @@ -61,7 +69,14 @@ class NativeCombatTabRestoreTest { ClientboundPlayerInfoUpdatePacket.STREAM_CODEC.encode(buffer, packet); var decoded = ClientboundPlayerInfoUpdatePacket.STREAM_CODEC.decode(buffer); 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(); } } } diff --git a/src/nativeTest/java/games/dmg/spigotstealth/NativeSystemChatTest.java b/src/nativeTest/java/games/dmg/spigotstealth/NativeSystemChatTest.java new file mode 100644 index 0000000..be4a47b --- /dev/null +++ b/src/nativeTest/java/games/dmg/spigotstealth/NativeSystemChatTest.java @@ -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(); + 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)); + } +} diff --git a/src/nativeTest/java/games/dmg/spigotstealth/NativeTabMaskingTest.java b/src/nativeTest/java/games/dmg/spigotstealth/NativeTabMaskingTest.java new file mode 100644 index 0000000..6af6086 --- /dev/null +++ b/src/nativeTest/java/games/dmg/spigotstealth/NativeTabMaskingTest.java @@ -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(); + 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); + } +}