Command argument signing, TODO: Check where is it necessary to pass the signature to the context

This commit is contained in:
Noel Németh 2022-06-12 16:15:41 +02:00
parent e77050936f
commit 4a04d16bad
17 changed files with 181 additions and 45 deletions

View File

@ -1,6 +1,7 @@
package net.minestom.demo;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.event.ClickEvent;
import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.format.Style;
import net.kyori.adventure.text.format.TextColor;
@ -8,12 +9,15 @@ import net.kyori.adventure.text.format.TextDecoration;
import net.minestom.demo.commands.*;
import net.minestom.server.MinecraftServer;
import net.minestom.server.command.CommandManager;
import net.minestom.server.event.player.PlayerChatEvent;
import net.minestom.server.event.player.PlayerChatPreviewEvent;
import net.minestom.server.event.server.ServerListPingEvent;
import net.minestom.server.extras.lan.OpenToLAN;
import net.minestom.server.extras.lan.OpenToLANConfig;
import net.minestom.server.extras.optifine.OptifineSupport;
import net.minestom.server.instance.block.BlockManager;
import net.minestom.server.instance.block.rule.vanilla.RedstonePlacementRule;
import net.minestom.server.message.MessageSender;
import net.minestom.server.ping.ResponseData;
import net.minestom.server.utils.identity.NamedAndIdentified;
import net.minestom.server.utils.time.TimeUnit;
@ -52,6 +56,8 @@ public class Main {
commandManager.register(new AutoViewCommand());
commandManager.register(new SaveCommand());
commandManager.register(new GamemodeCommand());
commandManager.register(new CommandSignTest());
commandManager.register(new ChatPreviewCommand());
commandManager.setUnknownCommandCallback((sender, command) -> sender.sendMessage(Component.text("Unknown command", NamedTextColor.RED)));
@ -92,6 +98,22 @@ public class Main {
// on legacy versions, colors will be converted to the section format so it'll work there too
responseData.setDescription(Component.text("This is a Minestom Server", TextColor.color(0x66b3ff)));
//responseData.setPlayersHidden(true);
}).addListener(PlayerChatPreviewEvent.class, e -> {
if (e.getQuery().contains("!")) {
e.setResult(null);
} else {
e.setResult(Component.empty()
.append(Component.text("PREPEND>", TextColor.color(255,0,255)))
.append(Component.text(e.getQuery()))
.append(Component.text("<APPEND", TextColor.color(255,0,255))));
// e.setResult(Component.text(e.getQuery(), NamedTextColor.BLACK));
}
}).addListener(PlayerChatEvent.class, e -> {
final MessageSender s = e.getSender();
e.setSender(new MessageSender(
s.displayName().clickEvent(ClickEvent.suggestCommand("/msg "+e.getPlayer().getUsername())).color(NamedTextColor.DARK_GREEN),
Component.text("Team Name").color(NamedTextColor.GOLD)
));
});
PlayerInit.init();

View File

@ -0,0 +1,45 @@
package net.minestom.demo.commands;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import net.minestom.server.command.builder.Command;
import net.minestom.server.command.builder.arguments.ArgumentType;
import net.minestom.server.command.builder.arguments.minecraft.ArgumentMessage;
import net.minestom.server.crypto.MessageSignature;
import net.minestom.server.crypto.SignatureValidator;
import net.minestom.server.entity.Player;
import net.minestom.server.message.ChatPosition;
import net.minestom.server.message.Messenger;
public class CommandSignTest extends Command {
private static final ArgumentMessage message = ArgumentType.Message("message");
public CommandSignTest() {
super("sign");
addSyntax(((sender, context) -> {
if (sender instanceof Player player) {
final MessageSignature signature = context.getSignature().signatureOf(message, player.getUuid());
final SignatureValidator validator = SignatureValidator.from(player);
Messenger.sendSystemMessage(player,
Component.text("Signature details: preview: ")
.append(formatBoolean(context.getSignature().signedPreview()))
.append(Component.text(", argument: "))
.append(format(SignatureValidator.validate(validator, signature, Component.text(context.get(message)))))
.append(Component.text(", preview: "))
.append(format(SignatureValidator.validate(validator, signature, player.getLastPreviewedMessage()))),
ChatPosition.CHAT);
} else {
// TODO Handle this case
}
}), message);
}
private static Component format(boolean valid) {
return valid ? Component.text("valid", NamedTextColor.GREEN) : Component.text("invalid", NamedTextColor.RED);
}
private static Component formatBoolean(boolean value) {
return value ? Component.text("true", NamedTextColor.GREEN) : Component.text("false", NamedTextColor.RED);
}
}

