前言
在某些情况下,我们需要在游戏客户端中动态地加载材质贴图,而我们又不想将贴图文件不与加密的存储在客户端中,因此这种情况下我们可能会选择将材质加密后存储于客户端本地,而后通过网络发送密钥至客户端解密材质后进行加载,本篇文章尝试以MC为例探究和解决这个问题
注:以下操作简化了一些步骤,具体为
- 材质的加密 -> 使用加密的压缩包
整体设计
- 服务端发送密钥
- 客户端尝试使用密钥解密
- 若成功,则加载材质
但是实际上,直接发送明文加密密钥是非常不安全的,因为明文的密钥可能会被以“中间人攻击”的形式抓到,因此我们将使用非对称加密技术来解决这个问题,实际的逻辑为
- 服务端发送公钥
- 客户端通过服务端公钥和自己的私钥产生共享密钥,并向服务端发送客户端公钥
- 服务端通过客户端公钥和自己的私钥产生共享密钥
- 服务端使用共享密钥发送材质的加密密钥
- 客户端使用共享密钥解密材质的加密密钥
- 客户端尝试使用密钥解密
- 若成功,则加载材质
密钥:
- 非对称加密:ECDH,椭圆曲线使用secp256r1
- 对称加密:AES256
通信:
- 客户端为Forge,服务端为Bukkit(Paper)
- 参考海螺先生在2017年的博客Forge / LiteLoader 与 Bukkit / Sponge 之间的通信
了解设计后即可开始编写代码
编写代码
- 客户端环境:版本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;
}
}
材质动态加载部分
我们通过实现IResourceManager和IResource来设计一个从压缩包中加载贴图的材质管理器
对于压缩包的处理,使用到了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)
暂无评论,快来抢沙发吧~