From 93f582bba06d24d2a29ad3f5a5b82e8232ae99bf Mon Sep 17 00:00:00 2001 From: ChomeNS <95471003+ChomeNS@users.noreply.github.com> Date: Tue, 8 Apr 2025 16:42:17 +0700 Subject: [PATCH] feat: smp-like encryption for chomens mod instead of public-private keys --- build-number.txt | 2 +- .../chayapak1/chomens_bot/Configuration.java | 8 + .../java/me/chayapak1/chomens_bot/Main.java | 6 +- .../chomens_bot/chomeNSMod/Encryptor.java | 73 +++++ .../data/chomeNSMod/PayloadState.java | 6 + .../plugins/ChomeNSModIntegrationPlugin.java | 264 ++++++------------ src/main/resources/default-config.yml | 9 + 7 files changed, 189 insertions(+), 179 deletions(-) create mode 100644 src/main/java/me/chayapak1/chomens_bot/chomeNSMod/Encryptor.java create mode 100644 src/main/java/me/chayapak1/chomens_bot/data/chomeNSMod/PayloadState.java diff --git a/build-number.txt b/build-number.txt index f47475ba..987514dd 100644 --- a/build-number.txt +++ b/build-number.txt @@ -1 +1 @@ -2445 \ No newline at end of file +2472 \ No newline at end of file diff --git a/src/main/java/me/chayapak1/chomens_bot/Configuration.java b/src/main/java/me/chayapak1/chomens_bot/Configuration.java index 6227d67a..a3b10aba 100644 --- a/src/main/java/me/chayapak1/chomens_bot/Configuration.java +++ b/src/main/java/me/chayapak1/chomens_bot/Configuration.java @@ -16,6 +16,8 @@ public class Configuration { public Database database = new Database(); + public ChomeNSMod chomeNSMod = new ChomeNSMod(); + public String weatherApiKey; public String namespace = "chomens_bot"; @@ -69,6 +71,12 @@ public class Configuration { public String password = "123456"; } + public static class ChomeNSMod { + public boolean enabled = false; + public String password = "123456"; + public List players = new ArrayList<>(); + } + public static class Keys { public String trustedKey; public String adminKey; diff --git a/src/main/java/me/chayapak1/chomens_bot/Main.java b/src/main/java/me/chayapak1/chomens_bot/Main.java index 6f9e8f5b..32f28f17 100644 --- a/src/main/java/me/chayapak1/chomens_bot/Main.java +++ b/src/main/java/me/chayapak1/chomens_bot/Main.java @@ -1,7 +1,10 @@ package me.chayapak1.chomens_bot; import com.google.common.util.concurrent.ThreadFactoryBuilder; -import me.chayapak1.chomens_bot.plugins.*; +import me.chayapak1.chomens_bot.plugins.ConsolePlugin; +import me.chayapak1.chomens_bot.plugins.DatabasePlugin; +import me.chayapak1.chomens_bot.plugins.DiscordPlugin; +import me.chayapak1.chomens_bot.plugins.IRCPlugin; import me.chayapak1.chomens_bot.util.ArrayUtilities; import me.chayapak1.chomens_bot.util.HttpUtilities; import me.chayapak1.chomens_bot.util.LoggerUtilities; @@ -132,7 +135,6 @@ public class Main { // initialize plugins console = new ConsolePlugin(config); - ChomeNSModIntegrationPlugin.init(); if (config.database.enabled) database = new DatabasePlugin(config); if (config.discord.enabled) discord = new DiscordPlugin(config); if (config.irc.enabled) irc = new IRCPlugin(config); diff --git a/src/main/java/me/chayapak1/chomens_bot/chomeNSMod/Encryptor.java b/src/main/java/me/chayapak1/chomens_bot/chomeNSMod/Encryptor.java new file mode 100644 index 00000000..7b669e39 --- /dev/null +++ b/src/main/java/me/chayapak1/chomens_bot/chomeNSMod/Encryptor.java @@ -0,0 +1,73 @@ +package me.chayapak1.chomens_bot.chomeNSMod; + +import me.chayapak1.chomens_bot.util.Ascii85; + +import javax.crypto.Cipher; +import javax.crypto.SecretKey; +import javax.crypto.SecretKeyFactory; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.PBEKeySpec; +import javax.crypto.spec.SecretKeySpec; +import java.security.SecureRandom; +import java.security.spec.KeySpec; + +// inspired from smp encryption plugin +public class Encryptor { + private static final SecureRandom RANDOM = new SecureRandom(); + + private static final int SALT_LENGTH = 16; + private static final int IV_LENGTH = 16; + private static final int ITERATIONS = 65536; + private static final int KEY_LENGTH = 256; + + public static String encrypt (byte[] data, String password) throws Exception { + final byte[] salt = generateRandomBytes(SALT_LENGTH); + final SecretKey key = deriveKey(password, salt); + + final Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); + final byte[] iv = generateRandomBytes(IV_LENGTH); + final IvParameterSpec ivSpec = new IvParameterSpec(iv); + cipher.init(Cipher.ENCRYPT_MODE, key, ivSpec); + + final byte[] encrypted = cipher.doFinal(data); + + final byte[] combined = new byte[salt.length + iv.length + encrypted.length]; + System.arraycopy(salt, 0, combined, 0, salt.length); + System.arraycopy(iv, 0, combined, salt.length, iv.length); + System.arraycopy(encrypted, 0, combined, salt.length + iv.length, encrypted.length); + + return Ascii85.encode(combined); + } + + public static byte[] decrypt (String ascii85Data, String password) throws Exception { + final byte[] combined = Ascii85.decode(ascii85Data); + + final byte[] salt = new byte[SALT_LENGTH]; + final byte[] iv = new byte[IV_LENGTH]; + final byte[] encrypted = new byte[combined.length - SALT_LENGTH - IV_LENGTH]; + + System.arraycopy(combined, 0, salt, 0, SALT_LENGTH); + System.arraycopy(combined, SALT_LENGTH, iv, 0, IV_LENGTH); + System.arraycopy(combined, SALT_LENGTH + IV_LENGTH, encrypted, 0, encrypted.length); + + final SecretKey key = deriveKey(password, salt); + final Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); + final IvParameterSpec ivSpec = new IvParameterSpec(iv); + cipher.init(Cipher.DECRYPT_MODE, key, ivSpec); + + return cipher.doFinal(encrypted); + } + + private static SecretKey deriveKey (String password, byte[] salt) throws Exception { + final SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256"); + final KeySpec spec = new PBEKeySpec(password.toCharArray(), salt, ITERATIONS, KEY_LENGTH); + final SecretKey tmp = factory.generateSecret(spec); + return new SecretKeySpec(tmp.getEncoded(), "AES"); + } + + private static byte[] generateRandomBytes (int length) { + final byte[] bytes = new byte[length]; + RANDOM.nextBytes(bytes); + return bytes; + } +} diff --git a/src/main/java/me/chayapak1/chomens_bot/data/chomeNSMod/PayloadState.java b/src/main/java/me/chayapak1/chomens_bot/data/chomeNSMod/PayloadState.java new file mode 100644 index 00000000..59b52e60 --- /dev/null +++ b/src/main/java/me/chayapak1/chomens_bot/data/chomeNSMod/PayloadState.java @@ -0,0 +1,6 @@ +package me.chayapak1.chomens_bot.data.chomeNSMod; + +public enum PayloadState { + JOINING, + DONE +} diff --git a/src/main/java/me/chayapak1/chomens_bot/plugins/ChomeNSModIntegrationPlugin.java b/src/main/java/me/chayapak1/chomens_bot/plugins/ChomeNSModIntegrationPlugin.java index 3b3320ce..56959478 100644 --- a/src/main/java/me/chayapak1/chomens_bot/plugins/ChomeNSModIntegrationPlugin.java +++ b/src/main/java/me/chayapak1/chomens_bot/plugins/ChomeNSModIntegrationPlugin.java @@ -1,38 +1,34 @@ package me.chayapak1.chomens_bot.plugins; +import com.google.common.base.Splitter; +import com.google.common.collect.Iterables; import io.netty.buffer.ByteBuf; import io.netty.buffer.Unpooled; import me.chayapak1.chomens_bot.Bot; +import me.chayapak1.chomens_bot.chomeNSMod.Encryptor; import me.chayapak1.chomens_bot.chomeNSMod.Packet; import me.chayapak1.chomens_bot.chomeNSMod.PacketHandler; -import me.chayapak1.chomens_bot.chomeNSMod.Types; import me.chayapak1.chomens_bot.chomeNSMod.clientboundPackets.ClientboundHandshakePacket; import me.chayapak1.chomens_bot.chomeNSMod.serverboundPackets.ServerboundRunCommandPacket; import me.chayapak1.chomens_bot.chomeNSMod.serverboundPackets.ServerboundRunCoreCommandPacket; import me.chayapak1.chomens_bot.chomeNSMod.serverboundPackets.ServerboundSuccessfulHandshakePacket; +import me.chayapak1.chomens_bot.data.chomeNSMod.PayloadState; import me.chayapak1.chomens_bot.data.player.PlayerEntry; -import me.chayapak1.chomens_bot.util.Ascii85; -import me.chayapak1.chomens_bot.util.LoggerUtilities; +import me.chayapak1.chomens_bot.util.UUIDUtilities; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.TextComponent; -import org.apache.commons.lang3.tuple.Pair; +import net.kyori.adventure.text.TranslatableComponent; +import net.kyori.adventure.text.TranslationArgument; -import javax.crypto.Cipher; -import java.io.BufferedWriter; -import java.io.IOException; import java.lang.reflect.InvocationTargetException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.security.*; -import java.security.spec.InvalidKeySpecException; -import java.security.spec.PKCS8EncodedKeySpec; -import java.security.spec.X509EncodedKeySpec; import java.util.*; -import java.util.stream.Stream; // This is inspired from the ChomeNS Bot Proxy which is in the JavaScript version of ChomeNS Bot. public class ChomeNSModIntegrationPlugin implements ChatPlugin.Listener, PlayersPlugin.Listener, TickPlugin.Listener { private static final String ID = "chomens_mod"; + private static final int ENCODED_PAYLOAD_LENGTH = 31_000; // just 32767 trimmed "a bit" + + private static final Random RANDOM = new Random(); public static final List> SERVERBOUND_PACKETS = new ArrayList<>(); @@ -42,92 +38,6 @@ public class ChomeNSModIntegrationPlugin implements ChatPlugin.Listener, Players SERVERBOUND_PACKETS.add(ServerboundRunCommandPacket.class); } - private static PrivateKey PRIVATE_KEY; - - private static final Map CLIENT_PUBLIC_KEYS = new HashMap<>(); - private static final Path CLIENT_PUBLIC_KEYS_PATH = Path.of("client_public_keys"); - - private static final Path PRIVATE_KEY_PATH = Path.of("private.key"); - private static final Path PUBLIC_KEY_PATH = Path.of("public.key"); - - private static final String BEGIN_PRIVATE_KEY = "-----BEGIN CHOMENS BOT PRIVATE KEY-----"; - private static final String END_PRIVATE_KEY = "-----END CHOMENS BOT PRIVATE KEY-----"; - - private static final String BEGIN_PUBLIC_KEY = "-----BEGIN CHOMENS BOT PUBLIC KEY-----"; - private static final String END_PUBLIC_KEY = "-----END CHOMENS BOT PUBLIC KEY-----"; - - public static void init () { - try { - // let's only check for the private key here - if (!Files.exists(PRIVATE_KEY_PATH)) { - final KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA"); - keyGen.initialize(2048); - - final KeyPair pair = keyGen.generateKeyPair(); - - // write the keys - // (note: no newline split is intentional) - final String encodedPrivateKey = - BEGIN_PRIVATE_KEY + "\n" + - Base64.getEncoder().encodeToString(pair.getPrivate().getEncoded()) + - "\n" + END_PRIVATE_KEY; - final String encodedPublicKey = - BEGIN_PUBLIC_KEY + "\n" + - Base64.getEncoder().encodeToString(pair.getPublic().getEncoded()) + - "\n" + END_PUBLIC_KEY; - - final BufferedWriter privateKeyWriter = Files.newBufferedWriter(PRIVATE_KEY_PATH); - privateKeyWriter.write(encodedPrivateKey); - privateKeyWriter.close(); - - final BufferedWriter publicKeyWriter = Files.newBufferedWriter(PUBLIC_KEY_PATH); - publicKeyWriter.write(encodedPublicKey); - publicKeyWriter.close(); - } - - // is this a good way to remove the things? - final String privateKeyString = new String(Files.readAllBytes(PRIVATE_KEY_PATH)) - .replace(BEGIN_PRIVATE_KEY + "\n", "") - .replace("\n" + END_PRIVATE_KEY, "") - .replace("\n", "") - .trim(); - - final byte[] privateKeyBytes = Base64.getDecoder().decode(privateKeyString); - final KeyFactory keyFactory = KeyFactory.getInstance("RSA"); - - PRIVATE_KEY = keyFactory.generatePrivate(new PKCS8EncodedKeySpec(privateKeyBytes)); - - // lol this is so messy - if (Files.isDirectory(CLIENT_PUBLIC_KEYS_PATH)) { - try (final Stream files = Files.list(CLIENT_PUBLIC_KEYS_PATH)) { - for (Path path : files.toList()) { - try { - final String publicKeyString = new String(Files.readAllBytes(path)) - .replace(BEGIN_PUBLIC_KEY + "\n", "") - .replace("\n" + END_PUBLIC_KEY, "") - .replace("\n", "") - .trim(); - - final byte[] publicKeyBytes = Base64.getDecoder().decode(publicKeyString); - final KeyFactory clientKeyFactory = KeyFactory.getInstance("RSA"); - - String username = path.getFileName().toString(); - - if (username.contains(".")) username = username.substring(0, username.lastIndexOf(".")); - - CLIENT_PUBLIC_KEYS.put( - username, - clientKeyFactory.generatePublic(new X509EncodedKeySpec(publicKeyBytes)) - ); - } catch (Exception ignored) { } - } - } - } - } catch (IOException | NoSuchAlgorithmException | InvalidKeySpecException | IllegalArgumentException e) { - LoggerUtilities.error(e); - } - } - private final Bot bot; private final PacketHandler handler; @@ -136,7 +46,7 @@ public class ChomeNSModIntegrationPlugin implements ChatPlugin.Listener, Players public final List connectedPlayers = new ArrayList<>(); - private int chunkID = 0; + private final Map> receivedParts = new HashMap<>(); public ChomeNSModIntegrationPlugin (Bot bot) { this.bot = bot; @@ -152,26 +62,6 @@ public class ChomeNSModIntegrationPlugin implements ChatPlugin.Listener, Players tryHandshaking(); } - public byte[] decrypt (byte[] data) throws Exception { - final Cipher cipher = Cipher.getInstance("RSA"); - cipher.init(Cipher.DECRYPT_MODE, PRIVATE_KEY); - - return cipher.doFinal(data); - } - - public String encrypt (String player, byte[] data) throws Exception { - final PublicKey publicKey = CLIENT_PUBLIC_KEYS.get(player); - - if (publicKey == null) return null; - - final Cipher cipher = Cipher.getInstance("RSA"); - cipher.init(Cipher.ENCRYPT_MODE, publicKey); - - final byte[] encryptedBytes = cipher.doFinal(data); - - return Ascii85.encode(encryptedBytes); - } - public void send (PlayerEntry target, Packet packet) { if (!connectedPlayers.contains(target) && !(packet instanceof ClientboundHandshakePacket)) return; // LoL sus check @@ -184,60 +74,38 @@ public class ChomeNSModIntegrationPlugin implements ChatPlugin.Listener, Players final byte[] bytes = new byte[buf.readableBytes()]; buf.readBytes(bytes); - // split - final int length = bytes.length; - final int chunkSize = 245 - (6 * 3); + try { + final int messageId = RANDOM.nextInt(); - final List chunks = new ArrayList<>(); + final String encrypted = Encryptor.encrypt(bytes, bot.config.chomeNSMod.password); - for (int i = 0; i < length; i += chunkSize) { - final int end = Math.min(length, i + chunkSize); - final byte[] chunk = Arrays.copyOfRange(bytes, i, end); + final Iterable split = Splitter.fixedLength(ENCODED_PAYLOAD_LENGTH).split(encrypted); - chunks.add(chunk); - } + int i = 1; - final int currentChunkID = chunkID++; - - int fullBytesIndex = 0; - for (byte[] chunk : chunks) { - final ByteBuf finalBuf = Unpooled.buffer(); - - finalBuf.writeInt(currentChunkID); - finalBuf.writeInt(chunks.size()); - finalBuf.writeInt(fullBytesIndex); - finalBuf.writeInt(length); - - finalBuf.writeBytes(chunk); - - final byte[] finalBytes = new byte[finalBuf.readableBytes()]; - finalBuf.readBytes(finalBytes); - - try { - final String encrypted = encrypt(target.profile.getName(), finalBytes); + for (String part : split) { + final PayloadState state = i == Iterables.size(split) + ? PayloadState.DONE + : PayloadState.JOINING; final Component component = Component.translatable( "", Component.text(ID), - Component.text(encrypted) + Component.text(messageId), + Component.text(state.ordinal()), + Component.text(part) ); bot.chat.actionBar(component, target.profile.getId()); - } catch (Exception ignored) { } - fullBytesIndex += chunk.length; - } + i++; + } + } catch (Exception ignored) { } } - private Pair deserialize (byte[] data) { + private Packet deserialize (byte[] data) { final ByteBuf buf = Unpooled.wrappedBuffer(data); - final UUID uuid = Types.readUUID(buf); - - final PlayerEntry player = bot.players.getEntry(uuid); - - if (player == null) return null; - final int id = buf.readInt(); final Class packetClass = SERVERBOUND_PACKETS.get(id); @@ -245,7 +113,7 @@ public class ChomeNSModIntegrationPlugin implements ChatPlugin.Listener, Players if (packetClass == null) return null; try { - return Pair.of(player, packetClass.getDeclaredConstructor(ByteBuf.class).newInstance(buf)); + return packetClass.getDeclaredConstructor(ByteBuf.class).newInstance(buf); } catch (NoSuchMethodException | InvocationTargetException | InstantiationException | IllegalAccessException e) { return null; @@ -254,37 +122,80 @@ public class ChomeNSModIntegrationPlugin implements ChatPlugin.Listener, Players @Override public boolean systemMessageReceived (Component component, String string, String ansi) { - if (!(component instanceof TextComponent textComponent)) return true; - - final String id = textComponent.content(); - if ( - !id.equals(ID) || - component.children().size() != 1 || - !(component.children().getFirst() instanceof TextComponent dataComponent) + !(component instanceof TranslatableComponent translatableComponent) || + !translatableComponent.key().isEmpty() ) return true; - final String data = dataComponent.content(); + final List arguments = translatableComponent.arguments(); + + if ( + arguments.size() != 5 || + + !(arguments.get(0).asComponent() instanceof TextComponent idTextComponent) || + !(arguments.get(1).asComponent() instanceof TextComponent uuidTextComponent) || + !(arguments.get(2).asComponent() instanceof TextComponent messageIdTextComponent) || + !(arguments.get(3).asComponent() instanceof TextComponent payloadStateTextComponent) || + !(arguments.get(4).asComponent() instanceof TextComponent payloadTextComponent) || + + !idTextComponent.content().equals(ID) + ) return true; try { - final byte[] decrypted = decrypt(Ascii85.decode(data)); + final UUID uuid = UUIDUtilities.tryParse(uuidTextComponent.content()); - final Pair deserialized = deserialize(decrypted); + if (uuid == null) return true; - if (deserialized == null) return false; + final PlayerEntry player = bot.players.getEntry(uuid); - final PlayerEntry player = deserialized.getKey(); - final Packet packet = deserialized.getValue(); + if (player == null) return false; - handlePacket(player, packet); + final int messageId = Integer.parseInt(messageIdTextComponent.content()); + final int payloadStateIndex = Integer.parseInt(payloadStateTextComponent.content()); + + final PayloadState payloadState = PayloadState.values()[payloadStateIndex]; + + if (!receivedParts.containsKey(player)) receivedParts.put(player, new HashMap<>()); + + final Map playerReceivedParts = receivedParts.get(player); + + if (!playerReceivedParts.containsKey(messageId)) playerReceivedParts.put(messageId, new StringBuilder()); + + final StringBuilder builder = playerReceivedParts.get(messageId); + + final String payload = payloadTextComponent.content(); + + builder.append(payload); + + playerReceivedParts.put(messageId, builder); + + if (payloadState == PayloadState.DONE) { + playerReceivedParts.remove(messageId); + + final byte[] decryptedFullPayload = Encryptor.decrypt( + builder.toString(), + bot.config.chomeNSMod.password + ); + + final Packet packet = deserialize(decryptedFullPayload); + + if ( + packet == null || + ( + !(packet instanceof ServerboundSuccessfulHandshakePacket) && + !connectedPlayers.contains(player) + ) + ) return false; + + handlePacket(player, packet); + } } catch (Exception ignored) { } return false; } private void tryHandshaking () { - // is looping through the usernames from the client public keys list a good idea? - for (String username : CLIENT_PUBLIC_KEYS.keySet()) { + for (String username : bot.config.chomeNSMod.players) { final PlayerEntry target = bot.players.getEntry(username); if (target == null || connectedPlayers.contains(target)) continue; @@ -309,6 +220,7 @@ public class ChomeNSModIntegrationPlugin implements ChatPlugin.Listener, Players if (!connectedPlayers.contains(target)) return; connectedPlayers.remove(target); + receivedParts.remove(target); } @SuppressWarnings("unused") diff --git a/src/main/resources/default-config.yml b/src/main/resources/default-config.yml index 51c92137..eacb6bfd 100644 --- a/src/main/resources/default-config.yml +++ b/src/main/resources/default-config.yml @@ -29,6 +29,15 @@ database: username: 'chomens_bot' password: '123456' +chomeNSMod: + enabled: false + password: '123456' # secure password 2025 + players: + - 'player username that has ChomeNS Mod' + - 'Bob' + - 'Jake' + - 'Parker' + discord: enabled: false prefix: 'default!'