From 1addc93062baf04c27615b7c65023b4c2515e2eb Mon Sep 17 00:00:00 2001 From: Dylan Garvis Date: Sun, 6 Sep 2026 21:19:59 -0400 Subject: [PATCH] feat(stealth): mask identities and notify admins on login --- design/index.md | 4 +- design/log.md | 6 + design/user-stories/index.md | 4 +- .../us-002-rejoin-without-visible-identity.md | 16 ++- .../us-004-administer-player-stealth.md | 11 +- .../BukkitIdentityPresentation.java | 23 ++- .../spigotstealth/IdentityPacketFields.java | 46 ++++++ .../ProtocolLibIdentityMaskingListener.java | 136 ++++++++++++++++++ .../spigotstealth/SpigotStealthPlugin.java | 7 +- .../StealthAdminJoinListener.java | 49 +++++++ .../dmg/spigotstealth/StealthChatMasker.java | 117 +++++++++++++++ .../BukkitIdentityPresentationTest.java | 14 +- .../IdentityPacketFieldsTest.java | 32 +++++ ...rotocolLibIdentityMaskingListenerTest.java | 63 ++++++++ .../StealthAdminJoinListenerTest.java | 83 +++++++++++ .../spigotstealth/StealthChatMaskerTest.java | 70 +++++++++ .../StealthSessionListenerTest.java | 7 +- 17 files changed, 677 insertions(+), 11 deletions(-) create mode 100644 src/main/java/games/dmg/spigotstealth/IdentityPacketFields.java create mode 100644 src/main/java/games/dmg/spigotstealth/ProtocolLibIdentityMaskingListener.java create mode 100644 src/main/java/games/dmg/spigotstealth/StealthAdminJoinListener.java create mode 100644 src/main/java/games/dmg/spigotstealth/StealthChatMasker.java create mode 100644 src/test/java/games/dmg/spigotstealth/IdentityPacketFieldsTest.java create mode 100644 src/test/java/games/dmg/spigotstealth/ProtocolLibIdentityMaskingListenerTest.java create mode 100644 src/test/java/games/dmg/spigotstealth/StealthAdminJoinListenerTest.java create mode 100644 src/test/java/games/dmg/spigotstealth/StealthChatMaskerTest.java diff --git a/design/index.md b/design/index.md index 93643e3..004529a 100644 --- a/design/index.md +++ b/design/index.md @@ -4,7 +4,9 @@ okf_version: "0.1" # Spigot Stealth Design -Spigot Stealth rewards players who spend time under invisibility effects from potions they drink, then lets unlocked players conceal their identity for a later session. +Spigot Stealth rewards players who spend time under invisibility effects from potions they drink, then lets unlocked players conceal their identity for a later session. [Session concealment](user-stories/us-002-rejoin-without-visible-identity.md) includes command-suggestion filtering and scrambled message and overhead names. + +[Administration](user-stories/us-004-administer-player-stealth.md) provides private concealed-player counts at login and command-based inspection. ## Explore diff --git a/design/log.md b/design/log.md index c44025e..c6d80be 100644 --- a/design/log.md +++ b/design/log.md @@ -1,5 +1,11 @@ # Spigot Stealth Design Log +## 2026-09-06 + +- **Completion**: Extended US-004 with permission-gated private concealed-player counts for joining admins and arrival notices for online admins, including command hints, zero-count silence, and no duplicate notice for concealed admins. Verified all 70 tests, the JAR build, and OKF validation; not deployed or live-server tested. + +- **Completion**: Extended US-002 with concealed-username suggestion filtering, scrambled default chat and vanilla message/advancement names, scrambled overhead tags, and display-name restoration. Verified 65 automated tests, the Gradle JAR build, and OKF validation; live-server compatibility remains untested and custom chat/scoreboard integrations are outside this slice. + ## 2026-09-05 - **Fix**: Reworked US-002 server-list filtering for Purpur's native `NameAndId` samples by filtering names through Bukkit, limiting ProtocolLib to guarded count adjustment, and rate-limiting compatibility warnings; verified the complete Gradle build and OKF bundle. diff --git a/design/user-stories/index.md b/design/user-stories/index.md index 748f41e..5762ea3 100644 --- a/design/user-stories/index.md +++ b/design/user-stories/index.md @@ -1,8 +1,8 @@ # Spigot Stealth User Stories 1. [US-001: Accumulate invisibility time and unlock stealth](us-001-accumulate-invisibility-and-unlock.md) - Earn stealth by spending eight hours under invisibility effects from directly consumed potions. -2. [US-002: Rejoin without a visible identity](us-002-rejoin-without-visible-identity.md) - Turn a qualifying invisible disconnect into one concealed online session. +2. [US-002: Rejoin without a visible identity](us-002-rejoin-without-visible-identity.md) - Turn a qualifying invisible disconnect into one concealed online session with filtered name suggestions and scrambled message and overhead names. 3. [US-003: Check personal stealth progress](us-003-check-personal-progress.md) - View accumulated time, remaining time, and unlock status. -4. [US-004: Inspect and manage player stealth](us-004-administer-player-stealth.md) - Inspect and modify online or offline progression and find concealed players. +4. [US-004: Inspect and manage player stealth](us-004-administer-player-stealth.md) - Inspect and modify online or offline progression, find concealed players, and receive private login count notices. 5. [US-005: Configure and persist stealth progression](us-005-configure-and-persist-progression.md) - Keep progression durable and operator-configurable. 6. [US-006: Build, test, and release the plugin](us-006-build-test-and-release.md) - Provide repeatable Gradle builds and Gitea delivery. diff --git a/design/user-stories/us-002-rejoin-without-visible-identity.md b/design/user-stories/us-002-rejoin-without-visible-identity.md index b5c682c..9dbb335 100644 --- a/design/user-stories/us-002-rejoin-without-visible-identity.md +++ b/design/user-stories/us-002-rejoin-without-visible-identity.md @@ -29,7 +29,11 @@ As an **unlocked player**, I want to disconnect while invisibility from a potion - [x] When the sleep-count policy is `include`, concealed players count normally; ordinary players are never modified by either policy. - [x] A player's previous sleeping-ignore state is restored when concealment ends, the player disconnects or is reset, the policy changes to `include`, or the plugin disables. - [x] Policy changes apply immediately to currently concealed players. -- [x] Throughout the concealed session, no overhead name tag identifies the player to any other player, including administrators. +- [x] Throughout the concealed session, the overhead name is rendered as scrambled magic text, including for administrators, while the physical character stays visible. +- [x] Concealed usernames are excluded from outgoing command suggestions, including `/msg`, `/tell`, and `/w`; manually addressing a known username remains possible. +- [x] Default chat display names and vanilla private-message and advancement name components are scrambled without changing message content or signed chat bodies. +- [x] Masked message name components do not expose the original identity through hover or click actions. +- [x] Previous display names and ordinary suggestions return when concealment ends; ordinary players remain unaffected. - [x] The concealed player's physical character remains visible in the world and retains ordinary movement, interaction, combat, and permission behavior. - [x] The concealed player receives a private message explaining that stealth is active for the session. - [x] Concealment lasts until the player disconnects and is handled predictably across death and plugin reload or disable events. @@ -41,6 +45,16 @@ As an **unlocked player**, I want to disconnect while invisibility from a potion Automated tests verify unlocked and locked disconnect transitions, ordinary-disconnect clearing, one-login consumption, concealed join and quit announcement suppression, preservation of ordinary announcements, private activation messaging, ordinary-login presentation, tab removal for existing and new observers, overhead-name suppression, active concealed-session tracking, ProtocolLib-independent native player-sample filtering, guarded public server-list count adjustment, nonnegative counts, unchanged maximum capacity, default sleep-count exclusion, immediate policy changes, restoration of prior sleeping-ignore state, and the absence of entity-hiding calls. ProtocolLib is declared as a required dependency, prepared state round trips through YAML, and `./gradlew clean check jar` passes. +Identity masking verification (2026-09-06): `./gradlew --offline clean check jar` passed all 65 tests and OKF validation. Tests cover semantic name slots, preserved message content, stripped identifying actions, reflected suggestion entries and chat-bound records, safe packet failure handling, immutable name snapshots, overhead formatting, and display-name restoration after repeated concealment. Presentation tests first failed to compile against the missing snapshot API, then passed after implementation. These are automated unit checks, not a live-client smoke test. + +## Identity masking scope + +Masking applies to active concealed sessions, not every potion effect. Outgoing username suggestions are filtered across commands; entering a known username manually still works. Default Bukkit chat uses a temporary scrambled `Anonymous` display name. Vanilla translated announcements and chat-type bound names are masked at the packet boundary without editing signed message bodies. The overhead tag uses scoreboard magic formatting on the profile name, not a profile rename; this is visual concealment, not anonymity against modified clients. + +Custom chat formats that ignore Bukkit display names, nicknames, custom scoreboards, and third-party messaging plugins may need dedicated integration. Unsupported packet layouts retain the original packet and emit one warning per packet type rather than interrupting chat delivery. Live-server/client compatibility still requires a smoke test, particularly after Minecraft or ProtocolLib upgrades. + +Implementation: [identity presentation](../../src/main/java/games/dmg/spigotstealth/BukkitIdentityPresentation.java), [packet listener](../../src/main/java/games/dmg/spigotstealth/ProtocolLibIdentityMaskingListener.java), and [semantic name masking](../../src/main/java/games/dmg/spigotstealth/StealthChatMasker.java). + ## Related - [Accumulate invisibility time and unlock stealth](us-001-accumulate-invisibility-and-unlock.md) diff --git a/design/user-stories/us-004-administer-player-stealth.md b/design/user-stories/us-004-administer-player-stealth.md index 25316bb..6fed946 100644 --- a/design/user-stories/us-004-administer-player-stealth.md +++ b/design/user-stories/us-004-administer-player-stealth.md @@ -19,7 +19,12 @@ As a **server administrator**, I want to inspect and correct player stealth stat - [x] `/stealthadmin reset confirm` clears accumulated time, unlock ownership, prepared-login state, and current concealment for an online or known offline player. - [x] Reset requires explicit confirmation and safely restores an online concealed player to ordinary identity presentation. - [x] `/stealthadmin list` lists every currently online concealed player and clearly reports when there are none. -- [x] Concealed players remain absent from administrators' ordinary tab lists and retain hidden overhead name tags; the admin command is the supported discovery mechanism. +- [x] Concealed players remain absent from administrators' ordinary tab lists and retain scrambled overhead names; the admin command is the supported discovery mechanism. +- [x] An administrator joining while concealed players are online receives a private count and a hint to use `/stealthadmin list`. +- [x] When a concealed player joins, online administrators receive a private arrival notice, the updated concealed-player count, and the command hint. +- [x] Notifications require `spigotstealth.admin`; ordinary players receive no administrative notice. +- [x] Counts include the newly joined concealed player, and a joining concealed administrator receives only one administrative notification. +- [x] An administrator joining when no concealed players are online receives no notification. - [x] Commands clearly reject unknown, ambiguous, malformed, or otherwise invalid targets without creating unintended player records. - [x] Administrative inspection and modification require an operator-default administrative permission. - [x] State-changing operations persist before success is reported and are safe under retries. @@ -38,6 +43,10 @@ As a **server administrator**, I want to inspect and correct player stealth stat Automated tests verify exact offline name and UUID resolution, ambiguous and unknown rejection without record creation, complete status output, idempotent grants and notification, complete resets with presentation cleanup, online concealed-player filtering, online and offline unlocked-player listing, contextual permission-gated completion, durable sleep-count status and policy changes, immediate policy refresh, confirmation and permission gates, persisted-before-success replies, and audit records. The complete `./gradlew clean check jar` lifecycle passes. +Login notice verification (2026-09-06): `./gradlew --offline clean check jar` passed all 70 tests and OKF validation. New tests first failed against the absent listener, then passed after implementation. Tests cover permission gates, zero-count silence, singular/plural counts, concealed-admin deduplication, ordinary-join silence for existing admins, and a prepared-login transition followed by an accurate administrative notice. Live-server verification remains pending. + +Implementation: [admin join listener](../../src/main/java/games/dmg/spigotstealth/StealthAdminJoinListener.java), registered at `MONITOR` after the session listener's `HIGHEST` login transition. Notices expose only counts; names remain available through `/stealthadmin list`. + ## Related - [Rejoin without a visible identity](us-002-rejoin-without-visible-identity.md) diff --git a/src/main/java/games/dmg/spigotstealth/BukkitIdentityPresentation.java b/src/main/java/games/dmg/spigotstealth/BukkitIdentityPresentation.java index f27168a..b22af1b 100644 --- a/src/main/java/games/dmg/spigotstealth/BukkitIdentityPresentation.java +++ b/src/main/java/games/dmg/spigotstealth/BukkitIdentityPresentation.java @@ -18,6 +18,18 @@ public final class BukkitIdentityPresentation implements IdentityPresentation { private final Supplier sleepCountPolicy; private final Map concealedPlayers = new LinkedHashMap<>(); private final Map previousSleepingIgnored = new LinkedHashMap<>(); + private final Map previousDisplayNames = new LinkedHashMap<>(); + private volatile java.util.Set concealedNames = java.util.Set.of(); + + /** Immutable snapshot safe to read from outgoing packet threads. */ + public java.util.Set concealedNames() { + return concealedNames; + } + + private void publishNames() { + concealedNames = concealedPlayers.values().stream().map(Player::getName) + .collect(java.util.stream.Collectors.toUnmodifiableSet()); + } public BukkitIdentityPresentation( Supplier> onlinePlayers, @@ -40,12 +52,17 @@ public final class BukkitIdentityPresentation implements IdentityPresentation { @Override public void conceal(Player player) { concealedPlayers.put(player.getUniqueId(), player); + publishNames(); + previousDisplayNames.putIfAbsent(player.getUniqueId(), player.getDisplayName()); + player.setDisplayName("§kAnonymous§r"); applySleepCountPolicy(player); Team team = scoreboard.getTeam(teamName(player.getUniqueId())); if (team == null) { team = scoreboard.registerNewTeam(teamName(player.getUniqueId())); } - team.setOption(Team.Option.NAME_TAG_VISIBILITY, Team.OptionStatus.NEVER); + team.setOption(Team.Option.NAME_TAG_VISIBILITY, Team.OptionStatus.ALWAYS); + team.setPrefix("§k"); + team.setSuffix("§r"); team.addEntry(player.getName()); for (Player observer : onlinePlayers.get()) { if (!observer.getUniqueId().equals(player.getUniqueId())) { @@ -57,6 +74,10 @@ public final class BukkitIdentityPresentation implements IdentityPresentation { @Override public void reveal(Player player) { boolean wasConcealed = concealedPlayers.remove(player.getUniqueId()) != null; + publishNames(); + if (previousDisplayNames.containsKey(player.getUniqueId())) { + player.setDisplayName(previousDisplayNames.remove(player.getUniqueId())); + } restoreSleepingIgnored(player); Team team = scoreboard.getTeam(teamName(player.getUniqueId())); if (team != null) { diff --git a/src/main/java/games/dmg/spigotstealth/IdentityPacketFields.java b/src/main/java/games/dmg/spigotstealth/IdentityPacketFields.java new file mode 100644 index 0000000..802d7ab --- /dev/null +++ b/src/main/java/games/dmg/spigotstealth/IdentityPacketFields.java @@ -0,0 +1,46 @@ +package games.dmg.spigotstealth; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Method; +import java.lang.reflect.RecordComponent; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.function.UnaryOperator; + +/** Small reflective bridge: avoids a compile/runtime dependency on a specific NMS or Brigadier version. */ +final class IdentityPacketFields { + private IdentityPacketFields() { } + + static List filterEntries(List entries, StealthChatMasker masker) throws ReflectiveOperationException { + List filtered = new ArrayList<>(); + for (Object entry : entries) { + Method text; + try { + text = entry.getClass().getMethod("text"); + } catch (NoSuchMethodException exception) { + text = entry.getClass().getMethod("getText"); + } + if (!masker.isConcealedName((String) text.invoke(entry))) { + filtered.add(entry); + } + } + return List.copyOf(filtered); + } + + static Object mapRecord(Object record, UnaryOperator mapper) throws ReflectiveOperationException { + RecordComponent[] components = record.getClass().getRecordComponents(); + if (components == null) { + throw new IllegalArgumentException("Unsupported non-record chat binding"); + } + Class[] types = new Class[components.length]; + Object[] values = new Object[components.length]; + for (int i = 0; i < components.length; i++) { + types[i] = components[i].getType(); + Object value = components[i].getAccessor().invoke(record); + values[i] = value instanceof Optional optional ? optional.map(mapper) : mapper.apply(value); + } + Constructor constructor = record.getClass().getDeclaredConstructor(types); + return constructor.newInstance(values); + } +} diff --git a/src/main/java/games/dmg/spigotstealth/ProtocolLibIdentityMaskingListener.java b/src/main/java/games/dmg/spigotstealth/ProtocolLibIdentityMaskingListener.java new file mode 100644 index 0000000..999286c --- /dev/null +++ b/src/main/java/games/dmg/spigotstealth/ProtocolLibIdentityMaskingListener.java @@ -0,0 +1,136 @@ +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.PacketContainer; +import com.comphenix.protocol.events.PacketEvent; +import com.comphenix.protocol.reflect.StructureModifier; +import com.comphenix.protocol.utility.MinecraftReflection; +import com.comphenix.protocol.wrappers.WrappedChatComponent; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Consumer; +import java.util.function.Supplier; +import org.bukkit.plugin.Plugin; + +/** + * Outgoing identity-only masking. Registration is deliberately left to the plugin. + * The supplier must return an immutable, safely published snapshot (for example AtomicReference::get). + * It must not inspect Bukkit players or mutable session collections on the packet thread. + */ +public final class ProtocolLibIdentityMaskingListener extends PacketAdapter { + private final Supplier> concealedNames; + private final Consumer warningLog; + private final Set warned = ConcurrentHashMap.newKeySet(); + + public ProtocolLibIdentityMaskingListener( + Plugin plugin, Supplier> concealedNames, Consumer warningLog) { + // ProtocolLib calls the vanilla PLAYER_CHAT packet CHAT. + super(plugin, ListenerPriority.HIGHEST, PacketType.Play.Server.TAB_COMPLETE, + PacketType.Play.Server.SYSTEM_CHAT, PacketType.Play.Server.CHAT, + PacketType.Play.Server.DISGUISED_CHAT); + this.concealedNames = Objects.requireNonNull(concealedNames, "concealedNames"); + this.warningLog = Objects.requireNonNull(warningLog, "warningLog"); + } + + @Override + public void onPacketSending(PacketEvent event) { + if (event.isCancelled()) { + return; + } + try { + Set snapshot = concealedNames.get(); + if (snapshot.isEmpty()) { + return; + } + StealthChatMasker masker = new StealthChatMasker(snapshot); + PacketContainer packet = event.getPacket().shallowClone(); + if (event.getPacketType().equals(PacketType.Play.Server.TAB_COMPLETE)) { + filterSuggestions(packet, masker); + } else if (event.getPacketType().equals(PacketType.Play.Server.SYSTEM_CHAT)) { + maskSystemChat(packet, masker); + } else { + maskBoundNames(packet, masker); + } + event.setPacket(packet); + } catch (ReflectiveOperationException | RuntimeException exception) { + // Keep delivery usable; do not cancel signed chat and break the acknowledgement chain. + if (warned.add(event.getPacketType())) { + warningLog.accept("Stealth identity masking unavailable for " + event.getPacketType() + + "; original packet retained. Failure: " + exception.getClass().getSimpleName()); + } + } + } + + private static void filterSuggestions(PacketContainer packet, StealthChatMasker masker) + throws ReflectiveOperationException { + StructureModifier fields = packet.getModifier(); + for (int i = 0; i < fields.size(); i++) { + Object value = fields.read(i); + if (value instanceof List entries) { + // Modern ClientboundCommandSuggestionsPacket stores its Entry list directly. + fields.write(i, IdentityPacketFields.filterEntries(entries, masker)); + return; + } + if (value != null && value.getClass().getName().equals("com.mojang.brigadier.suggestion.Suggestions")) { + Object range = value.getClass().getMethod("getRange").invoke(value); + List entries = (List) value.getClass().getMethod("getList").invoke(value); + List filtered = IdentityPacketFields.filterEntries(entries, masker); + fields.write(i, value.getClass().getConstructor(range.getClass(), List.class).newInstance(range, filtered)); + return; + } + } + // Pre-Brigadier protocol compatibility. + StructureModifier arrays = packet.getStringArrays(); + if (arrays.size() > 0) { + arrays.write(0, java.util.Arrays.stream(arrays.read(0)) + .filter(name -> !masker.isConcealedName(name)).toArray(String[]::new)); + return; + } + throw new IllegalStateException("Unsupported command suggestions layout"); + } + + private static void maskSystemChat(PacketContainer packet, StealthChatMasker masker) { + StructureModifier components = packet.getChatComponents(); + if (components.size() > 0) { + WrappedChatComponent original = components.read(0); + String json = original.getJson(); + String masked = masker.maskAnnouncement(json); + if (!json.equals(masked)) { + components.write(0, WrappedChatComponent.fromJson(masked)); + } + } else if (packet.getStrings().size() > 0) { + // Older SYSTEM_CHAT represents its component as JSON text. + String json = packet.getStrings().read(0); + packet.getStrings().write(0, masker.maskAnnouncement(json)); + } else { + throw new IllegalStateException("Unsupported system chat layout"); + } + } + + private static void maskBoundNames(PacketContainer packet, StealthChatMasker masker) + throws ReflectiveOperationException { + StructureModifier fields = packet.getModifier(); + for (int i = 0; i < fields.size(); i++) { + Object value = fields.read(i); + if (value != null && (value.getClass().getName().endsWith("ChatType$BoundNetwork") + || value.getClass().getName().endsWith("ChatType$Bound"))) { + Object masked = IdentityPacketFields.mapRecord(value, field -> { + if (field != null && MinecraftReflection.getIChatBaseComponentClass().isInstance(field)) { + String json = WrappedChatComponent.fromHandle(field).getJson(); + String replacement = masker.maskDisplayName(json); + return json.equals(replacement) ? field : WrappedChatComponent.fromJson(replacement).getHandle(); + } + return field; + }); + fields.write(i, masked); + return; + } + } + // Intentionally never touch signed body, unsigned body, UUID, signature, or filter mask. + throw new IllegalStateException("Unsupported player chat display-name layout"); + } +} diff --git a/src/main/java/games/dmg/spigotstealth/SpigotStealthPlugin.java b/src/main/java/games/dmg/spigotstealth/SpigotStealthPlugin.java index aca5fec..30f3299 100644 --- a/src/main/java/games/dmg/spigotstealth/SpigotStealthPlugin.java +++ b/src/main/java/games/dmg/spigotstealth/SpigotStealthPlugin.java @@ -77,11 +77,14 @@ public final class SpigotStealthPlugin extends JavaPlugin { manager, settings.unlockThreshold(), System::nanoTime, notifier); sessions = new StealthSessionService(manager, progression); protocolManager = ProtocolLibrary.getProtocolManager(); - identityPresentation = new BukkitIdentityPresentation( + BukkitIdentityPresentation bukkitPresentation = new BukkitIdentityPresentation( getServer()::getOnlinePlayers, Objects.requireNonNull(getServer().getScoreboardManager(), "scoreboard manager").getMainScoreboard(), new ProtocolLibTabListController(protocolManager), () -> manager.snapshot().sleepCountPolicy()); + identityPresentation = bukkitPresentation; + protocolManager.addPacketListener(new ProtocolLibIdentityMaskingListener( + this, bukkitPresentation::concealedNames, getLogger()::warning)); protocolManager.addPacketListener(new ProtocolLibServerListPingListener( this, sessions::concealedPlayerIds, getLogger()::warning)); getServer().getPluginManager().registerEvents( @@ -90,6 +93,8 @@ public final class SpigotStealthPlugin extends JavaPlugin { new InvisibilityEffectListener(progression, Clock.systemUTC()), this); getServer().getPluginManager().registerEvents( new StealthSessionListener(sessions, identityPresentation, settings.concealedMessage()), this); + getServer().getPluginManager().registerEvents( + new StealthAdminJoinListener(sessions, getServer()::getOnlinePlayers), this); org.bukkit.command.PluginCommand stealthPluginCommand = Objects.requireNonNull(getCommand("stealth"), "stealth command"); StealthCommand stealthCommand = new StealthCommand(manager, progression, settings); diff --git a/src/main/java/games/dmg/spigotstealth/StealthAdminJoinListener.java b/src/main/java/games/dmg/spigotstealth/StealthAdminJoinListener.java new file mode 100644 index 0000000..5e3a535 --- /dev/null +++ b/src/main/java/games/dmg/spigotstealth/StealthAdminJoinListener.java @@ -0,0 +1,49 @@ +package games.dmg.spigotstealth; + +import java.util.Collection; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.function.Supplier; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerJoinEvent; + +/** Private administrative notices after the HIGHEST-priority session transition. */ +public final class StealthAdminJoinListener implements Listener { + private static final String PERMISSION = "spigotstealth.admin"; + private static final String HINT = " Use /stealthadmin list to see who."; + private final StealthSessionService sessions; + private final Supplier> onlinePlayers; + + public StealthAdminJoinListener( + StealthSessionService sessions, + Supplier> onlinePlayers) { + this.sessions = Objects.requireNonNull(sessions, "sessions"); + this.onlinePlayers = Objects.requireNonNull(onlinePlayers, "onlinePlayers"); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onJoin(PlayerJoinEvent event) { + Player joining = event.getPlayer(); + Set concealed = sessions.concealedPlayerIds(); + int count = concealed.size(); + if (count == 0) { + return; + } + String quantity = count + (count == 1 ? " player is" : " players are"); + if (joining.hasPermission(PERMISSION)) { + joining.sendMessage("[Stealth] " + quantity + " invisible." + HINT); + } + if (concealed.contains(joining.getUniqueId())) { + String notice = "[Stealth] An invisible player joined. " + quantity + " now invisible." + HINT; + for (Player observer : onlinePlayers.get()) { + if (!observer.getUniqueId().equals(joining.getUniqueId()) && observer.hasPermission(PERMISSION)) { + observer.sendMessage(notice); + } + } + } + } +} diff --git a/src/main/java/games/dmg/spigotstealth/StealthChatMasker.java b/src/main/java/games/dmg/spigotstealth/StealthChatMasker.java new file mode 100644 index 0000000..3c27248 --- /dev/null +++ b/src/main/java/games/dmg/spigotstealth/StealthChatMasker.java @@ -0,0 +1,117 @@ +package games.dmg.spigotstealth; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import com.google.gson.JsonParser; +import java.util.Locale; +import java.util.Set; +import java.util.stream.Collectors; + +/** Masks semantic name slots, never searching/replacing user-authored message text. */ +public final class StealthChatMasker { + private static final Set NAME_FIRST_TRANSLATIONS = Set.of( + "chat.type.text", "chat.type.announcement", "chat.type.emote", + "commands.message.display.incoming", "commands.message.display.outgoing", + "chat.type.advancement.task", "chat.type.advancement.goal", "chat.type.advancement.challenge"); + private final Set names; + + public StealthChatMasker(Set concealedNames) { + names = concealedNames.stream().map(name -> name.toLowerCase(Locale.ROOT)).collect(Collectors.toUnmodifiableSet()); + } + + public boolean isConcealedName(String text) { + return names.contains(text.toLowerCase(Locale.ROOT)); + } + + public String maskAnnouncement(String json) { + try { + JsonElement component = JsonParser.parseString(json); + return maskAnnouncement(component) ? component.toString() : json; + } catch (JsonParseException | IllegalStateException exception) { + return json; + } + } + + public String maskDisplayName(String json) { + try { + return isConcealedName(plainName(JsonParser.parseString(json))) ? anonymous().toString() : json; + } catch (JsonParseException | IllegalStateException exception) { + return json; + } + } + + private boolean maskAnnouncement(JsonElement component) { + boolean changed = false; + if (component.isJsonArray()) { + for (JsonElement child : component.getAsJsonArray()) { + changed |= maskAnnouncement(child); + } + if (changed && !component.getAsJsonArray().isEmpty()) { + clearInheritedActions(component.getAsJsonArray().get(0)); + } + } else if (component.isJsonObject()) { + JsonObject object = component.getAsJsonObject(); + // Translation arguments other than the explicitly known name slot are message content. + if (object.has("translate") && object.get("translate").isJsonPrimitive() + && NAME_FIRST_TRANSLATIONS.contains(object.get("translate").getAsString()) + && object.has("with") && object.get("with").isJsonArray()) { + JsonArray arguments = object.getAsJsonArray("with"); + if (!arguments.isEmpty() && isConcealedName(plainName(arguments.get(0)))) { + arguments.set(0, anonymous()); + changed = true; + } + } + if (object.has("extra")) { + changed |= maskAnnouncement(object.get("extra")); + } + // Prevent masked descendants from inheriting an identifying action from their parent. + if (changed) { + clearInheritedActions(object); + } + } + return changed; + } + + private static void clearInheritedActions(JsonElement component) { + if (component.isJsonObject()) { + for (String key : Set.of("hoverEvent", "clickEvent", "hover_event", "click_event", "insertion")) { + component.getAsJsonObject().remove(key); + } + } else if (component.isJsonArray() && !component.getAsJsonArray().isEmpty()) { + clearInheritedActions(component.getAsJsonArray().get(0)); + } + } + + private static String plainName(JsonElement component) { + if (component.isJsonPrimitive()) { + return component.getAsString(); + } + StringBuilder text = new StringBuilder(); + if (component.isJsonArray()) { + for (JsonElement child : component.getAsJsonArray()) { + text.append(plainName(child)); + } + } else if (component.isJsonObject()) { + JsonObject object = component.getAsJsonObject(); + if (object.has("text") && object.get("text").isJsonPrimitive()) { + text.append(object.get("text").getAsString()); + } + if (object.has("extra")) { + text.append(plainName(object.get("extra"))); + } + } + return text.toString(); + } + + private static JsonObject anonymous() { + JsonObject alias = new JsonObject(); + alias.addProperty("text", "Anonymous"); + alias.addProperty("obfuscated", true); + alias.add("hoverEvent", com.google.gson.JsonNull.INSTANCE); + alias.add("clickEvent", com.google.gson.JsonNull.INSTANCE); + alias.addProperty("insertion", ""); + return alias; + } +} diff --git a/src/test/java/games/dmg/spigotstealth/BukkitIdentityPresentationTest.java b/src/test/java/games/dmg/spigotstealth/BukkitIdentityPresentationTest.java index 4663d6e..b607c0f 100644 --- a/src/test/java/games/dmg/spigotstealth/BukkitIdentityPresentationTest.java +++ b/src/test/java/games/dmg/spigotstealth/BukkitIdentityPresentationTest.java @@ -25,10 +25,14 @@ class BukkitIdentityPresentationTest { BukkitIdentityPresentation presentation = new BukkitIdentityPresentation( () -> List.of(target), scoreboard, mock(TabListController.class)); + when(target.getDisplayName()).thenReturn("Original Alex"); + presentation.conceal(target); presentation.conceal(target); presentation.reveal(target); - verify(target).setSleepingIgnored(true); + verify(target).setDisplayName("Original Alex"); + org.junit.jupiter.api.Assertions.assertTrue(presentation.concealedNames().isEmpty()); + verify(target, org.mockito.Mockito.times(2)).setSleepingIgnored(true); verify(target).setSleepingIgnored(false); } @@ -73,7 +77,7 @@ class BukkitIdentityPresentationTest { } @Test - void concealRemovesTabEntryAndHidesNameTagWithoutHidingEntity() { + void concealRemovesTabEntryAndScramblesNameTagWithoutHidingEntity() { UUID targetId = UUID.randomUUID(); Player target = player(targetId, "Alex"); Player observer = player(UUID.randomUUID(), "Morgan"); @@ -87,7 +91,11 @@ class BukkitIdentityPresentationTest { presentation.conceal(target); - verify(team).setOption(Team.Option.NAME_TAG_VISIBILITY, Team.OptionStatus.NEVER); + verify(team).setOption(Team.Option.NAME_TAG_VISIBILITY, Team.OptionStatus.ALWAYS); + verify(team).setPrefix("§k"); + verify(team).setSuffix("§r"); + verify(target).setDisplayName("§kAnonymous§r"); + org.junit.jupiter.api.Assertions.assertEquals(java.util.Set.of("Alex"), presentation.concealedNames()); verify(team).addEntry("Alex"); verify(tabLists).remove(observer, targetId); verify(observer, never()).hidePlayer(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()); diff --git a/src/test/java/games/dmg/spigotstealth/IdentityPacketFieldsTest.java b/src/test/java/games/dmg/spigotstealth/IdentityPacketFieldsTest.java new file mode 100644 index 0000000..2c3034c --- /dev/null +++ b/src/test/java/games/dmg/spigotstealth/IdentityPacketFieldsTest.java @@ -0,0 +1,32 @@ +package games.dmg.spigotstealth; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.List; +import java.util.Optional; +import java.util.Set; +import org.junit.jupiter.api.Test; + +class IdentityPacketFieldsTest { + private final StealthChatMasker masker = new StealthChatMasker(Set.of("Secret")); + + record Entry(String text, Optional tooltip) { } + record Bound(int chatType, String name, Optional targetName) { } + + @Test + void filtersModernSuggestionEntriesWithoutChangingOrdinaryEntries() throws ReflectiveOperationException { + Entry ordinary = new Entry("Someone", Optional.of("help")); + List entries = List.of(new Entry("sEcReT", Optional.empty()), ordinary); + assertEquals(List.of(ordinary), IdentityPacketFields.filterEntries(entries, masker)); + assertEquals(2, entries.size()); + } + + @Test + void rebuildsDisplayNameRecordWithoutMutatingOriginal() throws ReflectiveOperationException { + Bound original = new Bound(42, "Secret", Optional.of("Secret")); + Object copy = IdentityPacketFields.mapRecord(original, + value -> value.equals("Secret") ? "Anonymous" : value); + assertEquals(new Bound(42, "Anonymous", Optional.of("Anonymous")), copy); + assertEquals("Secret", original.name()); + } +} diff --git a/src/test/java/games/dmg/spigotstealth/ProtocolLibIdentityMaskingListenerTest.java b/src/test/java/games/dmg/spigotstealth/ProtocolLibIdentityMaskingListenerTest.java new file mode 100644 index 0000000..93f324b --- /dev/null +++ b/src/test/java/games/dmg/spigotstealth/ProtocolLibIdentityMaskingListenerTest.java @@ -0,0 +1,63 @@ +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.PacketEvent; +import com.comphenix.protocol.utility.MinecraftReflection; +import com.comphenix.protocol.utility.MinecraftVersion; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; +import org.bukkit.plugin.Plugin; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +class ProtocolLibIdentityMaskingListenerTest { + private static MockedStatic reflection; + + @BeforeAll + static void initializeProtocolTypesWithoutServer() { + MinecraftVersion.setCurrentVersion(new MinecraftVersion(1, 21, 8)); + reflection = mockStatic(MinecraftReflection.class); + } + + @AfterAll + static void resetProtocolEnvironment() { + reflection.close(); + MinecraftVersion.setCurrentVersion(null); + } + + @Test + void skipsPacketWorkWhenNobodyIsConcealed() { + AtomicReference> names = new AtomicReference<>(Set.of()); + var listener = new ProtocolLibIdentityMaskingListener(mock(Plugin.class), names::get, ignored -> { }); + PacketEvent event = mock(PacketEvent.class); + listener.onPacketSending(event); + verify(event, never()).getPacket(); + assertTrue(listener.getSendingWhitelist().getTypes().contains(PacketType.Play.Server.CHAT)); + assertTrue(listener.getSendingWhitelist().getTypes().contains(PacketType.Play.Server.SYSTEM_CHAT)); + } + + @Test + void readsFreshSnapshotAndWarnsOnceWithoutCancellingOrReplacingFailedPacket() { + List warnings = new ArrayList<>(); + AtomicReference> names = new AtomicReference<>(Set.of()); + var listener = new ProtocolLibIdentityMaskingListener(mock(Plugin.class), names::get, warnings::add); + PacketEvent event = mock(PacketEvent.class); + when(event.getPacketType()).thenReturn(PacketType.Play.Server.CHAT); + listener.onPacketSending(event); + assertTrue(warnings.isEmpty()); + names.set(Set.of("Secret")); + // No real NMS packet: the clone attempt fails and must leave event delivery untouched. + listener.onPacketSending(event); + listener.onPacketSending(event); + assertEquals(1, warnings.size()); + verify(event, never()).setPacket(any()); + verify(event, never()).setCancelled(anyBoolean()); + } +} diff --git a/src/test/java/games/dmg/spigotstealth/StealthAdminJoinListenerTest.java b/src/test/java/games/dmg/spigotstealth/StealthAdminJoinListenerTest.java new file mode 100644 index 0000000..9dac8a8 --- /dev/null +++ b/src/test/java/games/dmg/spigotstealth/StealthAdminJoinListenerTest.java @@ -0,0 +1,83 @@ +package games.dmg.spigotstealth; + +import static org.mockito.Mockito.*; + +import java.util.List; +import java.util.Set; +import java.util.UUID; +import org.bukkit.entity.Player; +import org.bukkit.event.player.PlayerJoinEvent; +import org.junit.jupiter.api.Test; + +class StealthAdminJoinListenerTest { + @Test + void joiningAdminReceivesCountButExistingAdminsDoNotReceiveOrdinaryJoinNotice() { + Player joining = player(true); + Player existing = player(true); + StealthSessionService sessions = sessions(Set.of(UUID.randomUUID(), UUID.randomUUID())); + new StealthAdminJoinListener(sessions, () -> List.of(joining, existing)).onJoin(event(joining)); + verify(joining).sendMessage("[Stealth] 2 players are invisible. Use /stealthadmin list to see who."); + verify(existing, never()).sendMessage(anyString()); + } + + @Test + void concealedJoinNotifiesOnlyAdminsWithUpdatedCount() { + Player joining = player(false); + Player admin = player(true); + Player ordinary = player(false); + StealthSessionService sessions = sessions(Set.of(joining.getUniqueId())); + new StealthAdminJoinListener(sessions, () -> List.of(joining, admin, ordinary)).onJoin(event(joining)); + verify(admin).sendMessage("[Stealth] An invisible player joined. 1 player is now invisible. Use /stealthadmin list to see who."); + verify(joining, never()).sendMessage(anyString()); + verify(ordinary, never()).sendMessage(anyString()); + } + + @Test + void concealedAdminGetsExactlyOneNoticeEvenWhenIncludedInOnlinePlayers() { + Player joining = player(true); + Player admin = player(true); + StealthSessionService sessions = sessions(Set.of(joining.getUniqueId(), UUID.randomUUID())); + new StealthAdminJoinListener(sessions, () -> List.of(joining, admin)).onJoin(event(joining)); + verify(joining, times(1)).sendMessage("[Stealth] 2 players are invisible. Use /stealthadmin list to see who."); + verify(admin).sendMessage("[Stealth] An invisible player joined. 2 players are now invisible. Use /stealthadmin list to see who."); + } + + @Test + void zeroCountIsSilent() { + Player joining = player(true); + new StealthAdminJoinListener(sessions(Set.of()), () -> List.of(joining)).onJoin(event(joining)); + verify(joining, never()).sendMessage(anyString()); + } + + @Test + void ordinaryJoinIsSilentAndSinglePlayerCountUsesSingular() { + Player ordinary = player(false); + Player admin = player(true); + StealthAdminJoinListener listener = new StealthAdminJoinListener( + sessions(Set.of(UUID.randomUUID())), () -> List.of(ordinary, admin)); + listener.onJoin(event(ordinary)); + verify(ordinary, never()).sendMessage(anyString()); + verify(admin, never()).sendMessage(anyString()); + listener.onJoin(event(admin)); + verify(admin).sendMessage("[Stealth] 1 player is invisible. Use /stealthadmin list to see who."); + } + + private static StealthSessionService sessions(Set ids) { + StealthSessionService sessions = mock(StealthSessionService.class); + when(sessions.concealedPlayerIds()).thenReturn(ids); + return sessions; + } + + private static Player player(boolean admin) { + Player player = mock(Player.class); + when(player.getUniqueId()).thenReturn(UUID.randomUUID()); + when(player.hasPermission("spigotstealth.admin")).thenReturn(admin); + return player; + } + + private static PlayerJoinEvent event(Player player) { + PlayerJoinEvent event = mock(PlayerJoinEvent.class); + when(event.getPlayer()).thenReturn(player); + return event; + } +} diff --git a/src/test/java/games/dmg/spigotstealth/StealthChatMaskerTest.java b/src/test/java/games/dmg/spigotstealth/StealthChatMaskerTest.java new file mode 100644 index 0000000..1ed5c5c --- /dev/null +++ b/src/test/java/games/dmg/spigotstealth/StealthChatMaskerTest.java @@ -0,0 +1,70 @@ +package games.dmg.spigotstealth; + +import static org.junit.jupiter.api.Assertions.*; + +import com.google.gson.JsonParser; +import java.util.Set; +import org.junit.jupiter.api.Test; + +class StealthChatMaskerTest { + private final StealthChatMasker masker = new StealthChatMasker(Set.of("Secret")); + + @Test + void masksSenderAndRemovesIdentityMetadataWithoutChangingMessage() { + String input = """ + {"translate":"chat.type.text","with":[ + {"text":"Secret","hoverEvent":{"action":"show_entity","contents":{"name":"Secret"}}, + "clickEvent":{"action":"suggest_command","value":"/msg Secret "}}, + {"text":"Secret says hello","clickEvent":{"action":"copy_to_clipboard","value":"Secret"}}]} + """; + var result = JsonParser.parseString(masker.maskAnnouncement(input)).getAsJsonObject(); + var args = result.getAsJsonArray("with"); + assertEquals(JsonParser.parseString("{\"text\":\"Anonymous\",\"obfuscated\":true,\"hoverEvent\":null,\"clickEvent\":null,\"insertion\":\"\"}"), args.get(0)); + assertEquals(JsonParser.parseString(input).getAsJsonObject().getAsJsonArray("with").get(1), args.get(1)); + } + + @Test + void handlesPrivateMessagesAndAdvancementNamesOnly() { + for (String key : Set.of("commands.message.display.incoming", "commands.message.display.outgoing", + "chat.type.advancement.task", "chat.type.advancement.goal", "chat.type.advancement.challenge")) { + String input = "{\"translate\":\"" + key + "\",\"with\":[\"secret\",{\"text\":\"Secret\"}]}"; + var args = JsonParser.parseString(masker.maskAnnouncement(input)).getAsJsonObject().getAsJsonArray("with"); + assertEquals("Anonymous", args.get(0).getAsJsonObject().get("text").getAsString()); + assertEquals("Secret", args.get(1).getAsJsonObject().get("text").getAsString()); + } + } + + @Test + void doesNotTraverseMessageBodyEvenWhenItContainsChatTranslations() { + String input = """ + {"translate":"chat.type.text","with":["Ordinary", + {"translate":"chat.type.text","with":["Secret","body"]}]} + """; + assertEquals(input, masker.maskAnnouncement(input)); + } + + @Test + void handlesNestedAnnouncementsButNotPlainTextOrUnknownTranslations() { + assertTrue(masker.maskAnnouncement("{\"text\":\"\",\"extra\":[{\"translate\":\"chat.type.text\",\"with\":[\"Secret\",\"hello\"]}]}").contains("Anonymous")); + for (String input : Set.of("{\"text\":\"Secret: hello\"}", "{\"translate\":\"custom\",\"with\":[\"Secret\"]}", "not json")) { + assertEquals(input, masker.maskAnnouncement(input)); + } + } + + @Test + void removesInheritedActionsFromArrayParent() { + String json = """ + [{"text":"","hover_event":{"action":"show_text","value":"Secret"}}, + {"translate":"chat.type.text","with":["Secret","hello"]}] + """; + assertFalse(masker.maskAnnouncement(json).contains("Secret")); + } + + @Test + void recognizesSplitNamesAndMasksWholeNameComponent() { + String name = "{\"text\":\"Sec\",\"extra\":[{\"text\":\"ret\"}],\"insertion\":\"Secret\"}"; + assertTrue(masker.maskDisplayName(name).contains("Anonymous")); + assertFalse(masker.maskDisplayName(name).contains("Secret")); + assertEquals("\"NotSecret\"", masker.maskDisplayName("\"NotSecret\"")); + } +} diff --git a/src/test/java/games/dmg/spigotstealth/StealthSessionListenerTest.java b/src/test/java/games/dmg/spigotstealth/StealthSessionListenerTest.java index c119e36..a16aaa8 100644 --- a/src/test/java/games/dmg/spigotstealth/StealthSessionListenerTest.java +++ b/src/test/java/games/dmg/spigotstealth/StealthSessionListenerTest.java @@ -62,8 +62,9 @@ class StealthSessionListenerTest { QualifyingInvisibilityService progression = new QualifyingInvisibilityService( manager, Duration.ofHours(8), System::nanoTime, ignored -> { }); IdentityPresentation presentation = mock(IdentityPresentation.class); + StealthSessionService sessions = new StealthSessionService(manager, progression); StealthSessionListener listener = new StealthSessionListener( - new StealthSessionService(manager, progression), presentation, "Stealth active"); + sessions, presentation, "Stealth active"); Player player = player(playerId); PlayerJoinEvent event = mock(PlayerJoinEvent.class); when(event.getPlayer()).thenReturn(player); @@ -73,6 +74,10 @@ class StealthSessionListenerTest { verify(event).setJoinMessage(null); verify(presentation).conceal(player); verify(player).sendMessage("Stealth active"); + Player admin = player(UUID.randomUUID()); + when(admin.hasPermission("spigotstealth.admin")).thenReturn(true); + new StealthAdminJoinListener(sessions, () -> java.util.List.of(player, admin)).onJoin(event); + verify(admin).sendMessage("[Stealth] An invisible player joined. 1 player is now invisible. Use /stealthadmin list to see who."); } }