通过非对称加密实现“氨醛”的客户端动态材质加载

前言

在某些情况下,我们需要在游戏客户端中动态地加载材质贴图,而我们又不想将贴图文件不与加密的存储在客户端中,因此这种情况下我们可能会选择将材质加密后存储于客户端本地,而后通过网络发送密钥至客户端解密材质后进行加载,本篇文章尝试以MC为例探究和解决这个问题
注:以下操作简化了一些步骤,具体为

  • 材质的加密 -> 使用加密的压缩包

整体设计

  1. 服务端发送密钥
  2. 客户端尝试使用密钥解密
  3. 若成功,则加载材质

但是实际上,直接发送明文加密密钥是非常不安全的,因为明文的密钥可能会被以“中间人攻击”的形式抓到,因此我们将使用非对称加密技术来解决这个问题,实际的逻辑为

  1. 服务端发送公钥
  2. 客户端通过服务端公钥和自己的私钥产生共享密钥,并向服务端发送客户端公钥
  3. 服务端通过客户端公钥和自己的私钥产生共享密钥
  4. 服务端使用共享密钥发送材质的加密密钥
  5. 客户端使用共享密钥解密材质的加密密钥
  6. 客户端尝试使用密钥解密
  7. 若成功,则加载材质

密钥:

  • 非对称加密:ECDH,椭圆曲线使用secp256r1
  • 对称加密:AES256

通信:

了解设计后即可开始编写代码

编写代码

  • 客户端环境:版本1.12.2, RetroFuturaGradle 1.4.2, Forge2847, 使用Java
  • 服务端环境:版本1.12.2, Paper, Taoolib6.2,使用Kotlin 1.9.24

客户端

报文加解密部分

java 复制代码
public class CipherManager {
    @Getter
    private static PublicKey clientPublicKey;
    private static PrivateKey clientPrivateKey;
    @Setter
    private static PublicKey serverPublicKey;
    @Getter
    private static SecretKey aes256Key;
    @Getter
    private static boolean isCertificated = false;
    @Getter
    private static String password;

    public static void init() {
        new ZipTextureManager();
    }

    static {
        try {
            KeyPair keyPair = CipherUtil.getECDHKeyPair();
            clientPrivateKey = keyPair.getPrivate();
            clientPublicKey = keyPair.getPublic();
        } catch (NoSuchAlgorithmException | InvalidAlgorithmParameterException e) {
            e.printStackTrace();
        }
    }

    /**
     * 计算共享密钥
     */
    private static byte[] getSharedSecretKey() throws NoSuchAlgorithmException, InvalidKeyException {
        KeyAgreement keyAgreement = KeyAgreement.getInstance("ECDH");
        keyAgreement.init(clientPrivateKey);
        keyAgreement.doPhase(serverPublicKey, true);
        return keyAgreement.generateSecret();
    }

    /**
     * 服务端公钥交换成功
     */
    public static void onKeyExchanged(PublicKey publicKey) {
        //服务端公钥交换到客户端
        serverPublicKey = publicKey;
        //将客户端公钥交换给服务端
        sendPublicKeyExchange();
    }

    /**
     * 发送密钥交换请求
     */
    private static void sendPublicKeyExchange() {
        NetworkHandler.sendToServer(new PacketCipher.ECDHPublicKeyExchange(CipherManager.getClientPublicKey()));
    }

    /**
     * 解密Aes256密钥
     */
    @Nullable
    public static byte[] decryptAes256Key(@NotNull byte[] encryptedAesKey) {
        try {
            Cipher cipher = Cipher.getInstance("AES");
            cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(getSharedSecretKey(), "AES"));
            return cipher.doFinal(encryptedAesKey);
        } catch (Exception e) {
            e.printStackTrace();
        }
        return null;
    }

    /**
     * 收到AES256密钥
     */
    public static void onAESKeyReceived(byte[] key) {
        aes256Key = new SecretKeySpec(key, "AES");
    }

    /**
     * 收到压缩包密码
     */
    public static void onZipPasswordReceived(byte[] encryptedPassword) {
        try {
            Cipher cipher = Cipher.getInstance("AES");
            cipher.init(Cipher.DECRYPT_MODE, aes256Key);
            byte[] decryptedPasswordBytes = cipher.doFinal(encryptedPassword);
            CipherManager.password = new String(decryptedPasswordBytes, StandardCharsets.UTF_8);
            ZipTextureManager.openZipFile();
            isCertificated = true;
        } catch (GeneralSecurityException e) {
            e.printStackTrace();
        }
    }

    /**
     * 使用AES256密钥解密报文
     */
    public static byte[] decryptPacketBytes(byte[] encrypted) {
        try {
            Cipher cipher = Cipher.getInstance("AES");
            cipher.init(Cipher.DECRYPT_MODE, aes256Key);
            return cipher.doFinal(encrypted);
        } catch (Exception e) {
            e.printStackTrace();
        }

        return null;
    }

    /**
     * 使用AES256密钥加密报文
     */
    public static byte[] encryptPacketBytes(byte[] original) {
        try {
            Cipher cipher = Cipher.getInstance("AES");
            cipher.init(Cipher.ENCRYPT_MODE, aes256Key);
            return cipher.doFinal(original);
        } catch (Exception e) {
            e.printStackTrace();
        }

        return null;
    }
}

