From 250a0c3dd74b9b4c27710730c9cf56efacc16785 Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Wed, 16 Aug 2023 13:38:30 +0200 Subject: [PATCH 01/10] Add basic client class --- README.md | 75 +++++++++++++++ mcproto/client.py | 208 ++++++++++++++++++++++++++++++++++++++++++ mcproto/exceptions.py | 95 +++++++++++++++++++ 3 files changed, 378 insertions(+) create mode 100644 mcproto/client.py create mode 100644 mcproto/exceptions.py diff --git a/README.md b/README.md index 91fada4e..a3643530 100644 --- a/README.md +++ b/README.md @@ -333,3 +333,78 @@ async def get_status(ip: str, port: int) -> dict: ``` Well, that wasn't so hard, was it? + +### Using Client class for communication + +Now that you understand how packets work, let's take a look at a much nicer and easier way to do this. Note that it's +still very important that you understand the basics shown above, as even though this is the best way of using mcproto, +and by far the easiest, as a lot of the flows are already implemented for you, if you intent on writing any more +complex bot accounts or doing similar things, you will still need to understand how to work with packets on their own, +and where to learn what to send and when. + +```python + +import httpx + +from mcproto.client import Client +from mcproto.connection import TCPAsyncConnection +from mcproto.auth.account import Account +from mcproto.types.uuid import UUID + +HOST = "localhost" +PORT = 25565 + +MINECRAFT_USERNAME = "YourMinecraftUsername" + +# To get your UUID, go to: # https://api.mojang.com/users/profiles/minecraft/YourMinecraftUsername +MINECRAFT_UUID = UUID("YourMinecraftUUID") + +# This can be left empty for warez accounts, but if you want to connect to online mode servers, +# you will need to set this. See: https://mcproto.readthedocs.io/en/stable/usage/authentication/ +MINECRAFT_ACCESS_TOKEN = "" + + +account = Account(MINECRAFT_USERNAME, MINECRAFT_UUID, MINECRAFT_ACCESS_TOKEN) + + +async def main(): + async with httpx.AsyncClient() as client: + async with (await TCPAsyncConnection.make_client((HOST, PORT), 2)) as connection: + client = Client( + host="localhost", + port=25565, + client=client, + account=account, + conn=connection, + protocol_version=763, # 1.20.1 + ) + + # To request status, you can now simply do: + status_response = client.status() + + # `status_response` will now contain an instance of StatusResponse packet, + # so you can access the data just like in the above example, with `status_response.data` + + # In the back, the `status` function has performed a handshake to transition us from + # the initial (None) game state, to the STATUS game state, and then sent a status + # request, getting back a response. + + # The Client instance also includes a `login` function, which is capable to go through + # the entire login flow, leaving you in PLAY game state. Note that unless you've + # set MINECRAFT_ACCESS_TOKEN, you will only be able to do this for warez servers. + + # But since we just called `status`, it left us in the STATUS game state, but we need + # to be in LOGIN game state. The `login` function will work if called from an initial + # game state (None), as it's smart enough to perform a handshake getting us to LOGIN, + # however it doesn't know what to do from STATUS game state. + + # What we can do, is simply set game_state back to None (this is what happens during + # initialization of the Client class), making the login function send out another + # handshake, this time transitioning to LOGIN instead of STATUS. We could also create + # a completely new client instance. + client.game_state = None + + client.login() + + # Play state, yay! +``` diff --git a/mcproto/client.py b/mcproto/client.py new file mode 100644 index 00000000..a0e93a38 --- /dev/null +++ b/mcproto/client.py @@ -0,0 +1,208 @@ +from __future__ import annotations + +import httpx + +from mcproto.auth.account import Account +from mcproto.connection import TCPAsyncConnection +from mcproto.encryption import encrypt_token_and_secret, generate_shared_secret +from mcproto.exceptions import InvalidGameStateError, UnexpectedPacketError +from mcproto.multiplayer import compute_server_hash, join_request +from mcproto.packets.handshaking.handshake import Handshake, NextState +from mcproto.packets.interactions import async_read_packet, async_write_packet +from mcproto.packets.login.login import ( + LoginDisconnect, + LoginEncryptionRequest, + LoginEncryptionResponse, + LoginSetCompression, + LoginStart, + LoginSuccess, +) +from mcproto.packets.packet import ClientBoundPacket, GameState, PacketDirection, ServerBoundPacket +from mcproto.packets.packet_map import generate_packet_map +from mcproto.packets.status.status import StatusRequest, StatusResponse + + +class Client: + """Class representing the client, capable of connecting to the server. + + This class holds the logic for all client interactions/flows, is aware if the current + game state, packet compression, encryption, etc. + """ + + __slots__ = ( + "host", + "port", + "client", + "account", + "conn", + "protocol_version", + "game_state", + "packet_compression_threshold", + ) + + def __init__( # noqa: PLR0913 + self, + host: str, + port: int, + client: httpx.AsyncClient, + account: Account, + conn: TCPAsyncConnection, + protocol_version: int, + game_state: GameState | None = None, + packet_compression_threshold: int = -1, + ) -> None: + self.host = host + self.port = port + self.client = client + self.account = account + self.conn = conn + self.protocol_version = protocol_version + self.game_state = game_state + self.packet_compression_threshold = packet_compression_threshold + + async def _write_packet(self, packet: ServerBoundPacket) -> None: + """Write a packet to the connection. + + This sends the given ``packet`` to the server, respecting the current configuration + (compression threshold, encryption, ...) + """ + await async_write_packet(self.conn, packet, compression_threshold=self.packet_compression_threshold) + + async def _read_packet(self) -> ClientBoundPacket: + """Read a packet from the connection. + + This receives a packet from the server, resolving it based on the current configuration + (using a packet map for current game state, compression threshold, encryption, ...) + """ + if self.game_state is None: + raise InvalidGameStateError( + "Receiving packet failed", + expected=tuple(state for state in GameState.__members__.values()), # Any non-None game state + found=self.game_state, # None + ) + + packet_map = generate_packet_map(PacketDirection.CLIENTBOUND, self.game_state) + return await async_read_packet( + self.conn, + packet_map, + compression_threshold=self.packet_compression_threshold, + ) + + async def _handshake(self, next_state: NextState) -> None: + """Send the handshake packet, transitioning us to ``next_state``.""" + if self.game_state is not None: + raise InvalidGameStateError("Sending handshake failed", expected=None, found=self.game_state) + + packet = Handshake( + protocol_version=self.protocol_version, + server_address=self.host, + server_port=self.port, + next_state=next_state, + ) + await self._write_packet(packet) + self.game_state = GameState.STATUS if next_state is NextState.STATUS else GameState.LOGIN + + async def status(self) -> StatusResponse: + """Obtain status data from the server. + + This goes through the status flow, obtaining back a status response packet. + """ + if self.game_state is None: + await self._handshake(NextState.STATUS) + + if self.game_state is not GameState.STATUS: + raise InvalidGameStateError("Requesting status failed", expected=GameState.STATUS, found=self.game_state) + + packet = StatusRequest() + await self._write_packet(packet) + + recv_packet = await self._read_packet() + if not isinstance(recv_packet, StatusResponse): + raise UnexpectedPacketError("Receiving status response failed", expected=StatusResponse, found=recv_packet) + + return recv_packet + + async def _handle_encryption_request(self, packet: LoginEncryptionRequest) -> None: + """Handle receiving the :class:`mcproto.packets.login.login.LoginEncryptionRequest` packet. + + This will create a new shared secret for symmetric AES/CFB8 encryption, send it back to + the server encrypted using it's public key from the ``LoginEncryptionRequest`` packet. + + This allows the server to safely receive our randomly generated shared secret, and as + both sides now have the same encryption key, encryption is enabled. All further + communication will be encrypted. + """ + shared_secret = generate_shared_secret() + + # If the server isn't in offline mode (has server_id of "-"), contact the session server API. + if packet.server_id != "-": + server_hash = compute_server_hash(packet.server_id, shared_secret, packet.public_key) + await join_request(self.client, self.account, server_hash) + + encrypted_token, encrypted_secret = encrypt_token_and_secret( + packet.public_key, + packet.verify_token, + shared_secret, + ) + + response_packet = LoginEncryptionResponse(shared_secret=encrypted_secret, verify_token=encrypted_token) + await self._write_packet(response_packet) + + self.conn.enable_encryption(shared_secret) + + async def login(self) -> None: + """Go through the.""" + if self.game_state is None: + await self._handshake(NextState.LOGIN) + + if self.game_state is not GameState.LOGIN: + raise InvalidGameStateError("Login flow failed", expected=GameState.LOGIN, found=self.game_state) + + start_packet = LoginStart(username=self.account.username, uuid=self.account.uuid) + await self._write_packet(start_packet) + + # Keep reading new packets until we get LoginSuccess from server + # we don't really know the receive order here, as some servers use non-standard ordering + # (i.e. sending set compression before encryption request) + while not isinstance((recv_packet := await self._read_packet()), LoginSuccess): + if isinstance(recv_packet, LoginDisconnect): + raise UnexpectedPacketError( + f"Login failed, server sent disconnect! Reason: {recv_packet.reason!r}", + expected=(LoginEncryptionRequest, LoginSetCompression, LoginSuccess), + found=recv_packet, + ) + + if isinstance(recv_packet, LoginSetCompression): + self.packet_compression_threshold = recv_packet.threshold + continue + + if isinstance(recv_packet, LoginEncryptionRequest): + await self._handle_encryption_request(recv_packet) + continue + + raise UnexpectedPacketError( + "Login failed, server sent unexpected packet", + expected=(LoginEncryptionRequest, LoginSetCompression, LoginSuccess), + found=recv_packet, + ) + + # We now know the received packet is LoginSuccess, do some sanity checks for it's validity + if recv_packet.username != self.account.username: + raise IOError( + "Received LoginSuccess packet that didn't match request account username!" + f" request_username={self.account.username!r}, received_username={recv_packet.username!r}" + ) + if recv_packet.uuid != self.account.uuid: + raise IOError( + "Received LoginSuccess packet that didn't match request account uuid!" + f" request_uuid={self.account.uuid!r}, received_uuid={recv_packet.uuid!r}" + ) + + # Transition to PLAY state + self.game_state = GameState.PLAY + + # Wait for Login (play) packet now. It could take a while for some servers to + # transition to the play state, but we can be certain the server won't send any + # other packets before this Login one. + recv_packet = await self._read_packet() + # TODO: Mcproto doesn't yet contain PLAY game state packets diff --git a/mcproto/exceptions.py b/mcproto/exceptions.py new file mode 100644 index 00000000..e60048eb --- /dev/null +++ b/mcproto/exceptions.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +from mcproto.packets.packet import GameState, Packet + + +class InvalidGameStateError(Exception): + """Exception raised when the current game state didn't match the expected game state. + + Many of the minecraft communication flows, such as login, or status requesting requires + to start from a specific game state. For example login can't be performed again, if we're + already in the play game state. + """ + + def __init__( + self, + reason: str | None = None, + /, + *, + expected: GameState | None | tuple[GameState | None, ...], + found: GameState | None, + ) -> None: + if not isinstance(expected, tuple): + expected = (expected,) + + self.reason = reason + self.expected_state = expected + self.found_state = found + super().__init__(self.msg) + + @property + def msg(self) -> str: + """Produce a message for this error.""" + if len(self.expected_state) == 1: + state = self.expected_state[0] + msg = "Expected initial (no) game state" if state is None else f"Expected {state.name} game state" + else: + states = ", ".join(state.name if state is not None else "None" for state in self.expected_state) + msg = f"Expected one of: {states} game states" + + if self.found_state is None: + msg += ", but found initial (no) game state" + else: + msg += f", but found {self.found_state.name} game state" + + if self.reason: + msg += f": {self.reason}" + return msg + + def __repr__(self) -> str: + return f"{self.__class__.__name__}({self.msg!r})" + + +class UnexpectedPacketError(Exception): + """Exception produced when the obtained packet wasn't the packet we expected. + + In many minecraft communication flows, such as login, there are only a few (or just one) + specific packet types that we expect to be sent next. If a different packet is received, + this exception will be raised. + """ + + def __init__( + self, + reason: str | None = None, + /, + *, + expected: type[Packet] | tuple[type[Packet], ...], + found: Packet, + ) -> None: + if not isinstance(expected, tuple): + expected = (expected,) + + self.reason = reason + self.expected_packet_types = expected + self.found_packet = found + super().__init__(self.reason) + + @property + def msg(self) -> str: + """Produce a message for this error.""" + if len(self.expected_packet_types) == 1: + expected_packet = self.expected_packet_types[0].__name__ + msg = f"Expected a {expected_packet} packet" + else: + expected_packets = ", ".join(packet.__name__ for packet in self.expected_packet_types) + msg = f"Expected one of {expected_packets} packets" + + msg += f", but found {self.found_packet!r}" + + if self.reason: + msg += f": {self.reason}" + + return msg + + def __repr__(self) -> str: + return f"{self.__class__.__name__}({self.msg!r})" From 72c2335ce35df7412ab66d27d9e45b877d480f05 Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Mon, 9 Oct 2023 14:37:39 +0200 Subject: [PATCH 02/10] Add ping request to client class --- mcproto/client.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/mcproto/client.py b/mcproto/client.py index a0e93a38..a7789b0d 100644 --- a/mcproto/client.py +++ b/mcproto/client.py @@ -19,6 +19,7 @@ ) from mcproto.packets.packet import ClientBoundPacket, GameState, PacketDirection, ServerBoundPacket from mcproto.packets.packet_map import generate_packet_map +from mcproto.packets.status.ping import PingPong from mcproto.packets.status.status import StatusRequest, StatusResponse @@ -102,6 +103,23 @@ async def _handshake(self, next_state: NextState) -> None: await self._write_packet(packet) self.game_state = GameState.STATUS if next_state is NextState.STATUS else GameState.LOGIN + async def ping(self, payload: int) -> PingPong: + """Ping the server.""" + if self.game_state is None: + await self._handshake(NextState.STATUS) + + if self.game_state is not GameState.STATUS: + raise InvalidGameStateError("Requesting ping failed", expected=GameState.STATUS, found=self.game_state) + + packet = PingPong(payload) + await self._write_packet(packet) + + recv_packet = await self._read_packet() + if not isinstance(recv_packet, PingPong): + raise UnexpectedPacketError("Receiving ping response failed", expected=PingPong, found=recv_packet) + + return recv_packet + async def status(self) -> StatusResponse: """Obtain status data from the server. From ba9b4801880dbd595e76cf4c9c1a5a0ae2fedad9 Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Mon, 9 Oct 2023 16:09:35 +0200 Subject: [PATCH 03/10] Rename client -> httpx_client --- mcproto/client.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/mcproto/client.py b/mcproto/client.py index a7789b0d..2ab9d7cd 100644 --- a/mcproto/client.py +++ b/mcproto/client.py @@ -33,7 +33,7 @@ class Client: __slots__ = ( "host", "port", - "client", + "httpx_client", "account", "conn", "protocol_version", @@ -45,7 +45,7 @@ def __init__( # noqa: PLR0913 self, host: str, port: int, - client: httpx.AsyncClient, + httpx_client: httpx.AsyncClient, account: Account, conn: TCPAsyncConnection, protocol_version: int, @@ -54,7 +54,7 @@ def __init__( # noqa: PLR0913 ) -> None: self.host = host self.port = port - self.client = client + self.httpx_client = httpx_client self.account = account self.conn = conn self.protocol_version = protocol_version @@ -155,7 +155,7 @@ async def _handle_encryption_request(self, packet: LoginEncryptionRequest) -> No # If the server isn't in offline mode (has server_id of "-"), contact the session server API. if packet.server_id != "-": server_hash = compute_server_hash(packet.server_id, shared_secret, packet.public_key) - await join_request(self.client, self.account, server_hash) + await join_request(self.httpx_client, self.account, server_hash) encrypted_token, encrypted_secret = encrypt_token_and_secret( packet.public_key, From 18108eba606ec63f48fdabc4838c21931523127d Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Mon, 9 Oct 2023 16:09:59 +0200 Subject: [PATCH 04/10] Don't attempt reading from play state (will crash) --- README.md | 2 +- mcproto/client.py | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index a3643530..e5abe310 100644 --- a/README.md +++ b/README.md @@ -373,7 +373,7 @@ async def main(): client = Client( host="localhost", port=25565, - client=client, + httpx_client=client, account=account, conn=connection, protocol_version=763, # 1.20.1 diff --git a/mcproto/client.py b/mcproto/client.py index 2ab9d7cd..2c85a7ae 100644 --- a/mcproto/client.py +++ b/mcproto/client.py @@ -222,5 +222,4 @@ async def login(self) -> None: # Wait for Login (play) packet now. It could take a while for some servers to # transition to the play state, but we can be certain the server won't send any # other packets before this Login one. - recv_packet = await self._read_packet() - # TODO: Mcproto doesn't yet contain PLAY game state packets + raise NotImplementedError("Play state packets aren't implemented yet") From b297dd14425021ec045a549f92461afffe2a4517 Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Mon, 9 Oct 2023 16:29:57 +0200 Subject: [PATCH 05/10] Add basic server implementation --- README.md | 32 ++++++ mcproto/server.py | 281 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 313 insertions(+) create mode 100644 mcproto/server.py diff --git a/README.md b/README.md index e5abe310..61bfdfe1 100644 --- a/README.md +++ b/README.md @@ -408,3 +408,35 @@ async def main(): # Play state, yay! ``` + +### Using Server class to create a basic server + +Along with the `Client` class, mcproto also has a `Server` class, which is capable of partially simulating a minecraft +server. Note that this is a very basic implementation and it doesn't currently support PLAY state at all, however this +class is extendable, so you can absolutely subclass it to fit your needs. + +To start this server, you can run the following: + +```python +import httpx +from mcproto.server import Server + +HOST = "0.0.0.0" +PORT = 25565 + +async with httpx.AsyncClient() as client: + server = Server( + HOST, + PORT, + httpx_client=client, + enable_encryption=True, + online=True, + compression_threshold=-1, + prevent_proxy_connections=False, + ) + await server.start() +``` + +The `server.start()` function will block forever, as the server will be running. With this, you should be able to +actually see this server in minecraft's multiplayer menu, as it does support returning status. However actually trying +to connect will fail at finishConnect(), because of the lack of PLAY gamestate support. diff --git a/mcproto/server.py b/mcproto/server.py new file mode 100644 index 00000000..d6f85b08 --- /dev/null +++ b/mcproto/server.py @@ -0,0 +1,281 @@ +from __future__ import annotations + +import asyncio +from typing import NoReturn +from uuid import uuid4 + +import httpx +from typing_extensions import Self + +from mcproto.connection import TCPAsyncConnection +from mcproto.encryption import decrypt_token_and_secret, generate_rsa_key, generate_verify_token +from mcproto.exceptions import UnexpectedPacketError +from mcproto.multiplayer import compute_server_hash, join_check +from mcproto.packets.handshaking.handshake import Handshake, NextState +from mcproto.packets.interactions import async_read_packet, async_write_packet +from mcproto.packets.login.login import ( + LoginEncryptionRequest, + LoginEncryptionResponse, + LoginSetCompression, + LoginStart, + LoginSuccess, +) +from mcproto.packets.packet import ClientBoundPacket, GameState, PacketDirection, ServerBoundPacket +from mcproto.packets.packet_map import generate_packet_map +from mcproto.packets.status.ping import PingPong +from mcproto.packets.status.status import StatusRequest, StatusResponse +from mcproto.types.uuid import UUID as McUUID # noqa: N811 # UUID isn't a constant + + +class ConnectedClient: + """Class holding data about a client connected to the server. + + This class is aware of the current gamestate for this client, compression, encryption, ... + """ + + __slots__ = ("conn", "game_state", "compression_threshold", "username", "uuid") + + def __init__( + self, + conn: TCPAsyncConnection, + *, + game_state: GameState, + compression_threshold: int = -1, + ): + self.conn = conn + self.game_state = game_state + self.compression_threshold = compression_threshold + + # Manually set by the server, once LoginStart is received + self.username: str + self.uuid: McUUID + + @classmethod + def new_connection( + cls, + reader: asyncio.StreamReader, + writer: asyncio.StreamWriter, + *, + compression_threshold: int = -1, + timeout: int = 3, + ) -> Self: + """Create a new client from the received `reader` and `writer`.""" + conn = TCPAsyncConnection(reader, writer, timeout=timeout) + return cls(conn, game_state=GameState.HANDSHAKING, compression_threshold=compression_threshold) + + async def close(self) -> None: + """Close the connection with this client.""" + await self.conn.close() + + async def write_packet(self, packet: ClientBoundPacket) -> None: + """Write a packet to the client connection. + + This sends the given ``packet`` to the client, respecting the current configuration + (compression threshold, encryption, ...) + """ + await async_write_packet(self.conn, packet, compression_threshold=self.compression_threshold) + + async def read_packet(self) -> ServerBoundPacket: + """Read a packet from the client connection. + + This receives a packet from the client, resolving it based on the current configuration + (using a packet map for current game state, compression threshold, encryption, ...) + """ + packet_map = generate_packet_map(PacketDirection.SERVERBOUND, self.game_state) + return await async_read_packet(self.conn, packet_map, compression_threshold=self.compression_threshold) + + @property + def ip(self) -> str: + """Obtain the IP address of the client.""" + return self.conn.writer.get_extra_info("peername")[0] + + +class Server: + """Class representing the server, capable of communication with multiple clients. + + This class holds the logic for all server interactions/flows, and is capable to + process received client requests and act accordingly. + """ + + __slots__ = ( + "host", + "port", + "httpx_client", + "enable_encryption", + "online", + "compression_threshold", + "prevent_proxy_connections", + ) + + def __init__( # noqa: PLR0913 + self, + host: str, + port: int, + *, + httpx_client: httpx.AsyncClient, + enable_encryption: bool, + online: bool, + compression_threshold: int = -1, + prevent_proxy_connections: bool = False, + ): + if online and not enable_encryption: + raise ValueError("Can't use online mode without encryption") + + self.host = host + self.port = port + self.httpx_client = httpx_client + self.enable_encryption = enable_encryption + self.online = online + self.compression_threshold = compression_threshold + self.prevent_proxy_connections = prevent_proxy_connections + + async def start(self) -> NoReturn: + """Start the server, and run it forever.""" + server = await asyncio.start_server(self.handle_client, self.host, self.port) + async with server: + await server.serve_forever() + + async def handle_client(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + """Handle incoming connection from a client.""" + client = ConnectedClient.new_connection(reader, writer) + + try: + await self.handle_handshaking_gamestate(client) + + if client.game_state is GameState.STATUS: + await self.handle_status_gamestate(client) + elif client.game_state is GameState.LOGIN: + await self.handle_login_gamestate(client) + else: + raise # never + finally: + await client.close() + + async def handle_handshaking_gamestate(self, client: ConnectedClient) -> None: + """Handle client entering the handshaking gamestate (initial state).""" + handshake_packet = await client.read_packet() + if not isinstance(handshake_packet, Handshake): + raise UnexpectedPacketError("Receiving handshake failed", expected=Handshake, found=handshake_packet) + + if handshake_packet.next_state is NextState.LOGIN: + client.game_state = GameState.LOGIN + elif handshake_packet.next_state is NextState.STATUS: + client.game_state = GameState.STATUS + else: + raise # never + + async def handle_status_gamestate(self, client: ConnectedClient) -> None: + """Handle client entering the status state. + + The client is now expected to either send a status request, or a ping, or both + with status request being the first packet. + + If the first requested packet wasn't a status request, it can't be requested anymore! + However ping can be requested as many times as the client wants. + """ + recv_packet = await client.read_packet() + if isinstance(recv_packet, StatusRequest): + packet = StatusResponse(self.status) + await client.write_packet(packet) + + try: + recv_packet = await client.read_packet() + # If we can't read any more packets here, the client has probably + # ended the connection, do the same + except IOError: + return + + while True: + if isinstance(recv_packet, PingPong): + packet = PingPong(recv_packet.payload) + await client.write_packet(packet) + else: + raise UnexpectedPacketError("Status flow failed", expected=PingPong, found=recv_packet) + + try: + recv_packet = await client.read_packet() + # If we can't read any more packets here, the client has probably + # ended the connection, do the same + except IOError: + return + + async def _handle_encryption_request(self, client: ConnectedClient) -> None: + """Handle sending the :class:`~mcproto.packets.login.login.LoginEncryptionRequest` packet. + + This will generate an RSA keypair, sending the public key to the client, which will use it + to encrypt the shared secret value for symmetric AES/CFB8 encryption generated by the client. + + This allows the client to safely send a randomly generated shared secret, and as both + sides will now have the same encryption key, encryption is enabled. All further + communication will be encrypted. + """ + rsa_key = generate_rsa_key() + verify_token = generate_verify_token() + server_id = "" if self.online else "-" + + packet = LoginEncryptionRequest( + server_id=server_id, + public_key=rsa_key.public_key(), + verify_token=verify_token, + ) + await client.write_packet(packet) + + recv_packet = await client.read_packet() + if not isinstance(recv_packet, LoginEncryptionResponse): + raise UnexpectedPacketError("Login flow failed", expected=LoginEncryptionResponse, found=recv_packet) + + decrypted_token, decrypted_secret = decrypt_token_and_secret( + rsa_key, + recv_packet.verify_token, + recv_packet.shared_secret, + ) + if decrypted_token != verify_token: + raise # TODO: Make custom exc type + + client.conn.enable_encryption(decrypted_secret) + + if self.online: + client_ip = client.ip if self.prevent_proxy_connections else None + server_hash = compute_server_hash(server_id, decrypted_secret, rsa_key.public_key()) + ack_data = await join_check(self.httpx_client, client.username, server_hash, client_ip) + + client.uuid = McUUID(ack_data["id"]) + client.username = ack_data["name"] + + async def handle_login_gamestate(self, client: ConnectedClient) -> None: + """Handle client entering the login state.""" + login_start_packet = await client.read_packet() + if not isinstance(login_start_packet, LoginStart): + raise UnexpectedPacketError("Login flow failed", expected=LoginStart, found=login_start_packet) + + client.username = login_start_packet.username + client.uuid = login_start_packet.uuid or McUUID(str(uuid4())) + + if self.enable_encryption: + await self._handle_encryption_request(client) + + if self.compression_threshold >= 0: + packet = LoginSetCompression(self.compression_threshold) + await client.write_packet(packet) + + packet = LoginSuccess(client.uuid, client.username) + await client.write_packet(packet) + + # Transition to play + return await self.handle_play_gamestate(client) + + async def handle_play_gamestate(self, client: ConnectedClient) -> None: + """Handle client entering the play state.""" + raise NotImplementedError("Play state packets aren't implemented yet") + while True: + await client.read_packet() + + @property + def status(self) -> dict[str, object]: + """Return the server info (used in `~mcproto.packets.status.status.StatusResponse`).""" + return { + "version": {"name": "1.20.2", "protocol": 764}, + "enforcesSecureChat": True, + "description": {"text": "A Vanilla Minecraft Server powered by Mcproto"}, + "players": {"max": 20, "online": 0}, + } From ba7bb9a113eec01a9645802e1e8cd9db040375b8 Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Mon, 9 Oct 2023 17:19:05 +0200 Subject: [PATCH 06/10] Add InvalidVerifyTokenError exc class --- mcproto/exceptions.py | 25 +++++++++++++++++++++++++ mcproto/server.py | 4 ++-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/mcproto/exceptions.py b/mcproto/exceptions.py index e60048eb..4e04cb59 100644 --- a/mcproto/exceptions.py +++ b/mcproto/exceptions.py @@ -93,3 +93,28 @@ def msg(self) -> str: def __repr__(self) -> str: return f"{self.__class__.__name__}({self.msg!r})" + + +class InvalidVerifyTokenError(Exception): + """Exception produced when the verify_token from client doesn't match the one sent by the server. + + The verify_token is sent by the server in `~mcproto.packets.login.login.LoginEncryptionRequest`, then + encrypted by the client and sent back in `~mcproto.packets.login.login.LoginEncryptionResponse`. + """ + + def __init__(self, original_token: bytes, received_decrypted_token: bytes) -> None: + self.original_token = original_token + self.received_decrypted_token = received_decrypted_token + super().__init__(self.msg) + + @property + def msg(self) -> str: + """Produce a message for this error.""" + return ( + "Client sent mismatched verify token:" + f" original: {self.original_token!r}," + f" received: {self.received_decrypted_token!r}" + ) + + def __repr__(self) -> str: + return f"{self.__class__.__name__}({self.msg!r})" diff --git a/mcproto/server.py b/mcproto/server.py index d6f85b08..3bf4aa71 100644 --- a/mcproto/server.py +++ b/mcproto/server.py @@ -9,7 +9,7 @@ from mcproto.connection import TCPAsyncConnection from mcproto.encryption import decrypt_token_and_secret, generate_rsa_key, generate_verify_token -from mcproto.exceptions import UnexpectedPacketError +from mcproto.exceptions import InvalidVerifyTokenError, UnexpectedPacketError from mcproto.multiplayer import compute_server_hash, join_check from mcproto.packets.handshaking.handshake import Handshake, NextState from mcproto.packets.interactions import async_read_packet, async_write_packet @@ -230,7 +230,7 @@ async def _handle_encryption_request(self, client: ConnectedClient) -> None: recv_packet.shared_secret, ) if decrypted_token != verify_token: - raise # TODO: Make custom exc type + raise InvalidVerifyTokenError(verify_token, decrypted_token) client.conn.enable_encryption(decrypted_secret) From 1f651aeeeeda813c7ddc0ce6f99a63724a70915a Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Mon, 9 Oct 2023 17:19:18 +0200 Subject: [PATCH 07/10] Add changelog fragment for PR 182 --- changes/182.feature.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changes/182.feature.md diff --git a/changes/182.feature.md b/changes/182.feature.md new file mode 100644 index 00000000..4ec49f5f --- /dev/null +++ b/changes/182.feature.md @@ -0,0 +1 @@ +Add server and client classes, containing most of the supported flows and interactions. From 037d6bfc1c0ef48b391813d19c06dc60a88619eb Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Sun, 19 May 2024 01:07:43 +0200 Subject: [PATCH 08/10] Move server & client logic to interactions/ --- mcproto/interaction/__init__.py | 0 mcproto/{ => interaction}/client.py | 4 ++-- mcproto/{ => interaction}/exceptions.py | 0 mcproto/{ => interaction}/server.py | 4 ++-- 4 files changed, 4 insertions(+), 4 deletions(-) create mode 100644 mcproto/interaction/__init__.py rename mcproto/{ => interaction}/client.py (98%) rename mcproto/{ => interaction}/exceptions.py (100%) rename mcproto/{ => interaction}/server.py (98%) diff --git a/mcproto/interaction/__init__.py b/mcproto/interaction/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/mcproto/client.py b/mcproto/interaction/client.py similarity index 98% rename from mcproto/client.py rename to mcproto/interaction/client.py index 2c85a7ae..dfa3e774 100644 --- a/mcproto/client.py +++ b/mcproto/interaction/client.py @@ -5,7 +5,7 @@ from mcproto.auth.account import Account from mcproto.connection import TCPAsyncConnection from mcproto.encryption import encrypt_token_and_secret, generate_shared_secret -from mcproto.exceptions import InvalidGameStateError, UnexpectedPacketError +from mcproto.interaction.exceptions import InvalidGameStateError, UnexpectedPacketError from mcproto.multiplayer import compute_server_hash, join_request from mcproto.packets.handshaking.handshake import Handshake, NextState from mcproto.packets.interactions import async_read_packet, async_write_packet @@ -41,7 +41,7 @@ class Client: "packet_compression_threshold", ) - def __init__( # noqa: PLR0913 + def __init__( self, host: str, port: int, diff --git a/mcproto/exceptions.py b/mcproto/interaction/exceptions.py similarity index 100% rename from mcproto/exceptions.py rename to mcproto/interaction/exceptions.py diff --git a/mcproto/server.py b/mcproto/interaction/server.py similarity index 98% rename from mcproto/server.py rename to mcproto/interaction/server.py index 3bf4aa71..7e52944b 100644 --- a/mcproto/server.py +++ b/mcproto/interaction/server.py @@ -9,7 +9,7 @@ from mcproto.connection import TCPAsyncConnection from mcproto.encryption import decrypt_token_and_secret, generate_rsa_key, generate_verify_token -from mcproto.exceptions import InvalidVerifyTokenError, UnexpectedPacketError +from mcproto.interaction.exceptions import InvalidVerifyTokenError, UnexpectedPacketError from mcproto.multiplayer import compute_server_hash, join_check from mcproto.packets.handshaking.handshake import Handshake, NextState from mcproto.packets.interactions import async_read_packet, async_write_packet @@ -107,7 +107,7 @@ class Server: "prevent_proxy_connections", ) - def __init__( # noqa: PLR0913 + def __init__( self, host: str, port: int, From a4e4805d93f3fd309ab0039e5b420857dae07e95 Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Sun, 19 May 2024 01:12:09 +0200 Subject: [PATCH 09/10] Add @override decorator where necessary --- mcproto/interaction/exceptions.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/mcproto/interaction/exceptions.py b/mcproto/interaction/exceptions.py index 4e04cb59..5afe46c2 100644 --- a/mcproto/interaction/exceptions.py +++ b/mcproto/interaction/exceptions.py @@ -1,4 +1,5 @@ from __future__ import annotations +from typing_extensions import override from mcproto.packets.packet import GameState, Packet @@ -46,6 +47,7 @@ def msg(self) -> str: msg += f": {self.reason}" return msg + @override def __repr__(self) -> str: return f"{self.__class__.__name__}({self.msg!r})" @@ -91,6 +93,7 @@ def msg(self) -> str: return msg + @override def __repr__(self) -> str: return f"{self.__class__.__name__}({self.msg!r})" @@ -116,5 +119,6 @@ def msg(self) -> str: f" received: {self.received_decrypted_token!r}" ) + @override def __repr__(self) -> str: return f"{self.__class__.__name__}({self.msg!r})" From b85668d4df4d46bc8faf6508a47003064c275c1c Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Sun, 19 May 2024 01:43:38 +0200 Subject: [PATCH 10/10] Rename interaction -> interactions --- README.md | 20 ++++++++++++------- .../{interaction => interactions}/__init__.py | 0 .../{interaction => interactions}/client.py | 2 +- .../exceptions.py | 0 .../{interaction => interactions}/server.py | 2 +- 5 files changed, 15 insertions(+), 9 deletions(-) rename mcproto/{interaction => interactions}/__init__.py (100%) rename mcproto/{interaction => interactions}/client.py (99%) rename mcproto/{interaction => interactions}/exceptions.py (100%) rename mcproto/{interaction => interactions}/server.py (99%) diff --git a/README.md b/README.md index 61bfdfe1..ce04da32 100644 --- a/README.md +++ b/README.md @@ -346,7 +346,7 @@ and where to learn what to send and when. import httpx -from mcproto.client import Client +from mcproto.interactions.client import Client from mcproto.connection import TCPAsyncConnection from mcproto.auth.account import Account from mcproto.types.uuid import UUID @@ -371,8 +371,8 @@ async def main(): async with httpx.AsyncClient() as client: async with (await TCPAsyncConnection.make_client((HOST, PORT), 2)) as connection: client = Client( - host="localhost", - port=25565, + host=HOST, + port=PORT, httpx_client=client, account=account, conn=connection, @@ -388,20 +388,26 @@ async def main(): # In the back, the `status` function has performed a handshake to transition us from # the initial (None) game state, to the STATUS game state, and then sent a status # request, getting back a response. - + # # The Client instance also includes a `login` function, which is capable to go through # the entire login flow, leaving you in PLAY game state. Note that unless you've # set MINECRAFT_ACCESS_TOKEN, you will only be able to do this for warez servers. - + # # But since we just called `status`, it left us in the STATUS game state, but we need # to be in LOGIN game state. The `login` function will work if called from an initial # game state (None), as it's smart enough to perform a handshake getting us to LOGIN, # however it doesn't know what to do from STATUS game state. - + # # What we can do, is simply set game_state back to None (this is what happens during # initialization of the Client class), making the login function send out another # handshake, this time transitioning to LOGIN instead of STATUS. We could also create # a completely new client instance. + # + # Note that this way of naively resetting the game-state won't always work, as the + # underlying connection isn't actually reset, and it's possible that in some cases, + # the server simply won't let us perform another handshake on the same connection. + # You will likely encounter this if you attempt to request status twice, however + # transitioning to login in this way will generally work. client.game_state = None client.login() @@ -419,7 +425,7 @@ To start this server, you can run the following: ```python import httpx -from mcproto.server import Server +from mcproto.interactions.server import Server HOST = "0.0.0.0" PORT = 25565 diff --git a/mcproto/interaction/__init__.py b/mcproto/interactions/__init__.py similarity index 100% rename from mcproto/interaction/__init__.py rename to mcproto/interactions/__init__.py diff --git a/mcproto/interaction/client.py b/mcproto/interactions/client.py similarity index 99% rename from mcproto/interaction/client.py rename to mcproto/interactions/client.py index dfa3e774..59b7a4f0 100644 --- a/mcproto/interaction/client.py +++ b/mcproto/interactions/client.py @@ -5,7 +5,7 @@ from mcproto.auth.account import Account from mcproto.connection import TCPAsyncConnection from mcproto.encryption import encrypt_token_and_secret, generate_shared_secret -from mcproto.interaction.exceptions import InvalidGameStateError, UnexpectedPacketError +from mcproto.interactions.exceptions import InvalidGameStateError, UnexpectedPacketError from mcproto.multiplayer import compute_server_hash, join_request from mcproto.packets.handshaking.handshake import Handshake, NextState from mcproto.packets.interactions import async_read_packet, async_write_packet diff --git a/mcproto/interaction/exceptions.py b/mcproto/interactions/exceptions.py similarity index 100% rename from mcproto/interaction/exceptions.py rename to mcproto/interactions/exceptions.py diff --git a/mcproto/interaction/server.py b/mcproto/interactions/server.py similarity index 99% rename from mcproto/interaction/server.py rename to mcproto/interactions/server.py index 7e52944b..d2dbf9bf 100644 --- a/mcproto/interaction/server.py +++ b/mcproto/interactions/server.py @@ -9,7 +9,7 @@ from mcproto.connection import TCPAsyncConnection from mcproto.encryption import decrypt_token_and_secret, generate_rsa_key, generate_verify_token -from mcproto.interaction.exceptions import InvalidVerifyTokenError, UnexpectedPacketError +from mcproto.interactions.exceptions import InvalidVerifyTokenError, UnexpectedPacketError from mcproto.multiplayer import compute_server_hash, join_check from mcproto.packets.handshaking.handshake import Handshake, NextState from mcproto.packets.interactions import async_read_packet, async_write_packet