diff --git a/docs/Assemble-context.md b/docs/Assemble-context.md new file mode 100644 index 0000000..ee46059 --- /dev/null +++ b/docs/Assemble-context.md @@ -0,0 +1,31 @@ +# Assemble context API + +```kotlin +public val CustomProtocolContext: ProtocolContextBuilder.() -> Unit = { + rsaEncryptor = RSA1024Encryptor { key, data -> ... } + sha1Hasher = Sha1Hasher { serverId, secretKey, publicKey -> ... } + cipherFactory = { key -> ... } + authProvider = AuthenticationProvider { url, accessToken, uuid, serverIdHash -> ... } + socketEngine = MyCustomTCPSocketImpl() +} +``` + +> If using `DefaultProtocolContext` from `libmc-protocol-context` as the default context implementation, +> please refer to [Implementing TCPSocket](Impl-TCP-Socket.md) + +# Modify exists context API implementation + +```kotlin +fun main() { + val myNewProtocolContext = DefaultProtocolContext.withCustom { + rsaEncryptor = RSA1024Encryptor { key, data -> ... } + sha1Hasher = Sha1Hasher { serverId, secretKey, publicKey -> ... } + cipherFactory = { key -> ... } + authProvider = AuthenticationProvider { url, accessToken, uuid, serverIdHash -> ... } + socketEngine = MyCustomTCPSocketImpl() + } +} +``` + +> Call `.withCustom` on a `ProtocolContextBuilder` to modify exists context implementation + diff --git a/docs/Get-started.md b/docs/Get-started.md new file mode 100644 index 0000000..f270dcb --- /dev/null +++ b/docs/Get-started.md @@ -0,0 +1,82 @@ +# Creating a Client + +```kotlin +public fun main() = runBlocking { + val client = createMinecraftClient( + "127.0.0.1", 25566, "MyBot", + generateOfflineUuid("MyBot"), + accessToken = null, + context = DefaultProtocolContext.withCustom { + socketEngine = KtorNetworkEngine() + } + ) + client.launch { client.connect() } + while (true) { + delay(5.seconds) + } +} +``` + +> In the example code above, a `MinecraftClient` is created. This client will connect to an offline server at +> `127.0.0.1:25565` using `MyBot` as the player name, and replaces the underlying TCP Socket +> implementation with a `ktor-network` based TCP Socket. (For details on how to create a SocketEngine, please refer +> to [Implementing TCP Socket](Impl-TCP-Socket.md). For details on how to +> create a Context, please refer to [Required APIs](README.md#required-apis)) +> MinecraftClient implements CoroutineScope, and calling `client.connect()` will execute the connection on a background +> thread. Blocking thread to prevent the application from exiting + +## Connecting to an Online-Mode Server + +```kotlin +private val accessToken = "eyJraWQiOiIw..." + +public fun main() = runBlocking { + val client = createMinecraftClient( + // Other parameters + username = "RTAkland", + uuid = Uuid.parse("bb033844-e68e-4909-a636-1a5d1821ddc4"), + accessToken = accessToken, + // Other parameters + ) +} +``` + +> In the example code above, the client will connect to the server using `RTAkland` as the player name. + +# Get an AccessToken + +Open [minecraft.net](https://minecraft.net), log in, press `F12`, and run the following code in the Console: + +```javascript +console.log(`; ${document.cookie}`.split('; bearer_token=').pop().split(';').shift()) +``` + +> Note: AccessTokens are valid for 24 hours + +# Listening for Packets + +```kotlin +// Listen for specific received packets (must start with Clientbound) +client.onPacket { + println(it) +} + +// Listen for all received packets +client.on { packet, direction -> + println("$direction ->$packet") +} + +// Listen for outgoing packets (must start with Serverbound) +cli.onSent { + println(it) +} +``` + +# Sending Packets + +```kotlin +// Only packets starting with Serverbound can be sent, otherwise an UnsupportedOperationException will be thrown +client.networkChannel.sendPacket( + ServerboundChatCommandPacket(command = "say Hello from libmc") +) +``` \ No newline at end of file diff --git a/docs/Impl-Crypto.md b/docs/Impl-Crypto.md new file mode 100644 index 0000000..44c3104 --- /dev/null +++ b/docs/Impl-Crypto.md @@ -0,0 +1,67 @@ +# Before start + +`AES-128-CFB8`, `RSA1024` and `SHA1` has been implemented in `libmc-protocol-context` module, it uses +`cryptography-kotlin`(and its platform based provider) + +> All impl example below are based on Java's built-in security & cryptography API + +# AES-128-CFB8 + +```kotlin +class JavaAesCipher(sharedKey: ByteArray) : NetworkCipher { + private val encryptCipher = Cipher.getInstance("AES/CFB8/NoPadding").apply { + init(Cipher.ENCRYPT_MODE, SecretKeySpec(sharedKey, "AES"), IvParameterSpec(sharedKey)) + } + + private val decryptCipher = Cipher.getInstance("AES/CFB8/NoPadding").apply { + init(Cipher.DECRYPT_MODE, SecretKeySpec(sharedKey, "AES"), IvParameterSpec(sharedKey)) + } + + override fun encrypt(buffer: ByteArray, offset: Int, length: Int) { + encryptCipher.update(buffer, offset, length, buffer, offset) + } + + override fun decrypt(buffer: ByteArray, offset: Int, length: Int) { + decryptCipher.update(buffer, offset, length, buffer, offset) + } +} +``` + +# RSA1024 + +```kotlin +private fun encrypt(publicKeyBytes: ByteArray, data: ByteArray): ByteArray { + val keySpec = X509EncodedKeySpec(publicKeyBytes) + val keyFactory = KeyFactory.getInstance("RSA") + val publicKey = keyFactory.generatePublic(keySpec) + val cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding") + cipher.init(Cipher.ENCRYPT_MODE, publicKey) + return cipher.doFinal(data) +} + +public val CustomProtocolContext: ProtocolContextBuilder.() -> Unit = { + rsaEncryptor = RSA1024Encryptor { key: ByteArray, data: ByteArray -> encrypt(key, data) } +} +``` + +# SHA1 + +```kotlin +private fun minecraftServerIdHash(serverId: String, secretKey: ByteArray, publicKey: ByteArray): String { + val serverIdBytes: ByteArray = serverId.encodeToByteArray() + for (b in serverIdBytes) require((b.toInt() and 0xFF) <= 0x7F) { "serverId contains non-US-ASCII character" } + val data = ByteArray(serverIdBytes.size + secretKey.size + publicKey.size) + System.arraycopy(serverIdBytes, 0, data, 0, serverIdBytes.size) + System.arraycopy(secretKey, 0, data, serverIdBytes.size, secretKey.size) + System.arraycopy(publicKey, 0, data, serverIdBytes.size + secretKey.size, publicKey.size) + val digest = MessageDigest.getInstance("SHA-1") + val hash = digest.digest(data) + return BigInteger(hash).toString(16) +} + +public val CustomProtocolContext: ProtocolContextBuilder.() -> Unit = { + sha1Hasher = Sha1Hasher { serverId: String, secretKey: ByteArray, publicKey: ByteArray -> + minecraftServerIdHash(serverId, secretKey, publicKey) + } +} +``` \ No newline at end of file diff --git a/docs/Impl-HTTP-Client.md b/docs/Impl-HTTP-Client.md new file mode 100644 index 0000000..794ffc7 --- /dev/null +++ b/docs/Impl-HTTP-Client.md @@ -0,0 +1,41 @@ +# Ktor based http client + +```kotlin +private val httpClient = HttpClient() + +public val CustomProtocolContext: ProtocolContextBuilder.() -> Unit = { + authProvider = AuthenticationProvider { url, accessToken, uuid, serverIdHash -> + val status = httpClient.post(url) { + headers { header("Content-Type", "application/json") } + setBody("{\"accessToken\":\"$accessToken\", \"selectedProfile\":\"$uuid\", \"serverId\":\"$serverIdHash\"}") + }.status + require(status == HttpStatusCode.NoContent) + } +} +``` + +# HttpURLConnection based http client + +```kotlin +private fun sendJoinRequest(url: String, accessToken: String, uuid: String, serverIdHash: String): Boolean { + val connection = (URL(url).openConnection() as HttpURLConnection).apply { + requestMethod = "POST" + setRequestProperty("Content-Type", "application/json") + doOutput = true + connectTimeout = 5000 + readTimeout = 5000 + } + val jsonPayload = """{"accessToken":"$accessToken","selectedProfile":"$uuid","serverId":"$serverIdHash"}""" + connection.outputStream.use { os -> os.write(jsonPayload.toByteArray(Charsets.UTF_8)) } + val responseCode = connection.responseCode + connection.disconnect() + return responseCode == HttpURLConnection.HTTP_NO_CONTENT +} + +public val CustomProtocolContext: ProtocolContextBuilder.() -> Unit = { + authProvider = AuthenticationProvider { url, accessToken, uuid, serverIdHash -> + val success = sendJoinRequest(url, accessToken, uuid, serverIdHash) + require(success) { "Failed to authenticate with Mojang session server" } + } +} +``` \ No newline at end of file diff --git a/docs/Impl-TCP-Socket.md b/docs/Impl-TCP-Socket.md new file mode 100644 index 0000000..6b2dbfa --- /dev/null +++ b/docs/Impl-TCP-Socket.md @@ -0,0 +1,329 @@ +# Before started + +If the TCP Socket library is non-suspending, **DO NOT** wrap I/O read and write operations with `withContext`. +It will cause frequent thread context switching during network I/O, leading to a degradation in application +performance + +# Netty based TCP Socket implementation + +```kotlin +dependencies { + implementation("io.netty:netty-transport:4.2.17.Final") + implementation("io.netty:netty-buffer:4.2.17.Final") +} +``` + +
+Click to expand code + +```kotlin +import cn.rtast.libmc.network.RawSocket +import cn.rtast.libmc.network.ReadChannel +import cn.rtast.libmc.network.SocketEngine +import cn.rtast.libmc.network.WriteChannel +import io.netty.bootstrap.Bootstrap +import io.netty.buffer.ByteBuf +import io.netty.buffer.Unpooled +import io.netty.channel.* +import io.netty.channel.nio.NioIoHandler +import io.netty.channel.socket.SocketChannel +import io.netty.channel.socket.nio.NioSocketChannel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import io.netty.channel.Channel as NettyChannel +import kotlinx.coroutines.channels.Channel as KotlinChannel + +public class NettyNetworkSocketEngine : SocketEngine { + override fun create(host: String, port: Int): RawSocket = NettyNetworkSocket(host, port) + + private class NettyNetworkSocket(private val host: String, private val port: Int) : RawSocket { + private val workerGroup: EventLoopGroup = MultiThreadIoEventLoopGroup(0, NioIoHandler.newFactory()) + private lateinit var channel: NettyChannel + private val inboundQueue = KotlinChannel(KotlinChannel.UNLIMITED) + private lateinit var readChannel: NettyReadChannel + private lateinit var writeChannel: NettyWriteChannel + + override suspend fun connect() { + val bootstrap = Bootstrap() + .group(workerGroup) + .channel(NioSocketChannel::class.java) + .option(ChannelOption.TCP_NODELAY, true) + .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 10000) + .handler(object : ChannelInitializer() { + override fun initChannel(ch: SocketChannel) { + ch.pipeline().addLast(object : ChannelInboundHandlerAdapter() { + override fun channelRead(ctx: ChannelHandlerContext, msg: Any) { + if (msg is ByteBuf) { + try { + val bytes = ByteArray(msg.readableBytes()) + msg.readBytes(bytes) + inboundQueue.trySend(bytes) + } finally { + msg.release() + } + } + } + + override fun exceptionCaught(ctx: ChannelHandlerContext, cause: Throwable) { + inboundQueue.close(cause) + ctx.close() + } + + override fun channelInactive(ctx: ChannelHandlerContext) { + inboundQueue.close() + super.channelInactive(ctx) + } + }) + } + }) + + channel = withContext(Dispatchers.IO) { + bootstrap.connect(host, port).suspendAwait() + } + + readChannel = NettyReadChannel(inboundQueue) + writeChannel = NettyWriteChannel(channel) + } + + override fun openReadChannel(): ReadChannel = readChannel + override fun openWriteChannel(): WriteChannel = writeChannel + + override fun close() { + if (::channel.isInitialized && channel.isOpen) { + channel.close() + } + inboundQueue.close() + workerGroup.shutdownGracefully() + } + } + + private class NettyReadChannel(private val inboundQueue: KotlinChannel) : ReadChannel { + private var currentChunk: ByteArray? = null + private var chunkOffset = 0 + + private suspend fun fetchNextChunk() { + val next = inboundQueue.receiveCatching().getOrNull() + ?: throw IllegalStateException("Socket/Channel closed while reading") + currentChunk = next + chunkOffset = 0 + } + + override suspend fun readByte(): Byte { + while (currentChunk == null || chunkOffset >= currentChunk!!.size) { + fetchNextChunk() + } + val chunk = currentChunk!! + return chunk[chunkOffset++] + } + + override suspend fun readBytes(length: Int): ByteArray { + val result = ByteArray(length) + readFully(result, 0, length) + return result + } + + override suspend fun readFully(out: ByteArray, start: Int, end: Int) { + var written = start + + while (written < end) { + while (currentChunk == null || chunkOffset >= currentChunk!!.size) { + fetchNextChunk() + } + val chunk = currentChunk!! + val available = chunk.size - chunkOffset + val toCopy = minOf(available, end - written) + + chunk.copyInto( + out, + destinationOffset = written, + startIndex = chunkOffset, + endIndex = chunkOffset + toCopy + ) + chunkOffset += toCopy + written += toCopy + } + } + } + + private class NettyWriteChannel(private val channel: NettyChannel) : WriteChannel { + private val writeMutex = Mutex() + + override suspend fun writeFully(value: ByteArray, startIndex: Int, endIndex: Int) { + val length = endIndex - startIndex + if (length <= 0) return + + val nettyBuf = Unpooled.copiedBuffer(value, startIndex, length) + + writeMutex.withLock { + withContext(Dispatchers.IO) { + channel.writeAndFlush(nettyBuf).suspendAwait() + } + } + } + + override suspend fun flush() { + writeMutex.withLock { + withContext(Dispatchers.IO) { + channel.flush() + } + } + } + } + +} + +private suspend inline fun ChannelFuture.suspendAwait(): NettyChannel = suspendCancellableCoroutine { cont -> + if (isDone) { + if (isSuccess) cont.resume(channel()) + else cont.resumeWithException(cause() ?: RuntimeException("Netty operation failed")) + return@suspendCancellableCoroutine + } + + addListener { future -> + if (future.isSuccess) { + cont.resume(channel()) + } else { + cont.resumeWithException(future.cause() ?: RuntimeException("Netty operation failed")) + } + } + cont.invokeOnCancellation { cancel(false) } + +} +``` + +> Also copy the imports + +
+ +# ktor-network based TCP Socket implementation + +```kotlin +dependencies { + implementation("io.ktor:ktor-network:3.5.2") +} +``` + +
+Click to expand + +```kotlin +public class KtorNetworkEngine : SocketEngine { + override fun create(host: String, port: Int): RawSocket = KtorNetworkSocket(host, port) +} + +public class KtorNetworkSocket(private val host: String, private val port: Int) : RawSocket { + private val sm = SelectorManager(Dispatchers.IO) + private lateinit var socket: Socket + + override suspend fun connect(): Unit = run { socket = aSocket(sm).tcp().connect(host, port) } + override fun openReadChannel(): ReadChannel = KtorReadChannel(socket.openReadChannel()) + override fun openWriteChannel(): WriteChannel = KtorWriteChannel(socket.openWriteChannel()) + override fun close() { + socket.close() + sm.close() + } +} + +public class KtorReadChannel(private val readChannel: ByteReadChannel) : ReadChannel { + override suspend fun readByte(): Byte = readChannel.readByte() + override suspend fun readBytes(length: Int): ByteArray = readChannel.readByteArray(length) + override suspend fun readFully(out: ByteArray, start: Int, end: Int): Unit = + readChannel.readFully(out, start, end) +} + +public class KtorWriteChannel(private val writeChannel: ByteWriteChannel) : WriteChannel { + override suspend fun writeFully(value: ByteArray, startIndex: Int, endIndex: Int) { + writeChannel.writeFully(value, startIndex, endIndex) + } + + override suspend fun flush(): Unit = writeChannel.flush() +} +``` + +
+ +# Java built-in Socket based implementation + +
+Click to expand code + +```kotlin +class JavaSocketEngine : SocketEngine { + override fun create(host: String, port: Int): RawSocket = JavaNetworkSocket(host, port) +} + +class JavaNetworkSocket( + private val host: String, + private val port: Int, + private val connectTimeoutMs: Int = 10000, +) : RawSocket { + private lateinit var socket: Socket + private lateinit var readChannel: JavaReadChannel + private lateinit var writeChannel: JavaWriteChannel + + override suspend fun connect() { + val s = Socket() + s.tcpNoDelay = true + s.connect(InetSocketAddress(host, port), connectTimeoutMs) + + socket = s + readChannel = JavaReadChannel(s.getInputStream()) + writeChannel = JavaWriteChannel(s.getOutputStream()) + } + + override fun openReadChannel(): ReadChannel = readChannel + override fun openWriteChannel(): WriteChannel = writeChannel + + override fun close() { + if (::socket.isInitialized && !socket.isClosed) { + runCatching { socket.close() } + } + } +} + +class JavaReadChannel(private val inputStream: InputStream) : ReadChannel { + override suspend fun readByte(): Byte { + val b = inputStream.read() + if (b == -1) throw IllegalStateException("Socket stream reached EOF while reading byte") + return b.toByte() + } + + override suspend fun readBytes(length: Int): ByteArray { + val buffer = ByteArray(length) + readFullyInternal(buffer, 0, length) + return buffer + } + + override suspend fun readFully(out: ByteArray, start: Int, end: Int) { + readFullyInternal(out, start, end - start) + } + + private fun readFullyInternal(out: ByteArray, offset: Int, length: Int) { + var bytesRead = 0 + while (bytesRead < length) { + val count = inputStream.read(out, offset + bytesRead, length - bytesRead) + if (count == -1) { + throw IllegalStateException("Socket stream closed unexpectedly (read $bytesRead of $length bytes)") + } + bytesRead += count + } + } +} + +class JavaWriteChannel(private val outputStream: OutputStream) : WriteChannel { + override suspend fun writeFully(value: ByteArray, startIndex: Int, endIndex: Int) { + outputStream.write(value, startIndex, endIndex - startIndex) + } + + override suspend fun flush() { + outputStream.flush() + } +} +``` + +
\ No newline at end of file diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..8d5a5b9 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,29 @@ +# libmc + +A lightweight, modern Minecraft client protocol library designed for Kotlin Native & JVM. The core protocol library +module relies on the following dependencies: + +- Standard Library (`kotlin-stdlib`) +- I/O (`kotlinx-io`) - Efficiently wraps each packet into a Buffer +- Coroutines (`kotlinx-coroutines`) - Enables high-performance asynchronous operations + +## Required APIs + +| Module Name | Required | Notes | +|:-------------|:------------|:----------------------------------------------------------------------------------------------------------------------------------------------------------------| +| TCP Socket | Yes | The `protocol` module does not have a built-in TCP Socket implementation. [Implement TCP Socket](Impl-TCP-Socket.md) | +| AES-128-CFB8 | Conditional | Required only when logging into an `online-mode` server to encrypt/decrypt traffic. [Implement AES-128-CFB8](Impl-Crypto.md#AES-128-CFB8) | +| RSA 1024 | Conditional | Required only when logging into an `online-mode` server to encrypt the shared secret with the server's public key. [Implement RSA 1024](Impl-Crypto.md#RSA1024) | +| SHA1 | Conditional | Required only when logging into an `online-mode` server to compute the Server ID hash. [Implement SHA1](Impl-Crypto.md#SHA1) | +| HTTP Client | Conditional | Required only when logging into an `online-mode` server to send join request to mojang's session server. [Implement HTTP Client](Impl-HTTP-Client.md) | + +# Get started + +> `libmc-protocol` is current under development. It only supports the latest Minecraft version +> (Current supported Minecraft version: `26.2`, Protocol Version: `776`) + +[Start using libmc-protocol](Get-started.md) + +# Assemble all context APIs + +[Assemble context APIs](Assemble-context.md) \ No newline at end of file diff --git a/docs/implement-encryption.md b/docs/implement-encryption.md deleted file mode 100644 index 7f203e2..0000000 --- a/docs/implement-encryption.md +++ /dev/null @@ -1,16 +0,0 @@ -* **AES-128-CFB8** (`NetworkCipher`): Handles in-place network packet encryption and decryption. -* **SHA-1** (`Sha1Hasher`): Computes the Minecraft server ID hash for online-mode authentication. -* **RSA-1024** (`RSA1024Encryptor`): Encrypts the shared secret and verify token during the login handshake. -* **Auth Join Request** (`AuthenticationProvider`): Sends the HTTP POST request to Mojang's session server - (`https://sessionserver.mojang.com/session/minecraft/join`). - -[Use libmc-protocol-encrypt](https://repo.rtast.cn/packages/-/cn.rtast.libmc:protocol-encrypt) - -```kotlin -fun main() { - val client = createMinecraftClient( - // other parameter - crypto = DefaultProtocolContext - ) -} -``` \ No newline at end of file diff --git a/docs/zh/Assemble-context-zh.md b/docs/zh/Assemble-context-zh.md new file mode 100644 index 0000000..1b4c709 --- /dev/null +++ b/docs/zh/Assemble-context-zh.md @@ -0,0 +1,31 @@ +# 组合上下文API + +```kotlin +public val CustomProtocolContext: ProtocolContextBuilder.() -> Unit = { + rsaEncryptor = RSA1024Encryptor { key, data -> ... } + sha1Hasher = Sha1Hasher { serverId, secretKey, publicKey -> ... } + cipherFactory = { key -> ... } + authProvider = AuthenticationProvider { url, accessToken, uuid, serverIdHash -> ... } + socketEngine = MyCustomTCPSocketImpl() +} +``` + +> 如果使用`libmc-protocol-context`中的`DefaultProtocolContext`作为默认上下文实现, +> 请参阅[实现 TCPSocket](Impl-TCP-Socket-zh.md) + +# 修改默认实现 + +```kotlin +fun main() { + val myNewProtocolContext = DefaultProtocolContext.withCustom { + rsaEncryptor = RSA1024Encryptor { key, data -> ... } + sha1Hasher = Sha1Hasher { serverId, secretKey, publicKey -> ... } + cipherFactory = { key -> ... } + authProvider = AuthenticationProvider { url, accessToken, uuid, serverIdHash -> ... } + socketEngine = MyCustomTCPSocketImpl() + } +} +``` + +> 使用`.withCustom`来修改已有的上下文API实现 + diff --git a/docs/zh/Get-started-zh.md b/docs/zh/Get-started-zh.md new file mode 100644 index 0000000..ae8b135 --- /dev/null +++ b/docs/zh/Get-started-zh.md @@ -0,0 +1,79 @@ +# 创建客户端 + +```kotlin +public fun main() = runBlocking { + val client = createMinecraftClient( + "127.0.0.1", 25566, "MyBot", + generateOfflineUuid("MyBot"), + accessToken = null, + context = DefaultProtocolContext.withCustom { + socketEngine = KtorNetworkEngine() + } + ) + client.launch { client.connect() } + while (true) { + delay(5.seconds) + } +} +``` + +> 在上面的示例代码中, 创建了一个`MinecraftClient`, 这个客户端将会使用`MyBot`作为玩家名称连接到`127.0.0.1:25565`的`离线` +> 服务器, 并且将底层TCP Socket实现替换为了基于`ktor-network`实现的TCP Socket. +> (有关如何创建`SocketEngine`请参阅[实现TCP Socket](Impl-TCP-Socket-zh.md), +> 有关如何创建Context请参阅[需要实现的API](README-zh.md#需要实现的api)) +> `MinecraftClient`实现了`CoroutineContext`, 使用`client.connect()`后将会切换到后台线程执行, 添加阻塞线程代码以免程序直接退出. + +## 连接到在线服务器 + +```kotlin +private val accessToken = "eyJraWQiOiIw..." + +public fun main() = runBlocking { + val client = createMinecraftClient( + // 其他参数 + username = "RTAkland", + uuid = Uuid.parse("bb033844-e68e-4909-a636-1a5d1821ddc4"), + accessToken = accessToken, + // 其他参数 + ) +} +``` + +> 上面的示例代码中, 将会使用`RTAkland`作为玩家名称连接到服务器 + +### 快速获取AccessToken + +打开[minecraft.net](https://minecraft.net)并登录后按下F12, 在Console内输入 + +```javascript +console.log(`; ${document.cookie}`.split('; bearer_token=').pop().split(';').shift()) +``` + +> AccessToken的有效期为24小时 + +# 监听数据包 + +```kotlin +// 监听接收到的指定类型的数据包, 必须以Clientbound开头 +client.onPacket { + println(it) +} +// 监听所有接收到的数据包 +client.on { packet, direction -> + println("$direction -> $packet") +} + +// 监听被发送出去的数据包, 必须以Serverbound开头 +cli.onSent { + println(it) +} +``` + +# 发送数据包 + +```kotlin +// 必须以Serverbound开头的数据包才可以被发送, 否则将会抛出UnsupportedOperationException异常 +client.networkChannel.sendPacket( + ServerboundChatCommandPacket(command = "say Hello from libmc") +) +``` \ No newline at end of file diff --git a/docs/zh/Impl-Crypto-zh.md b/docs/zh/Impl-Crypto-zh.md new file mode 100644 index 0000000..3ed495f --- /dev/null +++ b/docs/zh/Impl-Crypto-zh.md @@ -0,0 +1,67 @@ +# Before start + +`libmc-protocol-context` 模块中默认实现了 `AES-128-CFB8`, `RSA1024` 以及 `SHA1`, +三种加密/签名算法都基于`cryptography-kotlin`及其对应平台的provider + +> 下面给出的示例代码均基于Java内置的密码学API + +# AES-128-CFB8 + +```kotlin +class JavaAesCipher(sharedKey: ByteArray) : NetworkCipher { + private val encryptCipher = Cipher.getInstance("AES/CFB8/NoPadding").apply { + init(Cipher.ENCRYPT_MODE, SecretKeySpec(sharedKey, "AES"), IvParameterSpec(sharedKey)) + } + + private val decryptCipher = Cipher.getInstance("AES/CFB8/NoPadding").apply { + init(Cipher.DECRYPT_MODE, SecretKeySpec(sharedKey, "AES"), IvParameterSpec(sharedKey)) + } + + override fun encrypt(buffer: ByteArray, offset: Int, length: Int) { + encryptCipher.update(buffer, offset, length, buffer, offset) + } + + override fun decrypt(buffer: ByteArray, offset: Int, length: Int) { + decryptCipher.update(buffer, offset, length, buffer, offset) + } +} +``` + +# RSA1024 + +```kotlin +private fun encrypt(publicKeyBytes: ByteArray, data: ByteArray): ByteArray { + val keySpec = X509EncodedKeySpec(publicKeyBytes) + val keyFactory = KeyFactory.getInstance("RSA") + val publicKey = keyFactory.generatePublic(keySpec) + val cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding") + cipher.init(Cipher.ENCRYPT_MODE, publicKey) + return cipher.doFinal(data) +} + +public val CustomProtocolContext: ProtocolContextBuilder.() -> Unit = { + rsaEncryptor = RSA1024Encryptor { key: ByteArray, data: ByteArray -> encrypt(key, data) } +} +``` + +# SHA1 + +```kotlin +private fun minecraftServerIdHash(serverId: String, secretKey: ByteArray, publicKey: ByteArray): String { + val serverIdBytes: ByteArray = serverId.encodeToByteArray() + for (b in serverIdBytes) require((b.toInt() and 0xFF) <= 0x7F) { "serverId contains non-US-ASCII character" } + val data = ByteArray(serverIdBytes.size + secretKey.size + publicKey.size) + System.arraycopy(serverIdBytes, 0, data, 0, serverIdBytes.size) + System.arraycopy(secretKey, 0, data, serverIdBytes.size, secretKey.size) + System.arraycopy(publicKey, 0, data, serverIdBytes.size + secretKey.size, publicKey.size) + val digest = MessageDigest.getInstance("SHA-1") + val hash = digest.digest(data) + return BigInteger(hash).toString(16) +} + +public val CustomProtocolContext: ProtocolContextBuilder.() -> Unit = { + sha1Hasher = Sha1Hasher { serverId: String, secretKey: ByteArray, publicKey: ByteArray -> + minecraftServerIdHash(serverId, secretKey, publicKey) + } +} +``` \ No newline at end of file diff --git a/docs/zh/Impl-HTTP-Client-zh.md b/docs/zh/Impl-HTTP-Client-zh.md new file mode 100644 index 0000000..8cd37f9 --- /dev/null +++ b/docs/zh/Impl-HTTP-Client-zh.md @@ -0,0 +1,45 @@ +# 前言 + +`libmc-protocol-context` 中提供了默认HTTP客户端实现, 如果使用了此模块则不需要手动配置HTTP客户端认证部分 + +# 基于Ktor的HTTP客户端 + +```kotlin +private val httpClient = HttpClient() + +public val CustomProtocolContext: ProtocolContextBuilder.() -> Unit = { + authProvider = AuthenticationProvider { url, accessToken, uuid, serverIdHash -> + val status = httpClient.post(url) { + headers { header("Content-Type", "application/json") } + setBody("{\"accessToken\":\"$accessToken\", \"selectedProfile\":\"$uuid\", \"serverId\":\"$serverIdHash\"}") + }.status + require(status == HttpStatusCode.NoContent) + } +} +``` + +# 基于Java8 HttpURLConnection的HTTP客户端 + +```kotlin +private fun sendJoinRequest(url: String, accessToken: String, uuid: String, serverIdHash: String): Boolean { + val connection = (URL(url).openConnection() as HttpURLConnection).apply { + requestMethod = "POST" + setRequestProperty("Content-Type", "application/json") + doOutput = true + connectTimeout = 5000 + readTimeout = 5000 + } + val jsonPayload = """{"accessToken":"$accessToken","selectedProfile":"$uuid","serverId":"$serverIdHash"}""" + connection.outputStream.use { os -> os.write(jsonPayload.toByteArray(Charsets.UTF_8)) } + val responseCode = connection.responseCode + connection.disconnect() + return responseCode == HttpURLConnection.HTTP_NO_CONTENT +} + +public val CustomProtocolContext: ProtocolContextBuilder.() -> Unit = { + authProvider = AuthenticationProvider { url, accessToken, uuid, serverIdHash -> + val success = sendJoinRequest(url, accessToken, uuid, serverIdHash) + require(success) { "Failed to authenticate with Mojang session server" } + } +} +``` \ No newline at end of file diff --git a/docs/zh/Impl-TCP-Socket-zh.md b/docs/zh/Impl-TCP-Socket-zh.md new file mode 100644 index 0000000..8f1f918 --- /dev/null +++ b/docs/zh/Impl-TCP-Socket-zh.md @@ -0,0 +1,327 @@ +# 前言 + +如果底层TCP Socket库是非挂起的, 那么**不要**将IO读写操作使用`withContext`包裹, +这会导致在网络IO中频繁的切换线程导致程序性能**下降**. + +# 基于Netty的TCP Socket实现 + +```kotlin +dependencies { + implementation("io.netty:netty-transport:4.2.17.Final") + implementation("io.netty:netty-buffer:4.2.17.Final") +} +``` + +
+点击展开代码 + +```kotlin +import cn.rtast.libmc.network.RawSocket +import cn.rtast.libmc.network.ReadChannel +import cn.rtast.libmc.network.SocketEngine +import cn.rtast.libmc.network.WriteChannel +import io.netty.bootstrap.Bootstrap +import io.netty.buffer.ByteBuf +import io.netty.buffer.Unpooled +import io.netty.channel.* +import io.netty.channel.nio.NioIoHandler +import io.netty.channel.socket.SocketChannel +import io.netty.channel.socket.nio.NioSocketChannel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import io.netty.channel.Channel as NettyChannel +import kotlinx.coroutines.channels.Channel as KotlinChannel + +public class NettyNetworkSocketEngine : SocketEngine { + override fun create(host: String, port: Int): RawSocket = NettyNetworkSocket(host, port) + + private class NettyNetworkSocket(private val host: String, private val port: Int) : RawSocket { + private val workerGroup: EventLoopGroup = MultiThreadIoEventLoopGroup(0, NioIoHandler.newFactory()) + private lateinit var channel: NettyChannel + private val inboundQueue = KotlinChannel(KotlinChannel.UNLIMITED) + private lateinit var readChannel: NettyReadChannel + private lateinit var writeChannel: NettyWriteChannel + + override suspend fun connect() { + val bootstrap = Bootstrap() + .group(workerGroup) + .channel(NioSocketChannel::class.java) + .option(ChannelOption.TCP_NODELAY, true) + .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 10000) + .handler(object : ChannelInitializer() { + override fun initChannel(ch: SocketChannel) { + ch.pipeline().addLast(object : ChannelInboundHandlerAdapter() { + override fun channelRead(ctx: ChannelHandlerContext, msg: Any) { + if (msg is ByteBuf) { + try { + val bytes = ByteArray(msg.readableBytes()) + msg.readBytes(bytes) + inboundQueue.trySend(bytes) + } finally { + msg.release() + } + } + } + + override fun exceptionCaught(ctx: ChannelHandlerContext, cause: Throwable) { + inboundQueue.close(cause) + ctx.close() + } + + override fun channelInactive(ctx: ChannelHandlerContext) { + inboundQueue.close() + super.channelInactive(ctx) + } + }) + } + }) + + channel = withContext(Dispatchers.IO) { + bootstrap.connect(host, port).suspendAwait() + } + + readChannel = NettyReadChannel(inboundQueue) + writeChannel = NettyWriteChannel(channel) + } + + override fun openReadChannel(): ReadChannel = readChannel + override fun openWriteChannel(): WriteChannel = writeChannel + + override fun close() { + if (::channel.isInitialized && channel.isOpen) { + channel.close() + } + inboundQueue.close() + workerGroup.shutdownGracefully() + } + } + + private class NettyReadChannel(private val inboundQueue: KotlinChannel) : ReadChannel { + private var currentChunk: ByteArray? = null + private var chunkOffset = 0 + + private suspend fun fetchNextChunk() { + val next = inboundQueue.receiveCatching().getOrNull() + ?: throw IllegalStateException("Socket/Channel closed while reading") + currentChunk = next + chunkOffset = 0 + } + + override suspend fun readByte(): Byte { + while (currentChunk == null || chunkOffset >= currentChunk!!.size) { + fetchNextChunk() + } + val chunk = currentChunk!! + return chunk[chunkOffset++] + } + + override suspend fun readBytes(length: Int): ByteArray { + val result = ByteArray(length) + readFully(result, 0, length) + return result + } + + override suspend fun readFully(out: ByteArray, start: Int, end: Int) { + var written = start + + while (written < end) { + while (currentChunk == null || chunkOffset >= currentChunk!!.size) { + fetchNextChunk() + } + val chunk = currentChunk!! + val available = chunk.size - chunkOffset + val toCopy = minOf(available, end - written) + + chunk.copyInto( + out, + destinationOffset = written, + startIndex = chunkOffset, + endIndex = chunkOffset + toCopy + ) + chunkOffset += toCopy + written += toCopy + } + } + } + + private class NettyWriteChannel(private val channel: NettyChannel) : WriteChannel { + private val writeMutex = Mutex() + + override suspend fun writeFully(value: ByteArray, startIndex: Int, endIndex: Int) { + val length = endIndex - startIndex + if (length <= 0) return + + val nettyBuf = Unpooled.copiedBuffer(value, startIndex, length) + + writeMutex.withLock { + withContext(Dispatchers.IO) { + channel.writeAndFlush(nettyBuf).suspendAwait() + } + } + } + + override suspend fun flush() { + writeMutex.withLock { + withContext(Dispatchers.IO) { + channel.flush() + } + } + } + } + +} + +private suspend inline fun ChannelFuture.suspendAwait(): NettyChannel = suspendCancellableCoroutine { cont -> + if (isDone) { + if (isSuccess) cont.resume(channel()) + else cont.resumeWithException(cause() ?: RuntimeException("Netty operation failed")) + return@suspendCancellableCoroutine + } + + addListener { future -> + if (future.isSuccess) { + cont.resume(channel()) + } else { + cont.resumeWithException(future.cause() ?: RuntimeException("Netty operation failed")) + } + } + cont.invokeOnCancellation { cancel(false) } + +} +``` + +> 请一并复制import部分的代码 + +
+ +# 基于ktor-network的TCP Socket实现 + +```kotlin +dependencies { + implementation("io.ktor:ktor-network:3.5.2") +} +``` + +
+点击展开代码 + +```kotlin +public class KtorNetworkEngine : SocketEngine { + override fun create(host: String, port: Int): RawSocket = KtorNetworkSocket(host, port) +} + +public class KtorNetworkSocket(private val host: String, private val port: Int) : RawSocket { + private val sm = SelectorManager(Dispatchers.IO) + private lateinit var socket: Socket + + override suspend fun connect(): Unit = run { socket = aSocket(sm).tcp().connect(host, port) } + override fun openReadChannel(): ReadChannel = KtorReadChannel(socket.openReadChannel()) + override fun openWriteChannel(): WriteChannel = KtorWriteChannel(socket.openWriteChannel()) + override fun close() { + socket.close() + sm.close() + } +} + +public class KtorReadChannel(private val readChannel: ByteReadChannel) : ReadChannel { + override suspend fun readByte(): Byte = readChannel.readByte() + override suspend fun readBytes(length: Int): ByteArray = readChannel.readByteArray(length) + override suspend fun readFully(out: ByteArray, start: Int, end: Int): Unit = + readChannel.readFully(out, start, end) +} + +public class KtorWriteChannel(private val writeChannel: ByteWriteChannel) : WriteChannel { + override suspend fun writeFully(value: ByteArray, startIndex: Int, endIndex: Int) { + writeChannel.writeFully(value, startIndex, endIndex) + } + + override suspend fun flush(): Unit = writeChannel.flush() +} +``` + +
+ +# 基于Java内置Socket的TCP Socket实现 + +
+点击展开代码 + +```kotlin +class JavaSocketEngine : SocketEngine { + override fun create(host: String, port: Int): RawSocket = JavaNetworkSocket(host, port) +} + +class JavaNetworkSocket( + private val host: String, + private val port: Int, + private val connectTimeoutMs: Int = 10000, +) : RawSocket { + private lateinit var socket: Socket + private lateinit var readChannel: JavaReadChannel + private lateinit var writeChannel: JavaWriteChannel + + override suspend fun connect() { + val s = Socket() + s.tcpNoDelay = true + s.connect(InetSocketAddress(host, port), connectTimeoutMs) + + socket = s + readChannel = JavaReadChannel(s.getInputStream()) + writeChannel = JavaWriteChannel(s.getOutputStream()) + } + + override fun openReadChannel(): ReadChannel = readChannel + override fun openWriteChannel(): WriteChannel = writeChannel + + override fun close() { + if (::socket.isInitialized && !socket.isClosed) { + runCatching { socket.close() } + } + } +} + +class JavaReadChannel(private val inputStream: InputStream) : ReadChannel { + override suspend fun readByte(): Byte { + val b = inputStream.read() + if (b == -1) throw IllegalStateException("Socket stream reached EOF while reading byte") + return b.toByte() + } + + override suspend fun readBytes(length: Int): ByteArray { + val buffer = ByteArray(length) + readFullyInternal(buffer, 0, length) + return buffer + } + + override suspend fun readFully(out: ByteArray, start: Int, end: Int) { + readFullyInternal(out, start, end - start) + } + + private fun readFullyInternal(out: ByteArray, offset: Int, length: Int) { + var bytesRead = 0 + while (bytesRead < length) { + val count = inputStream.read(out, offset + bytesRead, length - bytesRead) + if (count == -1) { + throw IllegalStateException("Socket stream closed unexpectedly (read $bytesRead of $length bytes)") + } + bytesRead += count + } + } +} + +class JavaWriteChannel(private val outputStream: OutputStream) : WriteChannel { + override suspend fun writeFully(value: ByteArray, startIndex: Int, endIndex: Int) { + outputStream.write(value, startIndex, endIndex - startIndex) + } + + override suspend fun flush() { + outputStream.flush() + } +} +``` +
\ No newline at end of file diff --git a/docs/zh/README-zh.md b/docs/zh/README-zh.md new file mode 100644 index 0000000..d90d851 --- /dev/null +++ b/docs/zh/README-zh.md @@ -0,0 +1,28 @@ +# libmc + +一个轻量级适用于Kotlin Native & Jvm的现代Minecraft客户端协议库, 核心协议库模块使用了以下依赖 + +- 标准库 (`kotlin-stdlib`) +- IO (`kotlinx-io`) - 高效的将每个数据包封装为一个Buffer +- 协程库 (`kotlinx-coroutines`) - 实现高性能异步操作 + +## 需要实现的API + +| 名称 | 是否必须实现 | 描述 | +|:-------------|:-------------|:-----------------------------------------------------------------------------------------------------------------------------------------------| +| TCP Socket | 是 | `protocol` 模块没有内置TCP Socket实现. [实现 TCP Socket](Impl-TCP-Socket-zh.md) | +| AES-128-CFB8 | 视情况而定 | 仅在登录开启了正版验证(`online-mode`)的服务器时需要, 用于加密/解密流量. [实现AES-128-CFB8](Impl-Crypto-zh.md#AES-128-CFB8) | +| RSA 1024 | 视情况而定 | 仅在登录开启了正版验证(`online-mode`)的服务器时需要, 用于对服务器下发的公钥进行签名. [实现RSA 1024](Impl-Crypto-zh.md#RSA1024) | +| SHA1 | 视情况而定 | 仅在登录开启了正版验证(`online-mode`)的服务器时需要, 用于计算服务器的ServerId. [实现SHA1](Impl-Crypto-zh.md#SHA1) | +| HTTP 客户端 | 视情况而定 | 仅在登录开启了正版验证(`online-mode`)的服务器时需要, 用于向Mojang的Session服务器发送加入服务器的请求 [实现HTTP 客户端](Impl-HTTP-Client-zh.md) | + +# 开始使用 + +> `libmc-protocol`目前正在开发中, 也许存在着某些未被发现的问题. +> 它仅支持最新的Minecraft版本 (目前支持的游戏版本: `26.2`, 协议版本: `776`) + +[开始使用libmc-protocol](Get-started-zh.md) + +# 将实现的API组合起来 + +[将所有上下文API组合](Assemble-context-zh.md) \ No newline at end of file diff --git a/example/network-engines/JavaSocketNetworkEngine.kt b/example/network-engines/JavaSocketNetworkEngine.kt new file mode 100644 index 0000000..175caae --- /dev/null +++ b/example/network-engines/JavaSocketNetworkEngine.kt @@ -0,0 +1,89 @@ +/* + * Copyright © 2026 RTAkland + * Author: RTAkland + * Date: 2026/9/7 + */ + + +package engines + +import cn.rtast.libmc.network.RawSocket +import cn.rtast.libmc.network.ReadChannel +import cn.rtast.libmc.network.SocketEngine +import cn.rtast.libmc.network.WriteChannel +import java.io.InputStream +import java.io.OutputStream +import java.net.InetSocketAddress +import java.net.Socket + +class JavaSocketEngine : SocketEngine { + override fun create(host: String, port: Int): RawSocket = JavaNetworkSocket(host, port) +} + +class JavaNetworkSocket( + private val host: String, + private val port: Int, + private val connectTimeoutMs: Int = 10000, +) : RawSocket { + private lateinit var socket: Socket + private lateinit var readChannel: JavaReadChannel + private lateinit var writeChannel: JavaWriteChannel + + override suspend fun connect() { + val s = Socket() + s.tcpNoDelay = true + s.connect(InetSocketAddress(host, port), connectTimeoutMs) + + socket = s + readChannel = JavaReadChannel(s.getInputStream()) + writeChannel = JavaWriteChannel(s.getOutputStream()) + } + + override fun openReadChannel(): ReadChannel = readChannel + override fun openWriteChannel(): WriteChannel = writeChannel + + override fun close() { + if (::socket.isInitialized && !socket.isClosed) { + runCatching { socket.close() } + } + } +} + +class JavaReadChannel(private val inputStream: InputStream) : ReadChannel { + override suspend fun readByte(): Byte { + val b = inputStream.read() + if (b == -1) throw IllegalStateException("Socket stream reached EOF while reading byte") + return b.toByte() + } + + override suspend fun readBytes(length: Int): ByteArray { + val buffer = ByteArray(length) + readFullyInternal(buffer, 0, length) + return buffer + } + + override suspend fun readFully(out: ByteArray, start: Int, end: Int) { + readFullyInternal(out, start, end - start) + } + + private fun readFullyInternal(out: ByteArray, offset: Int, length: Int) { + var bytesRead = 0 + while (bytesRead < length) { + val count = inputStream.read(out, offset + bytesRead, length - bytesRead) + if (count == -1) { + throw IllegalStateException("Socket stream closed unexpectedly (read $bytesRead of $length bytes)") + } + bytesRead += count + } + } +} + +class JavaWriteChannel(private val outputStream: OutputStream) : WriteChannel { + override suspend fun writeFully(value: ByteArray, startIndex: Int, endIndex: Int) { + outputStream.write(value, startIndex, endIndex - startIndex) + } + + override suspend fun flush() { + outputStream.flush() + } +} \ No newline at end of file diff --git a/example/network-engines/KtorNetworkSocketEngine.kt b/example/network-engines/KtorNetworkSocketEngine.kt new file mode 100644 index 0000000..1e744de --- /dev/null +++ b/example/network-engines/KtorNetworkSocketEngine.kt @@ -0,0 +1,41 @@ +/* + * Copyright © 2026 RTAkland + * Author: RTAkland + * Date: 2026/9/8 + */ + + +package engines + + +public class KtorNetworkEngine : SocketEngine { + override fun create(host: String, port: Int): RawSocket = KtorNetworkSocket(host, port) +} + +public class KtorNetworkSocket(private val host: String, private val port: Int) : RawSocket { + private val sm = SelectorManager(Dispatchers.IO) + private lateinit var socket: Socket + + override suspend fun connect(): Unit = run { socket = aSocket(sm).tcp().connect(host, port) } + override fun openReadChannel(): ReadChannel = KtorReadChannel(socket.openReadChannel()) + override fun openWriteChannel(): WriteChannel = KtorWriteChannel(socket.openWriteChannel()) + override fun close() { + socket.close() + sm.close() + } +} + +public class KtorReadChannel(private val readChannel: ByteReadChannel) : ReadChannel { + override suspend fun readByte(): Byte = readChannel.readByte() + override suspend fun readBytes(length: Int): ByteArray = readChannel.readByteArray(length) + override suspend fun readFully(out: ByteArray, start: Int, end: Int): Unit = + readChannel.readFully(out, start, end) +} + +public class KtorWriteChannel(private val writeChannel: ByteWriteChannel) : WriteChannel { + override suspend fun writeFully(value: ByteArray, startIndex: Int, endIndex: Int) { + writeChannel.writeFully(value, startIndex, endIndex) + } + + override suspend fun flush(): Unit = writeChannel.flush() +} \ No newline at end of file diff --git a/example/network-engines/netty/src/jvmMain/kotlin/engines/NettyNetworkSocketEngine.kt b/example/network-engines/NettyNetworkSocketEngine.kt similarity index 100% rename from example/network-engines/netty/src/jvmMain/kotlin/engines/NettyNetworkSocketEngine.kt rename to example/network-engines/NettyNetworkSocketEngine.kt diff --git a/example/network-engines/ktor-network/build.gradle.kts b/example/network-engines/ktor-network/build.gradle.kts deleted file mode 100644 index 571df6c..0000000 --- a/example/network-engines/ktor-network/build.gradle.kts +++ /dev/null @@ -1,19 +0,0 @@ -import org.jetbrains.kotlin.gradle.dsl.JvmTarget - -kotlin { - explicitApi() - withSourcesJar() - - linuxX64() - linuxArm64() - macosArm64() - mingwX64() - jvm { compilerOptions.jvmTarget = JvmTarget.JVM_1_8 } - - sourceSets { - jvmMain.dependencies { - api(project(":protocol")) - api(libs.ktor.network) - } - } -} \ No newline at end of file diff --git a/example/network-engines/ktor-network/src/commonMain/kotlin/engines/KtorNetworkSocketEngine.kt b/example/network-engines/ktor-network/src/commonMain/kotlin/engines/KtorNetworkSocketEngine.kt deleted file mode 100644 index b1aeb28..0000000 --- a/example/network-engines/ktor-network/src/commonMain/kotlin/engines/KtorNetworkSocketEngine.kt +++ /dev/null @@ -1,10 +0,0 @@ -/* - * Copyright © 2026 RTAkland - * Author: RTAkland - * Date: 2026/9/8 - */ - - -package engines - - diff --git a/example/network-engines/netty/build.gradle.kts b/example/network-engines/netty/build.gradle.kts deleted file mode 100644 index 3193ef1..0000000 --- a/example/network-engines/netty/build.gradle.kts +++ /dev/null @@ -1,16 +0,0 @@ -import org.jetbrains.kotlin.gradle.dsl.JvmTarget - -kotlin { - explicitApi() - withSourcesJar() - - jvm { compilerOptions.jvmTarget = JvmTarget.JVM_1_8 } - - sourceSets { - jvmMain.dependencies { - api(project(":protocol")) - api(libs.netty.handler) - api(libs.netty.buffer) - } - } -} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index af4cc30..aad749b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,6 @@ [versions] kotlin = "2.4.10" kotlinx-io = "0.9.1" -ktor-network = "3.5.2" coroutines-test = "1.11.0" kotlinx-coroutines = "1.11.0" ktor-core = "3.5.2" @@ -10,7 +9,7 @@ netty = "4.2.17.Final" [libraries] kotlinx-io = { module = "org.jetbrains.kotlinx:kotlinx-io-core", version.ref = "kotlinx-io" } -ktor-network = { module = "io.ktor:ktor-network", version.ref = "ktor-network" } +ktor-network = { module = "io.ktor:ktor-network", version.ref = "ktor-core" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines-test" } kotlinx-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } cryptography-provider-optimal = { module = "dev.whyoleg.cryptography:cryptography-provider-optimal", version.ref = "cryptography-core" } diff --git a/libmc-common/src/commonMain/kotlin/cn/rtast/libmc/crypto/ProtocolContext.kt b/libmc-common/src/commonMain/kotlin/cn/rtast/libmc/crypto/ProtocolContext.kt index 6e9d499..2881c57 100644 --- a/libmc-common/src/commonMain/kotlin/cn/rtast/libmc/crypto/ProtocolContext.kt +++ b/libmc-common/src/commonMain/kotlin/cn/rtast/libmc/crypto/ProtocolContext.kt @@ -11,9 +11,9 @@ import cn.rtast.libmc.network.SocketContext import cn.rtast.libmc.network.SocketEngine public data class ProtocolContext( - val rsaEncryptor: RSA1024Encryptor, - val sha1Hasher: Sha1Hasher, - val cipherFactory: (sharedKey: ByteArray) -> NetworkCipher, + val rsaEncryptor: RSA1024Encryptor?, + val sha1Hasher: Sha1Hasher?, + val cipherFactory: ((sharedKey: ByteArray) -> NetworkCipher)?, val authProvider: AuthenticationProvider?, override val engine: SocketEngine, ) : SocketContext() @@ -27,12 +27,22 @@ public class ProtocolContextBuilder(private val onlineMode: Boolean) { public fun build(): ProtocolContext = ProtocolContext( - rsaEncryptor = if (::rsaEncryptor.isInitialized) rsaEncryptor else error("rsaEncryptor is required"), - sha1Hasher = if (::sha1Hasher.isInitialized) sha1Hasher else error("sha1Hasher is required"), - cipherFactory = if (::cipherFactory.isInitialized) cipherFactory else error("cipherFactory is required"), + rsaEncryptor = if (onlineMode) { + if (::rsaEncryptor.isInitialized) rsaEncryptor else error("rsaEncryptor is required in online mode") + } else if (::rsaEncryptor.isInitialized) rsaEncryptor else null, + + sha1Hasher = if (onlineMode) { + if (::sha1Hasher.isInitialized) sha1Hasher else error("sha1Hasher is required in online mode") + } else if (::sha1Hasher.isInitialized) sha1Hasher else null, + + cipherFactory = if (onlineMode) { + if (::cipherFactory.isInitialized) cipherFactory else error("cipherFactory is required in online mode") + } else if (::cipherFactory.isInitialized) cipherFactory else null, + authProvider = if (onlineMode) { if (::authProvider.isInitialized) authProvider else error("authProvider is required in online mode") } else if (::authProvider.isInitialized) authProvider else null, + engine = if (::socketEngine.isInitialized) socketEngine else error("SocketEngine is not configured") ) } diff --git a/libmc-protocol-context/src/commonMain/kotlin/cn/rtast/libmc/protocol/context/DefaultProtocolCryptoContext.kt b/libmc-protocol-context/src/commonMain/kotlin/cn/rtast/libmc/protocol/context/DefaultProtocolCryptoContext.kt index a32d521..a959c39 100644 --- a/libmc-protocol-context/src/commonMain/kotlin/cn/rtast/libmc/protocol/context/DefaultProtocolCryptoContext.kt +++ b/libmc-protocol-context/src/commonMain/kotlin/cn/rtast/libmc/protocol/context/DefaultProtocolCryptoContext.kt @@ -15,7 +15,7 @@ import io.ktor.client.* import io.ktor.client.request.* import io.ktor.http.* -public val httpClient: HttpClient = HttpClient() +private val httpClient: HttpClient = HttpClient() public val DefaultProtocolContext: ProtocolContextBuilder.() -> Unit = { rsaEncryptor = RSA1024Encryptor { key, data -> rsaEncrypt(key, data) } diff --git a/libmc-protocol/src/commonMain/kotlin/cn/rtast/libmc/protocol/client/MinecraftClient.kt b/libmc-protocol/src/commonMain/kotlin/cn/rtast/libmc/protocol/client/MinecraftClient.kt index f6e873d..10ef5ab 100644 --- a/libmc-protocol/src/commonMain/kotlin/cn/rtast/libmc/protocol/client/MinecraftClient.kt +++ b/libmc-protocol/src/commonMain/kotlin/cn/rtast/libmc/protocol/client/MinecraftClient.kt @@ -29,27 +29,23 @@ public class MinecraftClient internal constructor( internal val accessToken: String?, parentJob: Job?, private val ioDispatcher: CoroutineDispatcher, - protocolContext: ProtocolContext, + internal val protocolContext: ProtocolContext, ) : PacketEventDispatcher(), CoroutineScope { - internal val rsa1024Encryptor = protocolContext.rsaEncryptor - internal val serverIdHasher = protocolContext.sha1Hasher - internal val authProvider = protocolContext.authProvider internal val stateMachine = ClientStateMachine() public val networkChannel: NetworkChannel = NetworkChannel( host, port, stateMachine, - protocolContext.cipherFactory, this, protocolContext ) - private val internalPacketDispatcher = InternalPacketDispatcher(this, authProvider) + private val internalPacketDispatcher = InternalPacketDispatcher(this) private val clientJob = SupervisorJob(parentJob) private var listenJob: Job? = null - public val isOnlineMode: Boolean get() = accessToken != null + public val isOnlineMode: Boolean = accessToken != null public val transactionManager: TransactionIdManager = TransactionIdManager() - public suspend fun connect(protocolVersion: Int = 776) { + public suspend fun connect(protocolVersion: Int = CURRENT_MINECRAFT_PROTOCOL_VERSION) { networkChannel.connect() startListening() networkChannel.sendPacket( diff --git a/libmc-protocol/src/commonMain/kotlin/cn/rtast/libmc/protocol/client/MinecraftProtocolVersion.kt b/libmc-protocol/src/commonMain/kotlin/cn/rtast/libmc/protocol/client/MinecraftProtocolVersion.kt new file mode 100644 index 0000000..9893251 --- /dev/null +++ b/libmc-protocol/src/commonMain/kotlin/cn/rtast/libmc/protocol/client/MinecraftProtocolVersion.kt @@ -0,0 +1,10 @@ +/* + * Copyright © 2026 RTAkland + * Author: RTAkland + * Date: 2026/9/8 + */ + + +package cn.rtast.libmc.protocol.client + +internal const val CURRENT_MINECRAFT_PROTOCOL_VERSION: Int = 776 diff --git a/libmc-protocol/src/commonMain/kotlin/cn/rtast/libmc/protocol/event/InternalPacketDispatcher.kt b/libmc-protocol/src/commonMain/kotlin/cn/rtast/libmc/protocol/event/InternalPacketDispatcher.kt index 2127a14..7bd8807 100644 --- a/libmc-protocol/src/commonMain/kotlin/cn/rtast/libmc/protocol/event/InternalPacketDispatcher.kt +++ b/libmc-protocol/src/commonMain/kotlin/cn/rtast/libmc/protocol/event/InternalPacketDispatcher.kt @@ -7,7 +7,6 @@ package cn.rtast.libmc.protocol.event -import cn.rtast.libmc.crypto.AuthenticationProvider import cn.rtast.libmc.packet.MinecraftPacket import cn.rtast.libmc.protocol.client.MinecraftClient import cn.rtast.libmc.protocol.packet.configuration.clientbound.* @@ -33,10 +32,7 @@ import cn.rtast.libmc.protocol.util.generateRandom16Bytes * Auto respond packets the server needed. * Only including `Handshake`, `Login` and `Configuration` State */ -public class InternalPacketDispatcher( - private val client: MinecraftClient, - private val authProvider: AuthenticationProvider?, -) { +public class InternalPacketDispatcher(private val client: MinecraftClient) { public suspend fun handleIncomingPackets(packet: MinecraftPacket) { when (packet) { // login @@ -50,16 +46,18 @@ public class InternalPacketDispatcher( is ClientboundHelloPacket -> { val sharedSecret = generateRandom16Bytes() if (client.isOnlineMode) { - val serverHash = client.serverIdHasher.hash(packet.serverId, sharedSecret, packet.publicKey) - authProvider!!.joinServer( + val serverHash = client.protocolContext.sha1Hasher!! + .hash(packet.serverId, sharedSecret, packet.publicKey) + client.protocolContext.authProvider!!.joinServer( "https://sessionserver.mojang.com/session/minecraft/join", client.accessToken!!, client.uuid.toString().replace("-", ""), serverHash ) } - val encryptedSecret = client.rsa1024Encryptor.encrypt(packet.publicKey, sharedSecret) - val encryptedVerifyToken = client.rsa1024Encryptor.encrypt(packet.publicKey, packet.verifyToken) + val encryptedSecret = client.protocolContext.rsaEncryptor!!.encrypt(packet.publicKey, sharedSecret) + val encryptedVerifyToken = + client.protocolContext.rsaEncryptor!!.encrypt(packet.publicKey, packet.verifyToken) client.networkChannel.sendPacket(ServerboundKeyPacket(encryptedSecret, encryptedVerifyToken)) client.networkChannel.session.enableEncryption(sharedSecret) } diff --git a/libmc-protocol/src/commonMain/kotlin/cn/rtast/libmc/protocol/network/NetworkChannel.kt b/libmc-protocol/src/commonMain/kotlin/cn/rtast/libmc/protocol/network/NetworkChannel.kt index a0d53da..1b1ebb0 100644 --- a/libmc-protocol/src/commonMain/kotlin/cn/rtast/libmc/protocol/network/NetworkChannel.kt +++ b/libmc-protocol/src/commonMain/kotlin/cn/rtast/libmc/protocol/network/NetworkChannel.kt @@ -26,11 +26,10 @@ public class NetworkChannel internal constructor( host: String, port: Int, private val stateMachine: ClientStateMachine, - cipherProvider: (ByteArray) -> NetworkCipher, private val dispatcher: PacketEventDispatcher, protocolContext: ProtocolContext, ) { - internal val session: NetworkSession = NetworkSession(host, port, cipherProvider, protocolContext) + internal val session: NetworkSession = NetworkSession(host, port, protocolContext) @Volatile private var threshold = -1 diff --git a/libmc-protocol/src/commonMain/kotlin/cn/rtast/libmc/protocol/network/NetworkSession.kt b/libmc-protocol/src/commonMain/kotlin/cn/rtast/libmc/protocol/network/NetworkSession.kt index 790ba2a..94b0e86 100644 --- a/libmc-protocol/src/commonMain/kotlin/cn/rtast/libmc/protocol/network/NetworkSession.kt +++ b/libmc-protocol/src/commonMain/kotlin/cn/rtast/libmc/protocol/network/NetworkSession.kt @@ -6,7 +6,6 @@ package cn.rtast.libmc.protocol.network -import cn.rtast.libmc.crypto.NetworkCipher import cn.rtast.libmc.crypto.ProtocolContext import cn.rtast.libmc.network.RawSocket import cn.rtast.libmc.network.ReadChannel @@ -16,7 +15,6 @@ import cn.rtast.libmc.primitives.readVarInt public class NetworkSession internal constructor( private val host: String, private val port: Int, - private var cipherProvider: (ByteArray) -> NetworkCipher, private val context: ProtocolContext, ) { private var socket: RawSocket? = null @@ -38,7 +36,7 @@ public class NetworkSession internal constructor( public fun enableEncryption(sharedKey: ByteArray) { val currentRead = requireNotNull(readChannel) val currentWrite = requireNotNull(writeChannel) - val cipher = cipherProvider(sharedKey) + val cipher = context.cipherFactory!!.invoke(sharedKey) this.readChannel = CipherReadChannel(currentRead, cipher) this.writeChannel = CipherWriteChannel(currentWrite, cipher) } diff --git a/libmc-protocol/src/commonMain/kotlin/cn/rtast/libmc/protocol/util/TransactionIdManager.kt b/libmc-protocol/src/commonMain/kotlin/cn/rtast/libmc/protocol/util/TransactionIdManager.kt index f14548f..e4d775d 100644 --- a/libmc-protocol/src/commonMain/kotlin/cn/rtast/libmc/protocol/util/TransactionIdManager.kt +++ b/libmc-protocol/src/commonMain/kotlin/cn/rtast/libmc/protocol/util/TransactionIdManager.kt @@ -10,6 +10,10 @@ package cn.rtast.libmc.protocol.util import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +/** + * Client-side managed transaction id manager, + * managed an auto-increment transaction id + */ public class TransactionIdManager internal constructor() { private var queryTransactionCounter: Int = 1 private var commandSuggestionTransactionCounter: Int = 1 diff --git a/libmc-protocol/src/jvmTest/kotlin/test/JavaSocketEngine.kt b/libmc-protocol/src/jvmTest/kotlin/test/JavaSocketEngine.kt new file mode 100644 index 0000000..8983850 --- /dev/null +++ b/libmc-protocol/src/jvmTest/kotlin/test/JavaSocketEngine.kt @@ -0,0 +1,89 @@ +/* + * Copyright © 2026 RTAkland + * Author: RTAkland + * Date: 2026/9/7 + */ + + +package test + +import cn.rtast.libmc.network.RawSocket +import cn.rtast.libmc.network.ReadChannel +import cn.rtast.libmc.network.SocketEngine +import cn.rtast.libmc.network.WriteChannel +import java.io.InputStream +import java.io.OutputStream +import java.net.InetSocketAddress +import java.net.Socket + +class JavaSocketEngine : SocketEngine { + override fun create(host: String, port: Int): RawSocket = JavaNetworkSocket(host, port) +} + +class JavaNetworkSocket( + private val host: String, + private val port: Int, + private val connectTimeoutMs: Int = 10000, +) : RawSocket { + private lateinit var socket: Socket + private lateinit var readChannel: JavaReadChannel + private lateinit var writeChannel: JavaWriteChannel + + override suspend fun connect() { + val s = Socket() + s.tcpNoDelay = true + s.connect(InetSocketAddress(host, port), connectTimeoutMs) + + socket = s + readChannel = JavaReadChannel(s.getInputStream()) + writeChannel = JavaWriteChannel(s.getOutputStream()) + } + + override fun openReadChannel(): ReadChannel = readChannel + override fun openWriteChannel(): WriteChannel = writeChannel + + override fun close() { + if (::socket.isInitialized && !socket.isClosed) { + runCatching { socket.close() } + } + } +} + +class JavaReadChannel(private val inputStream: InputStream) : ReadChannel { + override suspend fun readByte(): Byte { + val b = inputStream.read() + if (b == -1) throw IllegalStateException("Socket stream reached EOF while reading byte") + return b.toByte() + } + + override suspend fun readBytes(length: Int): ByteArray { + val buffer = ByteArray(length) + readFullyInternal(buffer, 0, length) + return buffer + } + + override suspend fun readFully(out: ByteArray, start: Int, end: Int) { + readFullyInternal(out, start, end - start) + } + + private fun readFullyInternal(out: ByteArray, offset: Int, length: Int) { + var bytesRead = 0 + while (bytesRead < length) { + val count = inputStream.read(out, offset + bytesRead, length - bytesRead) + if (count == -1) { + throw IllegalStateException("Socket stream closed unexpectedly (read $bytesRead of $length bytes)") + } + bytesRead += count + } + } +} + +class JavaWriteChannel(private val outputStream: OutputStream) : WriteChannel { + override suspend fun writeFully(value: ByteArray, startIndex: Int, endIndex: Int) { + outputStream.write(value, startIndex, endIndex - startIndex) + } + + override suspend fun flush() { + outputStream.flush() + } +} \ No newline at end of file diff --git a/libmc-protocol/src/jvmTest/kotlin/test/TestClientTestInJvm.kt b/libmc-protocol/src/jvmTest/kotlin/test/TestClientTestInJvm.kt index b56e515..c48a396 100644 --- a/libmc-protocol/src/jvmTest/kotlin/test/TestClientTestInJvm.kt +++ b/libmc-protocol/src/jvmTest/kotlin/test/TestClientTestInJvm.kt @@ -17,11 +17,6 @@ import cn.rtast.libmc.protocol.util.generateOfflineUuid import kotlinx.coroutines.launch import org.junit.Test import java.io.File -import java.math.BigInteger -import java.security.KeyFactory -import java.security.MessageDigest -import java.security.spec.X509EncodedKeySpec -import javax.crypto.Cipher import kotlin.random.Random import kotlin.time.Clock import kotlin.uuid.Uuid @@ -31,43 +26,14 @@ class TestClientTestInJvm { val accessToken = File("src/jvmTest/resources/accessToken.txt").readText() private val chatTracker = ClientChatTracker() - fun encrypt(publicKeyBytes: ByteArray, data: ByteArray): ByteArray { - val keySpec = X509EncodedKeySpec(publicKeyBytes) - val keyFactory = KeyFactory.getInstance("RSA") - val publicKey = keyFactory.generatePublic(keySpec) - val cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding") - cipher.init(Cipher.ENCRYPT_MODE, publicKey) - return cipher.doFinal(data) - } - - fun minecraftServerIdHash(serverId: String, secretKey: ByteArray, publicKey: ByteArray): String { - val serverIdBytes: ByteArray = serverId.encodeToByteArray() - for (b in serverIdBytes) { - require((b.toInt() and 0xFF) <= 0x7F) { "serverId contains non-US-ASCII character" } - } - val data = ByteArray(serverIdBytes.size + secretKey.size + publicKey.size) - System.arraycopy(serverIdBytes, 0, data, 0, serverIdBytes.size) - System.arraycopy(secretKey, 0, data, serverIdBytes.size, secretKey.size) - System.arraycopy(publicKey, 0, data, serverIdBytes.size + secretKey.size, publicKey.size) - val digest = MessageDigest.getInstance("SHA-1") - val hash = digest.digest(data) - return BigInteger(hash).toString(16) - } - @Test fun `test client`() { val cli = createMinecraftClient( - "127.0.0.1", - 25565, - "RTAkland", -// generateOfflineUuid("RTAkland"), + "127.0.0.1", 25565, "RTAkland", Uuid.parse("bb033844-e68e-4909-a636-1a5d1821ddc4"), -// null, accessToken, context = DefaultProtocolContext ) -// cli.onPacket { println(it) } -// cli.onPacket { println(it) } cli.on { packet, direction -> println("$direction -> $packet") } cli.launch { cli.connect() } while (true) { @@ -77,11 +43,8 @@ class TestClientTestInJvm { @Test fun `test client offline mode`() { val cli = createMinecraftClient( - "127.0.0.1", - 25566, - "11", - generateOfflineUuid("11"), - null, + "127.0.0.1", 25566, "11", + generateOfflineUuid("11"), null, context = DefaultProtocolContext.withCustom { socketEngine = KtorNetworkEngine() }