材质动态加载部分

我们通过实现IResourceManagerIResource来设计一个从压缩包中加载贴图的材质管理器
对于压缩包的处理,使用到了SevenZipJBinding

java 复制代码
@SuppressWarnings({"DataFlowIssue", "NullableProblems"})
public class ZipResource implements IResource {

    private final ResourceLocation resourceLocation;
    private InputStream inputStream;

    public ZipResource(ResourceLocation zipResourceLocation) {
        this.resourceLocation = zipResourceLocation;
    }

    @Override
    public ResourceLocation getResourceLocation() {
        return resourceLocation;
    }

    @Override
    public InputStream getInputStream() {
        inputStream = ZipTextureManager.getInputStream(resourceLocation.getPath());
        return inputStream;
    }

    @Override
    public boolean hasMetadata() {
        return false;
    }

    @Nullable
    @Override
    public <T extends IMetadataSection> T getMetadata(String sectionName) {
        return null;
    }

    @Override
    public String getResourcePackName() {
        return "";
    }

    @Override
    public void close() throws IOException {
        inputStream.close();
    }
}
java 复制代码
public class ZipResourceManager implements IResourceManager {
    @Override
    public Set<String> getResourceDomains() {
        return Collections.emptySet();
    }

    @Override
    public IResource getResource(ResourceLocation location) {
        return new ZipResource(location);
    }

    @Override
    public List<IResource> getAllResources(ResourceLocation location) {
        return Collections.emptyList();
    }
}

其中ZipTextureManager为我们自己编写的管理类

java 复制代码
@SuppressWarnings("CallToPrintStackTrace")
public class ZipTextureManager {
    @Getter
    private static TextureManager Instance;
    @Getter
    private static final ZipResourceManager zipResourceManager = new ZipResourceManager();
    @Getter
    private static IInArchive mapZipFile;
    private static RandomAccessFile zipFile = null;
    /** 压缩包中所有资源文件 路径 -> 文件 **/
    private final static Map<String, ISimpleInArchiveItem> zipTextures = new HashMap<>();
    /** 材质缓存 **/
    private static final ConcurrentHashMap<ResourceLocation, BufferedImage> textureCache = new ConcurrentHashMap<>();

    static {
        try {
            zipFile = new RandomAccessFile(new File(Minecraft.getMinecraft().gameDir, "resourcepacks/我们的压缩包.zip"), "r");
        } catch (FileNotFoundException e) {
            e.printStackTrace();
        }
    }

    public ZipTextureManager() {
        Instance = new TextureManager(zipResourceManager);
        MinecraftForge.EVENT_BUS.register(this);
    }

    /**
     * 打开Zip压缩包
     */
    public static void openZipFile() {
        if (zipFile == null) {
            return;
        }

        try {
            RandomAccessFileInStream randomAccessFileInStream = new RandomAccessFileInStream(zipFile);
            mapZipFile = SevenZip.openInArchive(null, randomAccessFileInStream, CipherManager.getPassword());

            ISimpleInArchive simpleInArchive = mapZipFile.getSimpleInterface();
            for (ISimpleInArchiveItem item : simpleInArchive.getArchiveItems()) {
                if (!item.isFolder()) {
                    zipTextures.put(item.getPath().replace("\\", "/"), item);
                }
            }
        } catch (SevenZipException e) {
            e.printStackTrace();
        }
    }

    /**
     * 获取材质输入流
     */
    @Nullable
    public static InputStream getInputStream(String path) {
        if (!zipTextures.containsKey(path)) {
            return null;
        }

        try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
            ISequentialOutStream iSequentialOutStream = data -> {
                try {
                    outputStream.write(data);
                    return data.length;
                } catch (IOException e) {
                    e.printStackTrace();
                }
                return 0;
            };

            zipTextures.get(path).extractSlow(iSequentialOutStream, CipherManager.getPassword());
            return new ByteArrayInputStream(outputStream.toByteArray());
        } catch (Exception e) {
            e.printStackTrace();
        }