View File

@ -25,7 +25,7 @@ import java.util.*;
/**
* Manager used to register {@link Command commands}.
* <p>
* It is also possible to simulate a command using {@link #execute(CommandSender, String)}.
* It is also possible to simulate a command using {@link #execute(CommandSender, String, ArgumentsSignature)}.
*/
public final class CommandManager {
@ -97,17 +97,17 @@ public final class CommandManager {
* @param command the raw command string (without the command prefix)
* @return the execution result
*/
public @NotNull CommandResult execute(@NotNull CommandSender sender, @NotNull String command) {
public @NotNull CommandResult execute(@NotNull CommandSender sender, @NotNull String command, @Nullable ArgumentsSignature signature) {
// Command event
if (sender instanceof Player player) {
PlayerCommandEvent playerCommandEvent = new PlayerCommandEvent(player, command);
PlayerCommandEvent playerCommandEvent = new PlayerCommandEvent(player, command, signature);
EventDispatcher.call(playerCommandEvent);
if (playerCommandEvent.isCancelled())
return CommandResult.of(CommandResult.Type.CANCELLED, command);
command = playerCommandEvent.getCommand();
}
// Process the command
final CommandResult result = dispatcher.execute(sender, command);
final CommandResult result = dispatcher.execute(sender, command, signature);
if (result.getType() == CommandResult.Type.UNKNOWN) {
if (unknownCommandCallback != null) {
this.unknownCommandCallback.apply(sender, command);
@ -120,10 +120,10 @@ public final class CommandManager {
* Executes the command using a {@link ServerSender}. This can be used
* to run a silent command (nothing is printed to console).
*
* @see #execute(CommandSender, String)
* @see #execute(CommandSender, String, ArgumentsSignature)
*/
public @NotNull CommandResult executeServerCommand(@NotNull String command) {
return execute(serverSender, command);
return execute(serverSender, command, null);
}
public @NotNull CommandDispatcher getDispatcher() {
@ -210,7 +210,7 @@ public final class CommandManager {
}
final ArgumentQueryResult queryResult = CommandParser.findEligibleArgument(commandQueryResult.command(),
commandQueryResult.args(), input, false, true, syntax -> true, argument -> true);
commandQueryResult.args(), input, false, true, syntax -> true, argument -> true, null);
if (queryResult == null) {
// Invalid argument, return command node (default to root)
final int commandNode = commandIdentityMap.getOrDefault(commandQueryResult.command(), 0);

View File

@ -1,5 +1,6 @@
package net.minestom.server.command.builder;
import net.minestom.server.command.ArgumentsSignature;
import net.minestom.server.command.builder.arguments.Argument;
import net.minestom.server.utils.StringUtils;
import org.jetbrains.annotations.NotNull;
@ -25,10 +26,16 @@ public class CommandContext {
protected Map<String, Object> args = new HashMap<>();
protected Map<String, String> rawArgs = new HashMap<>();
private CommandData returnData;
private final @Nullable ArgumentsSignature signature;
public CommandContext(@NotNull String input) {
public CommandContext(@NotNull String input, @Nullable ArgumentsSignature signature) {
this.input = input;
this.commandName = input.split(StringUtils.SPACE)[0];
this.signature = signature;
}
public @Nullable ArgumentsSignature getSignature() {
return signature;
}
public @NotNull String getInput() {

View File

@ -3,6 +3,7 @@ package net.minestom.server.command.builder;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import it.unimi.dsi.fastutil.ints.Int2ObjectRBTreeMap;
import net.minestom.server.command.ArgumentsSignature;
import net.minestom.server.command.CommandSender;
import net.minestom.server.command.builder.arguments.Argument;
import net.minestom.server.command.builder.exception.ArgumentSyntaxException;
@ -87,11 +88,11 @@ public class CommandDispatcher {
* @param commandString the command with the argument(s)
* @return the command result
*/
public @NotNull CommandResult execute(@NotNull CommandSender source, @NotNull String commandString) {
CommandResult commandResult = parse(commandString);
public @NotNull CommandResult execute(@NotNull CommandSender source, @NotNull String commandString, @Nullable ArgumentsSignature signature) {
CommandResult commandResult = parse(commandString, signature);
ParsedCommand parsedCommand = commandResult.parsedCommand;
if (parsedCommand != null) {
commandResult.commandData = parsedCommand.execute(source);
commandResult.commandData = parsedCommand.execute(source, signature);
}
return commandResult;
}
@ -102,7 +103,7 @@ public class CommandDispatcher {
* @param commandString the command (containing the command name and the args if any)
* @return the parsing result
*/
public @NotNull CommandResult parse(@NotNull String commandString) {
public @NotNull CommandResult parse(@NotNull String commandString, @Nullable ArgumentsSignature signature) {
commandString = commandString.trim();
// Verify if the result is cached
{
@ -124,7 +125,7 @@ public class CommandDispatcher {
CommandResult result = new CommandResult();
result.input = commandString;
// Find the used syntax and fill CommandResult#type and CommandResult#parsedCommand
findParsedCommand( commandQueryResult, commandName, commandString, result);
findParsedCommand( commandQueryResult, commandName, commandString, result, signature);
// Cache result
this.cache.put(commandString, result);
@ -135,7 +136,8 @@ public class CommandDispatcher {
private @NotNull ParsedCommand findParsedCommand(@NotNull CommandQueryResult commandQueryResult,
@NotNull String commandName,
@NotNull String commandString,
@NotNull CommandResult result) {
@NotNull CommandResult result,
@Nullable ArgumentsSignature signature) {
final Command command = commandQueryResult.command();
String[] args = commandQueryResult.args();
final boolean hasArgument = args.length > 0;
@ -159,7 +161,7 @@ public class CommandDispatcher {
final CommandSyntax syntax = optionalSyntax.get();
parsedCommand.syntax = syntax;
parsedCommand.executor = syntax.getExecutor();
parsedCommand.context = new CommandContext(input);
parsedCommand.context = new CommandContext(input, signature);
result.type = CommandResult.Type.SUCCESS;
result.parsedCommand = parsedCommand;
@ -169,7 +171,7 @@ public class CommandDispatcher {
final CommandExecutor defaultExecutor = command.getDefaultExecutor();
if (defaultExecutor != null) {
parsedCommand.executor = defaultExecutor;
parsedCommand.context = new CommandContext(input);
parsedCommand.context = new CommandContext(input, signature);
result.type = CommandResult.Type.SUCCESS;
result.parsedCommand = parsedCommand;
@ -194,7 +196,7 @@ public class CommandDispatcher {
// Check if there is at least one correct syntax
if (!validSyntaxes.isEmpty()) {
CommandContext context = new CommandContext(input);
CommandContext context = new CommandContext(input, signature);
// Search the syntax with all perfect args
final ValidSyntaxHolder finalValidSyntax = CommandParser.findMostCorrectSyntax(validSyntaxes, context);
if (finalValidSyntax != null) {
@ -233,7 +235,7 @@ public class CommandDispatcher {
// No syntax found
result.type = CommandResult.Type.INVALID_SYNTAX;
result.parsedCommand = ParsedCommand.withDefaultExecutor(command, input);
result.parsedCommand = ParsedCommand.withDefaultExecutor(command, input, signature);
return result.parsedCommand;
}
}

View File

@ -1,6 +1,7 @@
package net.minestom.server.command.builder;
import net.minestom.server.MinecraftServer;
import net.minestom.server.command.ArgumentsSignature;
import net.minestom.server.command.CommandSender;
import net.minestom.server.command.builder.condition.CommandCondition;
import net.minestom.server.command.builder.exception.ArgumentSyntaxException;
@ -39,9 +40,9 @@ public class ParsedCommand {
* @param source the command source
* @return the command data, null if none
*/
public @Nullable CommandData execute(@NotNull CommandSender source) {
public @Nullable CommandData execute(@NotNull CommandSender source, @Nullable ArgumentsSignature signature) {
// Global listener
command.globalListener(source, Objects.requireNonNullElseGet(context, () -> new CommandContext(commandString)), commandString);
command.globalListener(source, Objects.requireNonNullElseGet(context, () -> new CommandContext(commandString, signature)), commandString);
// Command condition check
{
// Parents
@ -98,12 +99,12 @@ public class ParsedCommand {
return context.getReturnData();
}
public static @NotNull ParsedCommand withDefaultExecutor(@NotNull Command command, @NotNull String input) {
public static @NotNull ParsedCommand withDefaultExecutor(@NotNull Command command, @NotNull String input, @Nullable ArgumentsSignature signature) {
ParsedCommand parsedCommand = new ParsedCommand();
parsedCommand.command = command;
parsedCommand.commandString = input;
parsedCommand.executor = command.getDefaultExecutor();
parsedCommand.context = new CommandContext(input);
parsedCommand.context = new CommandContext(input, signature);
return parsedCommand;
}
}

View File

@ -28,7 +28,7 @@ public class ArgumentCommand extends Argument<CommandResult> {
shortcut + StringUtils.SPACE + input
: input;
CommandDispatcher dispatcher = MinecraftServer.getCommandManager().getDispatcher();
CommandResult result = dispatcher.parse(commandString);
CommandResult result = dispatcher.parse(commandString, null);
if (onlyCorrect && result.getType() != CommandResult.Type.SUCCESS)
throw new ArgumentSyntaxException("Invalid command", input, INVALID_COMMAND_ERROR);

View File

@ -28,7 +28,7 @@ public class ArgumentGroup extends Argument<CommandContext> {
List<ValidSyntaxHolder> validSyntaxes = new ArrayList<>();
CommandParser.parse(null, group, input.split(StringUtils.SPACE), input, validSyntaxes, null);
CommandContext context = new CommandContext(input);
CommandContext context = new CommandContext(input, null);
CommandParser.findMostCorrectSyntax(validSyntaxes, context);
if (validSyntaxes.isEmpty()) {
throw new ArgumentSyntaxException("Invalid arguments", input, INVALID_ARGUMENTS_ERROR);

View File

@ -1,7 +1,10 @@
package net.minestom.server.command.builder.arguments;
import net.minestom.server.command.builder.arguments.minecraft.*;
import net.minestom.server.command.builder.arguments.minecraft.registry.*;
import net.minestom.server.command.builder.arguments.minecraft.registry.ArgumentEnchantment;
import net.minestom.server.command.builder.arguments.minecraft.registry.ArgumentEntityType;
import net.minestom.server.command.builder.arguments.minecraft.registry.ArgumentParticle;
import net.minestom.server.command.builder.arguments.minecraft.registry.ArgumentPotionEffect;
import net.minestom.server.command.builder.arguments.number.ArgumentDouble;
import net.minestom.server.command.builder.arguments.number.ArgumentFloat;
import net.minestom.server.command.builder.arguments.number.ArgumentInteger;
@ -268,4 +271,11 @@ public class ArgumentType {
public static ArgumentEntity Entities(@NotNull String id) {
return new ArgumentEntity(id);
}
/**
* @see ArgumentMessage
*/
public static ArgumentMessage Message(@NotNull String id) {
return new ArgumentMessage(id);
}
}

View File

@ -0,0 +1,40 @@
package net.minestom.server.command.builder.arguments.minecraft;
import net.kyori.adventure.text.Component;
import net.minestom.server.command.builder.NodeMaker;
import net.minestom.server.command.builder.arguments.Argument;
import net.minestom.server.command.builder.exception.ArgumentSyntaxException;
import net.minestom.server.crypto.MessageSignature;
import net.minestom.server.crypto.SignatureValidator;
import net.minestom.server.entity.Player;
import net.minestom.server.network.packet.server.play.DeclareCommandsPacket;
import org.jetbrains.annotations.NotNull;
/**
* This argument type enables chat preview of the entire command while editing
* the node with this type. Although the protocol allows multiple signed arguments this is the only
* type that supports it and the clientside parser takes the remaining string for this type, so
* it is impossible to have any more arguments after this one.<p>
* The signature verification happens by first acquiring a {@link net.minestom.server.crypto.SignatureValidator
* SignatureValidator} for the sender by using {@link net.minestom.server.crypto.SignatureValidator#from(Player)},
* after that the verification happens with {@link net.minestom.server.crypto.SignatureValidator#validate(SignatureValidator, MessageSignature, Component)}
* where the component is either the string value of this argument wrapped in {@link Component#text(String)} or
* {@link Player#getLastPreviewedMessage()} depending on {@link net.minestom.server.command.ArgumentsSignature#signedPreview()}
*/
public final class ArgumentMessage extends Argument<String> implements SignableArgument {
public ArgumentMessage(String id) {
super(id);
}
@Override
public @NotNull String parse(@NotNull String input) throws ArgumentSyntaxException {
return input;
}
@Override
public void processNodes(@NotNull NodeMaker nodeMaker, boolean executable) {
DeclareCommandsPacket.Node argumentNode = simpleArgumentNode(this, executable, false, false);
argumentNode.parser = "minecraft:message";
nodeMaker.addNodes(new DeclareCommandsPacket.Node[]{argumentNode});
}
}

View File

@ -0,0 +1,7 @@
package net.minestom.server.command.builder.arguments.minecraft;
/**
* Sealed because argument signing is entirely dependent on the argument type, and it's decided by the client
*/
public sealed interface SignableArgument permits ArgumentMessage {
}

View File

@ -1,6 +1,7 @@
package net.minestom.server.command.builder.parser;
import it.unimi.dsi.fastutil.ints.Int2ObjectRBTreeMap;
import net.minestom.server.command.ArgumentsSignature;
import net.minestom.server.command.builder.Command;
import net.minestom.server.command.builder.CommandContext;
import net.minestom.server.command.builder.CommandDispatcher;
@ -127,7 +128,7 @@ public final class CommandParser {
maxArguments = argsSize;
// Fill arguments map
finalContext = new CommandContext(validSyntaxHolder.commandString());
finalContext = new CommandContext(validSyntaxHolder.commandString(), context.getSignature());
for (var entry : argsValues.entrySet()) {
final Argument<?> argument = entry.getKey();
final ArgumentParser.ArgumentResult argumentResult = entry.getValue();
@ -148,7 +149,8 @@ public final class CommandParser {
public static ArgumentQueryResult findEligibleArgument(@NotNull Command command, String[] args, String commandString,
boolean trailingSpace, boolean forceCorrect,
Predicate<CommandSyntax> syntaxPredicate,
Predicate<Argument<?>> argumentPredicate) {
Predicate<Argument<?>> argumentPredicate,
@Nullable ArgumentsSignature signature) {
final Collection<CommandSyntax> syntaxes = command.getSyntaxes();
Int2ObjectRBTreeMap<ArgumentQueryResult> suggestions = new Int2ObjectRBTreeMap<>(Collections.reverseOrder());
@ -158,7 +160,7 @@ public final class CommandParser {
continue;
}
final CommandContext context = new CommandContext(commandString);
final CommandContext context = new CommandContext(commandString, signature);
final Argument<?>[] commandArguments = syntax.getArguments();
int inputIndex = 0;

View File

@ -3,7 +3,6 @@ package net.minestom.server.crypto;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.serializer.gson.GsonComponentSerializer;
import net.minestom.server.entity.Player;
import net.minestom.server.network.packet.client.play.ClientCommandChatPacket;
import net.minestom.server.utils.crypto.KeyUtils;
import org.jetbrains.annotations.Nullable;
@ -55,11 +54,7 @@ public interface SignatureValidator {
}
/**
* Can be used to verify signatures of {@link net.minestom.server.network.packet.client.play.ClientChatMessagePacket}
* and {@link ClientCommandChatPacket#signatures()}. For command args the signature is generated for the given
* value wrapped in {@link Component#text(String)} or for the preview if it's present regardless of the arg node
* name. Vanilla implementation of argument signing can be found at (Mojang mappings):
* <i>net.minecraft.client.player.LocalPlayer#signCommandArguments</i><br>
* Can be used to verify signatures of chat messages and command arguments.
*
* @param validator validator acquired from {@link SignatureValidator#from(Player)}
* @param signature signature data

View File

@ -1,9 +1,11 @@
package net.minestom.server.event.player;
import net.minestom.server.command.ArgumentsSignature;
import net.minestom.server.entity.Player;
import net.minestom.server.event.trait.CancellableEvent;
import net.minestom.server.event.trait.PlayerInstanceEvent;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
/**
* Called every time a player send a message starting by '/'.
@ -12,10 +14,11 @@ public class PlayerCommandEvent implements PlayerInstanceEvent, CancellableEvent
private final Player player;
private String command;
private @Nullable ArgumentsSignature signature;
private boolean cancelled;
public PlayerCommandEvent(@NotNull Player player, @NotNull String command) {
public PlayerCommandEvent(@NotNull Player player, @NotNull String command, @Nullable ArgumentsSignature signature) {
this.player = player;
this.command = command;
}
@ -53,4 +56,12 @@ public class PlayerCommandEvent implements PlayerInstanceEvent, CancellableEvent
public @NotNull Player getPlayer() {
return player;
}
public @Nullable ArgumentsSignature getSignature() {
return signature;
}
public void setSignature(@Nullable ArgumentsSignature signature) {
this.signature = signature;
}
}

View File

@ -29,7 +29,7 @@ public class ChatMessageListener {
public static void commandChatListener(ClientCommandChatPacket packet, Player player) {
final String command = packet.message();
if (Messenger.canReceiveCommand(player)) {
COMMAND_MANAGER.execute(player, command);
COMMAND_MANAGER.execute(player, command, packet.argumentsSignature().signatures().size() > 0 ? packet.argumentsSignature() : null);
} else {
Messenger.sendRejectionMessage(player);
}

View File

@ -50,7 +50,7 @@ public class TabCompleteListener {
final ArgumentQueryResult queryResult = CommandParser.findEligibleArgument(commandQueryResult.command(),
commandQueryResult.args(), commandString, text.endsWith(StringUtils.SPACE), false,
CommandSyntax::hasSuggestion, Argument::hasSuggestion);
CommandSyntax::hasSuggestion, Argument::hasSuggestion, null);
if (queryResult == null) {
// Suggestible argument not found
return null;

View File

@ -6,13 +6,7 @@ import net.minestom.server.command.builder.suggestion.Suggestion;
import net.minestom.server.command.builder.suggestion.SuggestionEntry;
import net.minestom.server.listener.TabCompleteListener;
import org.jetbrains.annotations.ApiStatus;
import org.jline.reader.Candidate;
import org.jline.reader.Completer;
import org.jline.reader.EndOfFileException;
import org.jline.reader.LineReader;
import org.jline.reader.LineReaderBuilder;
import org.jline.reader.ParsedLine;
import org.jline.reader.UserInterruptException;
import org.jline.reader.*;
import org.jline.terminal.Terminal;
import org.jline.terminal.TerminalBuilder;
@ -44,7 +38,7 @@ public class MinestomTerminal {
try {
command = reader.readLine(PROMPT);
var commandManager = MinecraftServer.getCommandManager();
commandManager.execute(commandManager.getConsoleSender(), command);
commandManager.execute(commandManager.getConsoleSender(), command, null);
} catch (UserInterruptException e) {
// Handle Ctrl + C
System.exit(0);