        return null;
    }

    /**
     * 获取所需的材质
     */
    public static BufferedImage getTexture(ResourceLocation location) {
        for (Map.Entry<ResourceLocation, BufferedImage> entry : textureCache.entrySet()) {
            if (entry.getKey().equals(location)) {
                return entry.getValue();
            }
        }
        try {
            //从压缩包加载
            BufferedImage bufferedImage = TextureUtil.readBufferedImage(ZipTextureManager.getZipResourceManager().getResource(location).getInputStream());
            textureCache.put(location, bufferedImage);
            return bufferedImage;
        } catch (IOException e) {
            e.printStackTrace();
        }
        return null;
    }

    /**
     * 注销
     */
    @SubscribeEvent
    public void onPlayerLoggedOut(PlayerEvent.PlayerLoggedOutEvent event) {
        try {
            if (mapZipFile != null) mapZipFile.close();
            if (zipFile != null) zipFile.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

网络通讯部分

我们使用了Forge的SimpleNetworkWrapper来完成网络通讯

java 复制代码
public class PacketCipher {
    @SuppressWarnings("CallToPrintStackTrace")
    public static class ECDHPublicKeyExchange extends PacketBase<ECDHPublicKeyExchange> {
        private PublicKey clientPublicKey;
        private PublicKey serverPublicKey;

        public ECDHPublicKeyExchange() {}

        public ECDHPublicKeyExchange(PublicKey clientPublicKey) {
            this.clientPublicKey = clientPublicKey;
        }

        @Override
        public void handleClientSide(ECDHPublicKeyExchange message, EntityPlayer player) {
            CipherManager.onKeyExchanged(message.serverPublicKey);
        }

        @Override
        public void handleServerSide(ECDHPublicKeyExchange message, EntityPlayer player) {}

        @Override
        public void fromBytes(ByteBuf buf) {
            ByteBuf publicKeyBuf = buf.readBytes(buf.readableBytes());
            byte[] publicKeyBytes = new byte[publicKeyBuf.readableBytes()];
            publicKeyBuf.readBytes(publicKeyBytes);

            try {
                KeyFactory keyFactory = KeyFactory.getInstance("EC");
                X509EncodedKeySpec keySpec = new X509EncodedKeySpec(publicKeyBytes);
                serverPublicKey = keyFactory.generatePublic(keySpec);
            } catch (NoSuchAlgorithmException | InvalidKeySpecException e) {
                e.printStackTrace();
            }
        }

        @Override
        public void toBytes(ByteBuf buf) {
            X509EncodedKeySpec keySpec = new X509EncodedKeySpec(clientPublicKey.getEncoded());
            buf.writeBytes(keySpec.getEncoded());
        }
    }

    public static class AESKeyReceive extends PacketBase<AESKeyReceive> {
        private byte[] encryptedAes256Key;

        @Override
        public void handleClientSide(AESKeyReceive message, EntityPlayer player) {
            CipherManager.onAESKeyReceived(message.encryptedAes256Key);
        }

        @Override
        public void handleServerSide(AESKeyReceive message, EntityPlayer player) {}

        @Override
        public void fromBytes(ByteBuf buf) {
            ByteBuf encryptedAES256KeyBuf = buf.readBytes(buf.readableBytes());
            byte[] encryptedAes256KeyBytes = new byte[encryptedAES256KeyBuf.readableBytes()];
            encryptedAES256KeyBuf.readBytes(encryptedAes256KeyBytes);
            encryptedAes256Key = CipherManager.decryptAes256Key(encryptedAes256KeyBytes);
        }

        @Override
        public void toBytes(ByteBuf buf) {}
    }

    public static class ZipPasswordReceive extends PacketBase<ZipPasswordReceive> {
        private byte[] encryptedZipPassword;

        @Override
        public void handleClientSide(ZipPasswordReceive message, EntityPlayer player) {
            CipherManager.onZipPasswordReceived(message.encryptedZipPassword);
        }

        @Override
        public void handleServerSide(ZipPasswordReceive message, EntityPlayer player) {}

        @Override
        public void fromBytes(ByteBuf buf) {
            ByteBuf encryptedPassword = buf.readBytes(buf.readableBytes());
            byte[] encryptedPasswordBytes = new byte[encryptedPassword.readableBytes()];
            encryptedPassword.readBytes(encryptedPasswordBytes);
            encryptedZipPassword = encryptedPasswordBytes;
        }

        @Override
        public void toBytes(ByteBuf buf) {}
    }
}

其中

java 复制代码
public abstract class PacketBase<REQ extends IMessage> implements IMessage, IMessageHandler<REQ, REQ> {
    @Override
    public REQ onMessage(REQ message, MessageContext ctx) {
        if (ctx.side == Side.SERVER) {
            this.handleServerSide(message, ctx.getServerHandler().player);
        } else {
            this.handleClientSide(message, null);
        }
        return null;
    }

    public abstract void handleClientSide(REQ message, EntityPlayer player);

    public abstract void handleServerSide(REQ message, EntityPlayer player);

}

最后在网络管理器中注册信道以及数据包

java 复制代码
public class NetworkHandler extends NetworkManager {
    private static SimpleNetworkWrapper INSTANCE;

    public NetworkHandler(EnumPacketDirection packetDirection) {
        super(packetDirection);
    }

    public static void init() {
        INSTANCE = NetworkRegistry.INSTANCE.newSimpleChannel("我们的信道_channel");
        //数据包
        INSTANCE.registerMessage(PacketCipher.ECDHPublicKeyExchange.class, PacketCipher.ECDHPublicKeyExchange.class, 0, Side.CLIENT);
        INSTANCE.registerMessage(PacketCipher.AESKeyReceive.class, PacketCipher.AESKeyReceive.class, 1, Side.CLIENT);
        INSTANCE.registerMessage(PacketCipher.ZipPasswordReceive.class, PacketCipher.ZipPasswordReceive.class, 2, Side.CLIENT);
    }

    public static void sendToServer(IMessage message) {
        INSTANCE.sendToServer(message);
    }
}

服务端

加解密部分

kotlin 复制代码
object CipherManager {
    val serverPublicKey: PublicKey
    private val serverPrivateKey: PrivateKey
    /** 玩家 -> 客户端公钥 **/
    private val clientPublicKeys: ConcurrentHashMap<Player, PublicKey> = ConcurrentHashMap()
    /** 玩家 -> AES256密钥 **/
    private val clientAES256Keys: ConcurrentHashMap<Player, SecretKey> = ConcurrentHashMap()
    /** 已认证玩家 **/
    private val certificatedPlayers: CopyOnWriteArrayList<Player> = CopyOnWriteArrayList()

    init {
        val keyPair: KeyPair = CipherUtil.getECDHKeyPair()
        serverPublicKey = keyPair.public
        serverPrivateKey = keyPair.private
    }

    /**
     * 入服验证
     */
    @SubscribeEvent
    private fun onPlayerJoin(event: PlayerJoinEvent) {
        submitAsync(delay = 20L) {
            //发送密钥交换请求
            event.player.sendPublicKeyExchange()
        }
    }

    /**
     * 注销玩家公钥
     */
    @SubscribeEvent
    private fun onPlayerQuit(event: PlayerQuitEvent) {
        //清除缓存
        clientPublicKeys.remove(event.player)
        certificatedPlayers.remove(event.player)
    }

    /**
     * 客户端公钥交换成功
     */
    internal fun Player.onKeyExchanged(publicKey: PublicKey) {
        if (Config.isDebug) {
            info("&bECDH密钥交换成功!".colored())
        }
        //更新到缓存
        clientPublicKeys[this] = publicKey
        //使用共享密钥发送对称AES密钥
        this.sendAES256Key()
    }

    /**
     * 计算共享密钥
     */
    @Throws(NoSuchAlgorithmException::class, InvalidKeyException::class)
    private fun Player.getSharedSecretKey(): ByteArray {
        val keyAgreement = KeyAgreement.getInstance("ECDH")
        keyAgreement.init(serverPrivateKey)
        keyAgreement.doPhase(clientPublicKeys[this], true)
        return keyAgreement.generateSecret()
    }

    /**
     * 发送AES密钥
     */
    private fun Player.sendAES256Key() {
        val sharedSecret = this.getSharedSecretKey()
        //AES256
        val aes256Key = CipherUtil.getAES256Key()
        clientAES256Keys[this] = aes256Key
        //使用共享密钥加密AES256密钥
        val cipher = Cipher.getInstance("AES")
        cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(sharedSecret, "AES"))
        val encryptedAES256Key = cipher.doFinal(aes256Key.encoded)
        //发送数据包
        this.sendEncryptedAES256Key(encryptedAES256Key)
        //延时1Tick发送压缩包密码
        submitAsync(delay = 20L) {
            this@sendAES256Key.sendPassword()
        }
    }

    /**
     * 发送压缩包密码
     */
    private fun Player.sendPassword() {
        val passwordString = Config.conf.getString("Settings.password")!!
        val cipher = Cipher.getInstance("AES")
        cipher.init(Cipher.ENCRYPT_MODE, clientAES256Keys[this])
        val encryptedPassword = cipher.doFinal(passwordString.toByteArray())
        //发送数据包
        this.sendZipPassword(encryptedPassword)
        //延时1Tick通过验证
        submitAsync(delay = 1L) {
            certificatedPlayers.add(this@sendPassword)
            CipherEvent.PlayerCertificatedEvent(this@sendPassword).call()
        }
    }

    /**
     * 加密报文
     */
    fun ByteArray.getEncryptedFor(player: Player): ByteArray {
        val aes256Key = clientAES256Keys[player]!!
        val cipher = Cipher.getInstance("AES")
        cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(aes256Key.encoded, "AES"))
        return cipher.doFinal(this)
    }

    /**
     * 解密报文
     */
    fun ByteArray.getDecryptedFor(player: Player): ByteArray {
        val aes256Key = clientAES256Keys[player]!!
        val cipher = Cipher.getInstance("AES")
        cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(aes256Key.encoded, "AES"))
        return cipher.doFinal(this)
    }

    /**
     * 玩家是否已认证
     */
    fun Player.isCertificated(): Boolean {
        return certificatedPlayers.contains(this)
    }
}

其中工具类

kotlin 复制代码
object CipherUtil {
    /**
     * 获取服务端ECDH密钥对
     */
    @Throws(
        NoSuchAlgorithmException::class,
        InvalidAlgorithmParameterException::class
    )
    fun getECDHKeyPair(): KeyPair {
        val keyPairGenerator = KeyPairGenerator.getInstance("EC")
        keyPairGenerator.initialize(ECGenParameterSpec("secp256r1"))
        return keyPairGenerator.generateKeyPair()
    }

    /**
     * 获取AES256密钥
     */
    fun getAES256Key(): SecretKey {
        val keyGen = KeyGenerator.getInstance("AES")
        keyGen.init(256)
        return keyGen.generateKey()
    }
}

数据包

kotlin 复制代码
class PacketCipher {
    class ECDHPublicKeyExchange: PacketBase {
        private var serverPublicKey: PublicKey? = null

        constructor(serverPublicKey: PublicKey) {
            this.serverPublicKey = serverPublicKey
        }

        constructor()

        override fun sendTo(player: Player) {
            val keySpec = X509EncodedKeySpec(serverPublicKey!!.encoded)
            Unpooled.buffer().apply {
                this.writeByte(PID)
                this.writeBytes(keySpec.encoded)
            }.sendToPlayer(player)
        }

        override fun handleFor(byteBuf: ByteBuf, player: Player) {
            val publicKeyBuf: ByteBuf = byteBuf.readBytes(byteBuf.readableBytes())
            val publicKeyBytes = ByteArray(publicKeyBuf.readableBytes())
            publicKeyBuf.readBytes(publicKeyBytes)

            try {
                val keyFactory = KeyFactory.getInstance("EC")
                val keySpec = X509EncodedKeySpec(publicKeyBytes)
                val clientPublicKey = keyFactory.generatePublic(keySpec)

                player.onKeyExchanged(clientPublicKey)
            } catch (e: NoSuchAlgorithmException) {
                e.printStackTrace()
            } catch (e: InvalidKeySpecException) {
                e.printStackTrace()
            }
        }

        companion object {
            const val PID: Int = 0
        }
    }

    class AES256KeySend(private val encryptedAes256Key: ByteArray): PacketBase() {
        override fun sendTo(player: Player) {
            if (Config.isDebug) {
                info("&a向客户端发送加密(ECDH)报文".colored())
                info("&b报头: &ePacketCipher.AES256KeySend -> 下发AES256密钥".colored())
                info("&b报文: &f${encryptedAes256Key}".colored())
            }

            Unpooled.buffer().apply {
                this.writeByte(PID)
                this.writeBytes(encryptedAes256Key)
            }.sendToPlayer(player)
        }

        companion object {
            const val PID: Int = 1
        }
    }

    class ZipPasswordSend(private val encryptedPassword: ByteArray): PacketBase() {
        override fun sendTo(player: Player) {
            Unpooled.buffer().apply {
                this.writeByte(PID)
                this.writeBytes(encryptedPassword)
            }.sendToPlayer(player)
        }

        companion object {
            const val PID: Int = 2
        }
    }
}

其中

kotlin 复制代码
open class PacketBase {
    open fun handleFor(byteBuf: ByteBuf, player: Player) {}
    open fun sendTo(player: Player) {}
}

注:这里的PID与客户端中在网络管理器注册的是相同的

网络通信

kotlin 复制代码
object NetworkHandler : PluginMessageListener {
    private const val CHANNEL_NAME = "我们的信道_channel"

    @Awake(LifeCycle.ENABLE)
    private fun init() {
        Bukkit.getMessenger().registerIncomingPluginChannel(BukkitPlugin.getInstance(), CHANNEL_NAME, this)
        Bukkit.getMessenger().registerOutgoingPluginChannel(BukkitPlugin.getInstance(), CHANNEL_NAME)
    }

    @Override
    override fun onPluginMessageReceived(channel: String?, player: Player?, message: ByteArray?) {
        if (message == null) return
        if (player == null) return
        //数据包ID
        val buf = Unpooled.wrappedBuffer(message)
        val pid = buf.readByte().toInt()
        if (Config.isDebug) {
            info("&b收到客户端报文".colored())
            info("&b报头ID: &e$pid".colored())
        }
        submit {
            when (pid) {
                PacketExecCommands.PID -> PacketExecCommands().handleFor(buf, player)
                PacketCipher.ECDHPublicKeyExchange.PID -> PacketCipher.ECDHPublicKeyExchange().handleFor(buf, player)
            }
        }
    }

    /**
     * 发送ECDH密钥交换数据包
     */
    internal fun Player.sendPublicKeyExchange() {
        submitAsync {
            PacketCipher.ECDHPublicKeyExchange(CipherManager.serverPublicKey).sendTo(this@sendPublicKeyExchange)
        }
    }

    /**
     * 发送加密后的AES256密钥数据包
     */
    internal fun Player.sendEncryptedAES256Key(encryptedAES256Key: ByteArray) {
        submitAsync { PacketCipher.AES256KeySend(encryptedAES256Key).sendTo(this@sendEncryptedAES256Key) }
    }

    /**
     * 发送压缩包密码
     */
    internal fun Player.sendZipPassword(encryptedPassword: ByteArray) {
        submitAsync { PacketCipher.ZipPasswordSend(encryptedPassword).sendTo(this@sendZipPassword) }
    }

    /**
     * 压缩字符串
     */
    internal fun String.zip(level: Int = Deflater.DEFAULT_COMPRESSION): ByteArray {
        return ZipHelper.compress(this.toByteArray(), level, false)
    }

    internal fun ByteBuf.sendToPlayer(player: Player) {
        val bytes = ByteArray(this.readableBytes())
        this.readBytes(bytes)
        player.sendPluginMessage(
            BukkitPlugin.getInstance(),
            CHANNEL_NAME,
            bytes
        )
    }
}

其中将字符串进行压缩的工具类为

kotlin 复制代码
object ZipHelper {
    @Throws(IOException::class)
    fun compress(input: ByteArray?, compressionLevel: Int, gzipFormat: Boolean): ByteArray {
        val compressor = Deflater(compressionLevel, gzipFormat)
        compressor.setInput(input)
        compressor.finish()
        val bao = ByteArrayOutputStream()
        val readBuffer = ByteArray(1024)
        var readCount: Int
        while (!compressor.finished()) {
            readCount = compressor.deflate(readBuffer)
            if (readCount > 0) {
                bao.write(readBuffer, 0, readCount)
            }
        }
        compressor.end()
        return bao.toByteArray()
    }

    @Throws(IOException::class, DataFormatException::class)
    fun decompress(input: ByteArray?, gzipFormat: Boolean): ByteArray {
        val decompressor = Inflater(gzipFormat)
        decompressor.setInput(input)
        val bao = ByteArrayOutputStream()
        val readBuffer = ByteArray(1024)
        var readCount: Int
        while (!decompressor.finished()) {
            readCount = decompressor.inflate(readBuffer)
            if (readCount > 0) {
                bao.write(readBuffer, 0, readCount)
            }
        }
        decompressor.end()
        return bao.toByteArray()
    }
}

最后

如此,我们便完成了一个较为简单的动态下发材质解密,因为使用了非对称加密,用户不可能通过“中间人攻击”这种方式获得加密材质的压缩包密码,且在用户通过验证后服务端会向事件总线发布

kotlin 复制代码
class CipherEvent {
    class PlayerCertificatedEvent(val player: Player): BukkitProxyEvent()
}

我们可以在其他地方订阅这个事件来拓展我们的后续逻辑,并使用ZipTextureManager获取解密后的材质

当然,我们仍然无法完全保证网络传输过程中的安全性,以及客户端的安全性在本篇文章中根本没在我们的考虑之中

2025/10/10 补充

上述方法太过繁琐,弃用

通信

可以采用modlink的分片发包,但要注意的是,高版本(测试版本1.20.1)的forge对MC网络通信部分进行了进一步封装,数据包的结构已经不再为(id,报文)了

为了让modlink可以正确的获取原版的未封装的数据包,在forge下需要修改MC源代码,具体地

  • 在1.20.1-1.20.4:需要Mixin net.minecraft.client.multiplayer.ClientPacketListener.class
  • 在1.20.4及以上:需要变为ClientPacketListenerImpl.class

此处以1.20.1为例,在handleCustomPayload方法头部插入我们的自定义逻辑获取原版的FriendlyByteBuf

java 复制代码
@Mixin({ClientPacketListener.class})
public class ClientPacketListenerMixin {
   @Inject(
           at = @At("HEAD"),
           method = "handleCustomPayload",
           cancellable = true
   )
   public void handleChannelCustomPayLoad(ClientboundCustomPayloadPacket packet, CallbackInfo ci) {
      if (packet.getIdentifier().equals(NetworkHandler.INSTANCE.getChannelName())) {
         NetworkHandler.INSTANCE.onServerToClientCustomPacketReceive(packet.getData());
         ci.cancel();
      }
   }
}

之后桥接到modlink处理

kotlin 复制代码
fun onServerToClientCustomPacketReceive(buf: FriendlyByteBuf) {
        Minecraft.getInstance().execute {
            val readableBytes = buf.readableBytes()
            if (readableBytes <= 0) {
                return@execute
            }
            val data = ByteArray(readableBytes)
            buf.readBytes(data)
            modlink.handleMessageReceived(data)
        }
    }

而发包则需要发送原版的ServerboundCustomPayloadPacket

kotlin 复制代码
fun sendToServer(packet: ModLinkPacket) {
        modlink.sendPacket(packet) { bytes ->
            Minecraft.getInstance().connection?.send(
                ServerboundCustomPayloadPacket(
                    channelName,
                    FriendlyByteBuf(Unpooled.wrappedBuffer(bytes))
                )
            )
        }
    }

Bukkit端不需要额外进行处理

加解密

在ECDH密钥交换完毕获取共享密钥后就不需要再发送额外的AES256密钥了,可以使用HKDF派生得到32位密钥,之后使用带初始化向量的AES/GCM算法进行对称加密

kotlin 复制代码
    private const val GCM_IV_LENGTH: Int = 12
    private const val GCM_TAG_LENGTH: Int = 16
    private const val KEY_DERIVATION_ALGORITHM: String = "HmacSHA256"

    /**
     * 设置对端公钥并计算共享密钥
     */
    @Throws(GeneralSecurityException::class)
    private fun setPeerPublicKeyAndGenerateSharedKey(publicKey: PublicKey?) {
        // 计算ECDH共享密钥
        val keyAgreement = KeyAgreement.getInstance("ECDH")
        keyAgreement.init(clientPrivateKey)
        keyAgreement.doPhase(publicKey, true)
        val sharedSecret = keyAgreement.generateSecret()

        // 使用HKDF派生会话密钥
        this.secretKey = deriveKey(sharedSecret, "SESSION_KEY".toByteArray(), 32)
    }

    /**
     * HKDF密钥派生函数简化实现
     */
    @Suppress("SameParameterValue")
    @Throws(GeneralSecurityException::class)
    private fun deriveKey(ikm: ByteArray?, salt: ByteArray, length: Int): SecretKey {
        // 提取阶段
        val hmac = Mac.getInstance(KEY_DERIVATION_ALGORITHM)
        val saltKey = SecretKeySpec(salt, KEY_DERIVATION_ALGORITHM)
        hmac.init(saltKey)
        val prk = hmac.doFinal(ikm)

        // 扩展阶段
        hmac.init(SecretKeySpec(prk, KEY_DERIVATION_ALGORITHM))
        var t = ByteArray(0)
        val okm = ByteArray(length)
        var pos = 0

        var i = 1
        while (pos < length) {
            hmac.update(t)
            hmac.update(i.toByte())
            t = hmac.doFinal()
            System.arraycopy(t, 0, okm, pos, min(t.size, length - pos))
            pos += t.size
            i++
        }

        return SecretKeySpec(okm, "AES")
    }

    /**
     * 使用AES-GCM加密数据
     *
     * @param byte 明文数据
     * @return 密文数据和iv
     * @throws GeneralSecurityException 密钥或数据处理过程中发生错误
     */
    @Throws(GeneralSecurityException::class)
    internal fun encrypt(byte: ByteArray): Pair<ByteArray, ByteArray> {
        checkNotNull(secretKey) { "会话密钥不存在!" }

        val cipher = Cipher.getInstance("AES/GCM/NoPadding")
        val iv = ByteArray(GCM_IV_LENGTH)
        SecureRandom().nextBytes(iv)
        val parameterSpec = GCMParameterSpec(GCM_TAG_LENGTH * 8, iv)
        cipher.init(Cipher.ENCRYPT_MODE, secretKey, parameterSpec)

        val ciphertext = cipher.doFinal(byte)
        return Pair(ciphertext, iv)
    }

    /**
     * 使用AES-GCM解密数据
     *
     * @param encrypted 密文数据
     * @param iv IV
     * @return 明文数据
     * @throws GeneralSecurityException 密钥或数据处理过程中发生错误
     */
    @Throws(GeneralSecurityException::class)
    internal fun decrypt(encrypted: ByteArray, iv: ByteArray): ByteArray? {
        checkNotNull(secretKey) { "会话密钥不存在!" }

        val cipher = Cipher.getInstance("AES/GCM/NoPadding")
        val parameterSpec = GCMParameterSpec(GCM_TAG_LENGTH * 8, iv)
        cipher.init(Cipher.DECRYPT_MODE, secretKey, parameterSpec)

        return cipher.doFinal(encrypted)
    }

这样省去了额外发送AES256密钥的步骤,同时使用初始化向量的AES/GCM算法可以保证语义安全,只需在发送加密报文时将IV一并发送即可,而且与ECB相比,使用GCM的AES加密带有完整性校验

动态材质加载

其实调用MC材质管理器的register方法注册一个DynamicTexture就好了

在1.14以后麻将引入了全新的渲染库Blaze3D,查看绑定材质的方法com.mojang.blaze3d.systems.RenderSystem#setShaderTexture的源码

java 复制代码
   public static void setShaderTexture(int pShaderTexture, ResourceLocation pTextureId) {
      if (!isOnRenderThread()) {
         recordRenderCall(() -> {
            _setShaderTexture(pShaderTexture, pTextureId);
         });
      } else {
         _setShaderTexture(pShaderTexture, pTextureId);
      }

   }

   public static void _setShaderTexture(int pShaderTexture, ResourceLocation pTextureId) {
      if (pShaderTexture >= 0 && pShaderTexture < shaderTextures.length) {
         TextureManager texturemanager = Minecraft.getInstance().getTextureManager();
         AbstractTexture abstracttexture = texturemanager.getTexture(pTextureId);
         shaderTextures[pShaderTexture] = abstracttexture.getId();
      }

   }

可以看到它调用了MC的材质管理器中的方法TextureManager#getTexture

java 复制代码
   public AbstractTexture getTexture(ResourceLocation pPath) {
      AbstractTexture abstracttexture = this.byPath.get(pPath);
      if (abstracttexture == null) {
         abstracttexture = new SimpleTexture(pPath);
         this.register(pPath, abstracttexture);
      }

      return abstracttexture;
   }

默认为null时会注册一个SimpleTexture,所以我们只要提前注册一个DynamicTexture就好了

以下以1.20.1为例,Zip库改为Zip4J

kotlin 复制代码
    private var zipFile: ZipFile? = null
    /** 压缩包中所有资源文件 路径 -> 文件头  */
    private val zipResourceMap = HashMap<String, FileHeader>()

    /** 图片缓存  */
    private val imageCache: ConcurrentHashMap<ResourceLocation, NativeImage> = ConcurrentHashMap()

    /** 是否可以渲染 **/
    internal var isResourcesReady = AtomicBoolean(false)

    /**
     * 打开Zip压缩包
     */
    internal fun openMapZipFile(password: String) {
        run {
            val zipFile = Constants.ModDir.resolve("Zip.zip")
            if (!zipFile.exists()) SaintMapMod.LOGGER.error("无法找到压缩包!")
            this.zipFile = ZipFile(zipFile)
            // 检查文件是否存在且为ZIP文件
            if (!this.zipFile!!.isValidZipFile) throw RuntimeException("无效的ZIP文件")
            // 设置密码
            if (this.zipFile!!.isEncrypted) this.zipFile!!.setPassword(password.toCharArray())

            // 保存所有文件头
            this.zipFile!!.fileHeaders.forEach { header ->
                if (!header.isDirectory) {
                    // 规范化路径(替换反斜杠为正斜杠)
                    val normalizedPath = header.fileName.replace("\\", "/")
                    zipResourceMap[normalizedPath] = header
                }
            }

            // 注册图片到材质管理器
            zipResourceMap.keys.forEach { path ->
                val location = ClientMiscUtil.buildResourceLocation(path)

                val image = getNativeImage(location, true) ?: return@forEach
                val dynamicTexture = DynamicTexture(image)
                Minecraft.getInstance().getTextureManager().register(location, dynamicTexture)
            }

            // 就绪
            isResourcesReady.compareAndSet(false, true)
        }
    }

    /**
     * 重新加载压缩包内的资源
     */
    internal fun reloadResources(password: String) {
        run {
            // 清除就绪状态
            isResourcesReady.compareAndSet(true, false)
            // 注销
            close()
            // 重新打开
            openMapZipFile(password)
        }
    }

    /**
     * 直接获取材质的NativeImage
     * @param unsafe 忽略是否允许渲染,仅在内部加载材质时设为true
     */
    internal fun getNativeImage(location: ResourceLocation, unsafe: Boolean = false): NativeImage? {
        // 是否是PNG图片
        if (!location.path.endsWith(".png")) return null
        // 是否就绪
        if (!isResourcesReady.get() && !unsafe) return null
        // 读取缓存
        if (imageCache.containsKey(location)) return imageCache[location]

        run {
            //从压缩包加载
            val inputStream = getInputStream(location.path) ?: return null
            val nativeImage: NativeImage = NativeImage.read(inputStream)
            imageCache[location] = nativeImage
            return nativeImage
        }
    }

    /**
     * 获取压缩包文件输入流
     * @param path 压缩包内的路径
     */
    private fun getInputStream(path: String): InputStream? {
        // 没有文件
        if (!zipResourceMap.containsKey(path)) {
            return null
        }

        val fileHeader = zipResourceMap[path]!!

        return zipFile!!.getInputStream(fileHeader).use {
            ByteArrayInputStream(it.readAllBytes())
        }
    }

    /**
     * 注销
     */
    private fun close() {
        zipResourceMap.clear()
        zipFile?.close()
        imageCache.keys.forEach(Minecraft.getInstance().textureManager::release)
        imageCache.clear()
    }

    @Suppress("unused")
    internal fun onPlayerLoggedOut(event: PlayerEvent.PlayerLoggedOutEvent) {
        close()
    }
游客

全部评论 (0)

暂无评论,快来抢沙发吧~