diff --git a/Minecraft.Client/ClientConnection.cpp b/Minecraft.Client/ClientConnection.cpp index 125e6c37..afa6ec75 100644 --- a/Minecraft.Client/ClientConnection.cpp +++ b/Minecraft.Client/ClientConnection.cpp @@ -58,6 +58,7 @@ #ifdef _WINDOWS64 #include "Xbox\Network\NetworkPlayerXbox.h" #include "Common\Network\PlatformNetworkManagerStub.h" +#include "Windows64\Network\WinsockNetLayer.h" #endif @@ -3787,6 +3788,120 @@ void ClientConnection::handleSoundEvent(shared_ptr packet) void ClientConnection::handleCustomPayload(shared_ptr customPayloadPacket) { +#ifdef _WINDOWS64 + // Build a server-specific identity token file path next to the executable. + // Each server gets its own token file based on a hash of the server address, + // so connecting to multiple secured servers doesn't overwrite tokens. + auto buildIdentityTokenPath = []() -> std::string { + char exePath[MAX_PATH] = {}; + DWORD len = GetModuleFileNameA(NULL, exePath, MAX_PATH); + if (len == 0 || len >= MAX_PATH) return std::string(); + char *lastSlash = strrchr(exePath, '\\'); + if (lastSlash != NULL) *(lastSlash + 1) = 0; + + // Hash the server IP:port to create a unique filename per server + char serverAddr[300] = {}; + sprintf_s(serverAddr, sizeof(serverAddr), "%s:%d", g_Win64MultiplayerIP, g_Win64MultiplayerPort); + unsigned int hash = 5381; + for (const char *p = serverAddr; *p; ++p) + hash = ((hash << 5) + hash) + static_cast(*p); + + char filename[64] = {}; + sprintf_s(filename, sizeof(filename), "identity-token-%08x.dat", hash); + return std::string(exePath) + filename; + }; + + // Identity token: server issued us a new token - store it locally + if (CustomPayloadPacket::IDENTITY_TOKEN_ISSUE.compare(customPayloadPacket->identifier) == 0) + { + if (customPayloadPacket->data.data != nullptr && customPayloadPacket->length == 32) + { + std::string tokenPath = buildIdentityTokenPath(); + if (!tokenPath.empty()) + { + FILE *f = nullptr; + fopen_s(&f, tokenPath.c_str(), "wb"); + if (f != nullptr) + { + size_t written = fwrite(customPayloadPacket->data.data, 1, 32, f); + fclose(f); + if (written == 32) + { + app.DebugPrintf("Client: Stored identity token to %s\n", tokenPath.c_str()); + } + else + { + app.DebugPrintf("Client: Failed to write full identity token (wrote %zu/32)\n", written); + } + } + else + { + app.DebugPrintf("Client: Failed to open %s for writing\n", tokenPath.c_str()); + } + } + } + return; + } + + // Identity token: server is challenging us to present our stored token + if (CustomPayloadPacket::IDENTITY_TOKEN_CHALLENGE.compare(customPayloadPacket->identifier) == 0) + { + std::string tokenPath = buildIdentityTokenPath(); + FILE *f = nullptr; + if (!tokenPath.empty()) + fopen_s(&f, tokenPath.c_str(), "rb"); + if (f != nullptr) + { + uint8_t token[32] = {}; + size_t bytesRead = fread(token, 1, 32, f); + fclose(f); + if (bytesRead == 32) + { + byteArray tokenData(32); + memcpy(tokenData.data, token, 32); + connection->send(std::make_shared( + CustomPayloadPacket::IDENTITY_TOKEN_RESPONSE, tokenData)); + app.DebugPrintf("Client: Sent identity token response\n"); + } + else + { + app.DebugPrintf("Client: identity-token.dat is invalid (%zu bytes)\n", bytesRead); + connection->send(std::make_shared( + CustomPayloadPacket::IDENTITY_TOKEN_RESPONSE, byteArray())); + } + SecureZeroMemory(token, sizeof(token)); + } + else + { + app.DebugPrintf("Client: No identity-token.dat found, sending empty response\n"); + connection->send(std::make_shared( + CustomPayloadPacket::IDENTITY_TOKEN_RESPONSE, byteArray())); + } + return; + } + + // Stream cipher handshake: server sent us a key + if (CustomPayloadPacket::CIPHER_KEY_CHANNEL.compare(customPayloadPacket->identifier) == 0) + { + if (customPayloadPacket->length == ServerRuntime::Security::StreamCipher::KEY_SIZE && + customPayloadPacket->data.data != nullptr) + { + app.DebugPrintf("Client: Received MC|CKey from server (%d bytes)\n", customPayloadPacket->length); + // Store key and send ack+activate atomically to prevent ResetClientCipher race + WinsockNetLayer::StoreClientCipherKey(customPayloadPacket->data.data); + if (!WinsockNetLayer::SendAckAndActivateClientSendCipher()) + { + app.DebugPrintf("Client: Failed to send cipher ack, connection will be closed\n"); + } + } + else + { + app.DebugPrintf("Client: Received malformed MC|CKey (length=%d)\n", customPayloadPacket->length); + } + return; + } +#endif + if (CustomPayloadPacket::TRADER_LIST_PACKET.compare(customPayloadPacket->identifier) == 0) { ByteArrayInputStream bais(customPayloadPacket->data); diff --git a/Minecraft.Client/Extrax64Stubs.cpp b/Minecraft.Client/Extrax64Stubs.cpp index 0147896c..51baf6ac 100644 --- a/Minecraft.Client/Extrax64Stubs.cpp +++ b/Minecraft.Client/Extrax64Stubs.cpp @@ -196,9 +196,29 @@ void IQNetPlayer::SendData(IQNetPlayer * player, const void* pvData, DWORD dwDat { if (!WinsockNetLayer::IsHosting() && !m_isRemote) { + // Client sending to server via local socket (bypasses SendToSmallId) SOCKET sock = WinsockNetLayer::GetLocalSocket(m_smallId); if (sock != INVALID_SOCKET) - WinsockNetLayer::SendOnSocket(sock, pvData, dwDataSize); + { + // Encrypt if client send cipher is active + if (dwDataSize > 0) + { + std::vector buf(static_cast(pvData), + static_cast(pvData) + dwDataSize); + if (WinsockNetLayer::TryEncryptClientOutgoing(buf.data(), static_cast(dwDataSize))) + { + WinsockNetLayer::SendOnSocket(sock, buf.data(), static_cast(dwDataSize)); + } + else + { + WinsockNetLayer::SendOnSocket(sock, pvData, dwDataSize); + } + } + else + { + WinsockNetLayer::SendOnSocket(sock, pvData, dwDataSize); + } + } } else { diff --git a/Minecraft.Client/PendingConnection.cpp b/Minecraft.Client/PendingConnection.cpp index f24086c1..82c80df3 100644 --- a/Minecraft.Client/PendingConnection.cpp +++ b/Minecraft.Client/PendingConnection.cpp @@ -17,6 +17,7 @@ #if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD) #include "..\Minecraft.Server\ServerLogManager.h" #include "..\Minecraft.Server\Access\Access.h" +#include "..\Minecraft.Server\Security\SecurityConfig.h" #include "..\Minecraft.World\Socket.h" #endif // #ifdef __PS3__ @@ -150,6 +151,20 @@ void PendingConnection::sendPreLoginResponse() } } +#if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD) + // Security: strip real XUIDs from pre-login response to prevent unauthenticated enumeration. + // The client receives the correct player count but cannot identify who is connected. + // Real XUID data is sent post-login via PlayerInfoPacket broadcasts. + if (ServerRuntime::Security::GetSettings().hidePlayerListPreLogin) + { + for (DWORD i = 0; i < ugcXuidCount; ++i) + { + ugcXuids[i] = INVALID_XUID; + } + ugcFriendsOnlyBits = 0; + } +#endif + #if 0 if (false)// server->onlineMode) // 4J - removed { @@ -203,6 +218,56 @@ void PendingConnection::handleLogin(shared_ptr packet) duplicateXuid = true; } +#if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD) + // Cross-reference: if someone claims the same XUID as an existing player from a different IP, + // log and reject as a potential spoofing attempt. + // Note: this runs on the main tick thread (via PendingConnection::tick -> Connection::tick -> + // handleLogin), same thread that mutates the player list, so no lock is needed. + if (!duplicateXuid && loginXuid != INVALID_XUID) + { + std::string newIp; + unsigned char newSmallId = GetPendingConnectionSmallId(connection); + bool hasNewIp = ServerRuntime::ServerLogManager::TryGetConnectionRemoteIp(newSmallId, &newIp); + + for (auto &existingPlayer : server->getPlayers()->players) + { + if (existingPlayer == nullptr) continue; + PlayerUID existingXuid = existingPlayer->connection->m_offlineXUID; + if (existingXuid == INVALID_XUID) existingXuid = existingPlayer->connection->m_onlineXUID; + if (existingXuid == loginXuid) + { + if (hasNewIp) + { + std::string existingIp; + INetworkPlayer *np = existingPlayer->connection->getNetworkPlayer(); + if (np != nullptr) + { + unsigned char existingSmallId = np->GetSmallId(); + if (ServerRuntime::ServerLogManager::TryGetConnectionRemoteIp(existingSmallId, &existingIp)) + { + if (existingIp != newIp) + { + app.DebugPrintf("SECURITY: XUID spoofing suspected - XUID 0x%016llx claimed from IP %s while already connected from IP %s\n", + (unsigned long long)loginXuid, newIp.c_str(), existingIp.c_str()); + ServerRuntime::ServerLogManager::OnXuidSpoofDetected(newSmallId, name, newIp.c_str(), existingIp.c_str()); + duplicateXuid = true; + } + } + } + } + else + { + // Cannot verify IP -- treat same-XUID connection as suspicious + app.DebugPrintf("SECURITY: XUID 0x%016llx claimed but could not verify source IP\n", + (unsigned long long)loginXuid); + duplicateXuid = true; + } + break; + } + } + } +#endif + bool bannedXuid = false; if (loginXuid != INVALID_XUID) { @@ -243,7 +308,11 @@ void PendingConnection::handleLogin(shared_ptr packet) else if (!whitelistSatisfied) { #if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD) + // Cache name->XUID so `whitelist add ` can resolve the XUID + ServerRuntime::ServerLogManager::CachePlayerXuid(name, loginXuid); ServerRuntime::ServerLogManager::OnRejectedPlayerLogin(GetPendingConnectionSmallId(connection), name, ServerRuntime::ServerLogManager::eLoginRejectReason_NotWhitelisted); + app.DebugPrintf("WHITELIST: Rejected %ls (XUID: 0x%016llx) - use 'whitelist add %ls' to allow\n", + name.c_str(), (unsigned long long)loginXuid, name.c_str()); #endif disconnect(DisconnectPacket::eDisconnect_Banned); } @@ -330,11 +399,17 @@ void PendingConnection::handleAcceptedLogin(shared_ptr packet) PlayerUID playerXuid = packet->m_offlineXuid; if(playerXuid == INVALID_XUID) playerXuid = packet->m_onlineXuid; +#if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD) + // Cache name->XUID for console commands (whitelist add, revoketoken, etc.) + ServerRuntime::ServerLogManager::CachePlayerXuid(name, playerXuid); +#endif + shared_ptr playerEntity = server->getPlayers()->getPlayerForLogin(this, name, playerXuid,packet->m_onlineXuid); if (playerEntity != nullptr) { #if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD) - ServerRuntime::ServerLogManager::OnAcceptedPlayerLogin(GetPendingConnectionSmallId(connection), name); + ServerRuntime::ServerLogManager::OnAcceptedPlayerLogin(GetPendingConnectionSmallId(connection), name, + packet->m_offlineXuid, packet->m_onlineXuid, packet->m_isGuest); #endif server->getPlayers()->placeNewPlayer(connection, playerEntity, packet); connection = nullptr; // We've moved responsibility for this over to the new PlayerConnection, nullptr so we don't delete our reference to it here in our dtor diff --git a/Minecraft.Client/PlayerConnection.cpp b/Minecraft.Client/PlayerConnection.cpp index 01116a2c..ce843f42 100644 --- a/Minecraft.Client/PlayerConnection.cpp +++ b/Minecraft.Client/PlayerConnection.cpp @@ -37,6 +37,11 @@ #include "Options.h" #if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD) #include "..\Minecraft.Server\ServerLogManager.h" +#include "..\Minecraft.Server\Access\Access.h" +#include "..\Minecraft.Server\Security\IdentityTokenManager.h" +#include "..\Minecraft.Server\Security\SecurityConfig.h" +#include "..\Minecraft.Server\Security\ConnectionCipher.h" +extern bool g_Win64DedicatedServer; #endif namespace @@ -85,6 +90,9 @@ PlayerConnection::PlayerConnection(MinecraftServer *server, Connection *connecti m_onlineXUID = INVALID_XUID; m_bHasClientTickedOnce = false; m_logSmallId = 0; + m_identityVerified = false; + m_identityChallengeTick = -1; + m_securityGateOpen = true; // default open; closed when cipher is required // Cache the first valid transport smallId because disconnect teardown can clear it before the server logger runs. if (this->connection != NULL && this->connection->getSocket() != NULL) @@ -620,6 +628,22 @@ void PlayerConnection::onDisconnect(DisconnectPacket::eDisconnectReason reason, LeaveCriticalSection(&done_cs); } +void PlayerConnection::openSecurityGate() +{ + if (m_securityGateOpen) + return; + + m_securityGateOpen = true; + + // Flush all buffered packets now that the cipher is active + for (auto &buffered : m_securityBuffer) + { + send(buffered); + } + m_securityBuffer.clear(); + m_securityBuffer.shrink_to_fit(); +} + void PlayerConnection::onUnhandledPacket(shared_ptr packet) { // logger.warning(getClass() + " wasn't prepared to deal with a " + packet.getClass()); @@ -630,6 +654,39 @@ void PlayerConnection::send(shared_ptr packet) { if( connection->getSocket() != nullptr ) { +#if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD) + // Security gate: when require-secure-client is enabled, buffer ALL outgoing + // packets until the cipher handshake completes. Only the cipher handshake + // CustomPayloadPacket (MC|CKey) is sent immediately. Once the cipher activates, + // openSecurityGate() flushes the buffer. This prevents unsecured/old clients + // from receiving any game data (PlayerInfoPackets, XUIDs, etc.) before being kicked. + if (!m_securityGateOpen) + { + // Allow cipher handshake packets through immediately + if (packet->getId() == 250) + { + auto cpp = dynamic_pointer_cast(packet); + if (cpp != nullptr && + (cpp->identifier == CustomPayloadPacket::CIPHER_KEY_CHANNEL || + cpp->identifier == CustomPayloadPacket::CIPHER_ACK_CHANNEL || + cpp->identifier == CustomPayloadPacket::CIPHER_ON_CHANNEL)) + { + // Fall through to send + } + else + { + m_securityBuffer.push_back(packet); + return; + } + } + else + { + m_securityBuffer.push_back(packet); + return; + } + } +#endif + if( !server->getPlayers()->canReceiveAllPackets( player ) ) { // Check if we are allowed to send this packet type @@ -1070,10 +1127,19 @@ void PlayerConnection::handleServerSettingsChanged(shared_ptraction==ServerSettingsChangedPacket::HOST_IN_GAME_SETTINGS) { - // Need to check that this player has permission to change each individual setting? - INetworkPlayer *networkPlayer = getNetworkPlayer(); - if( (networkPlayer != nullptr && networkPlayer->IsHost()) || player->isModerator()) + bool isHost = (networkPlayer != nullptr && networkPlayer->IsHost()); +#if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD) + // On dedicated servers, only the host can change server settings. + // Moderators (OPs) should not be able to alter game rules. + if (!isHost) + { + app.DebugPrintf("SECURITY: Non-host player %ls attempted to change server settings\n", + player->getName().c_str()); + return; + } +#endif + if( isHost || player->isModerator()) { app.SetGameHostOption(eGameHostOption_FireSpreads, app.GetGameHostOption(packet->data,eGameHostOption_FireSpreads)); app.SetGameHostOption(eGameHostOption_TNT, app.GetGameHostOption(packet->data,eGameHostOption_TNT)); @@ -1096,14 +1162,81 @@ void PlayerConnection::handleServerSettingsChanged(shared_ptr packet) { INetworkPlayer *networkPlayer = getNetworkPlayer(); - if( (networkPlayer != nullptr && networkPlayer->IsHost()) || player->isModerator()) + bool isHost = (networkPlayer != nullptr && networkPlayer->IsHost()); +#if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD) + // Live ops.json check for non-host players + if (!isHost) { + PlayerUID kickerXuid = m_offlineXUID; + if (kickerXuid == INVALID_XUID) kickerXuid = m_onlineXUID; + if (!ServerRuntime::Access::IsPlayerOp(kickerXuid)) + { + app.DebugPrintf("SECURITY: Non-OP player %ls attempted to kick\n", player->getName().c_str()); + { + INetworkPlayer *npLog = getNetworkPlayer(); + if (npLog != nullptr) + ServerRuntime::ServerLogManager::OnUnauthorizedCommand(npLog->GetSmallId(), player->getName(), "kick"); + } + return; + } + } +#endif + if( isHost || player->isModerator()) + { +#if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD) + // On dedicated servers, non-host moderators cannot kick other moderators or the host. + if (!isHost) + { + for (auto &checkingPlayer : server->getPlayers()->players) + { + if (checkingPlayer != nullptr && + checkingPlayer->connection->getNetworkPlayer() != nullptr && + checkingPlayer->connection->getNetworkPlayer()->GetSmallId() == packet->m_networkSmallId) + { + if (checkingPlayer->isModerator() || + checkingPlayer->connection->getNetworkPlayer()->IsHost()) + { + app.DebugPrintf("SECURITY: Moderator %ls tried to kick host/moderator %ls\n", + player->getName().c_str(), checkingPlayer->getName().c_str()); + return; + } + break; + } + } + } + app.DebugPrintf("CMD: Player %ls kicked player with smallId=%d\n", + player->getName().c_str(), packet->m_networkSmallId); +#endif server->getPlayers()->kickPlayerByShortId(packet->m_networkSmallId); } } void PlayerConnection::handleGameCommand(shared_ptr packet) { +#if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD) + INetworkPlayer *networkPlayer = getNetworkPlayer(); + bool isHost = (networkPlayer != nullptr && networkPlayer->IsHost()); + if (!isHost) + { + // Live ops.json check - in-memory isModerator() can be stale if ops.json was edited mid-session + PlayerUID cmdXuid = m_offlineXUID; + if (cmdXuid == INVALID_XUID) cmdXuid = m_onlineXUID; + if (!ServerRuntime::Access::IsPlayerOp(cmdXuid)) + { + app.DebugPrintf("SECURITY: Non-OP player %ls attempted server command id=%d\n", + player->getName().c_str(), static_cast(packet->command)); + { + INetworkPlayer *npLog = getNetworkPlayer(); + if (npLog != nullptr) + ServerRuntime::ServerLogManager::OnUnauthorizedCommand(npLog->GetSmallId(), player->getName(), "game-command"); + } + return; + } + } + app.DebugPrintf("CMD: Player %ls (OP=%d, Host=%d) executed command id=%d\n", + player->getName().c_str(), player->isModerator() ? 1 : 0, isHost ? 1 : 0, + static_cast(packet->command)); +#endif MinecraftServer::getInstance()->getCommandDispatcher()->performCommand(player, packet->command, packet->data); } @@ -1373,10 +1506,21 @@ void PlayerConnection::handleKeepAlive(shared_ptr packet) void PlayerConnection::handlePlayerInfo(shared_ptr packet) { - // Need to check that this player has permission to change each individual setting? - INetworkPlayer *networkPlayer = getNetworkPlayer(); - if( (networkPlayer != nullptr && networkPlayer->IsHost()) || player->isModerator() ) + bool isHost = (networkPlayer != nullptr && networkPlayer->IsHost()); +#if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD) + // Live ops.json check for non-host players + if (!isHost) + { + PlayerUID infoXuid = m_offlineXUID; + if (infoXuid == INVALID_XUID) infoXuid = m_onlineXUID; + if (!ServerRuntime::Access::IsPlayerOp(infoXuid)) + { + return; + } + } +#endif + if( isHost || player->isModerator() ) { shared_ptr serverPlayer; // Find the player being edited @@ -1454,7 +1598,24 @@ void PlayerConnection::handlePlayerInfo(shared_ptr packet) serverPlayer->setPlayerGamePrivilege(Player::ePlayerGamePrivilege_CanToggleClassicHunger,Player::getPlayerGamePrivilege(packet->m_playerPrivileges,Player::ePlayerGamePrivilege_CanToggleClassicHunger) ); serverPlayer->setPlayerGamePrivilege(Player::ePlayerGamePrivilege_CanTeleport,Player::getPlayerGamePrivilege(packet->m_playerPrivileges,Player::ePlayerGamePrivilege_CanTeleport) ); } +#if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD) + // On dedicated servers, OP can only be granted/revoked if the target is in ops.json. + // This prevents runtime OP escalation via crafted PlayerInfoPackets. + bool wantsOp = Player::getPlayerGamePrivilege(packet->m_playerPrivileges, Player::ePlayerGamePrivilege_Op) != 0; + PlayerUID targetXuid = serverPlayer->connection->m_offlineXUID; + if (targetXuid == INVALID_XUID) targetXuid = serverPlayer->connection->m_onlineXUID; + if (wantsOp && !ServerRuntime::Access::IsPlayerOp(targetXuid)) + { + app.DebugPrintf("SECURITY: Host tried to OP player %ls who is not in ops.json\n", + serverPlayer->getName().c_str()); + } + else + { + serverPlayer->setPlayerGamePrivilege(Player::ePlayerGamePrivilege_Op, wantsOp ? 1u : 0u); + } +#else serverPlayer->setPlayerGamePrivilege(Player::ePlayerGamePrivilege_Op,Player::getPlayerGamePrivilege(packet->m_playerPrivileges,Player::ePlayerGamePrivilege_Op) ); +#endif } } @@ -1492,6 +1653,44 @@ void PlayerConnection::handlePlayerAbilities(shared_ptr p void PlayerConnection::handleCustomPayload(shared_ptr customPayloadPacket) { +#if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD) + // Identity token response from client + if (CustomPayloadPacket::IDENTITY_TOKEN_RESPONSE.compare(customPayloadPacket->identifier) == 0) + { + PlayerUID xuid = m_offlineXUID; + if (xuid == INVALID_XUID) xuid = m_onlineXUID; + + bool tokenValid = false; + if (customPayloadPacket->length == ServerRuntime::Security::IdentityTokenManager::TOKEN_SIZE && + customPayloadPacket->data.length == ServerRuntime::Security::IdentityTokenManager::TOKEN_SIZE && + customPayloadPacket->data.data != nullptr) + { + tokenValid = ServerRuntime::Security::GetIdentityTokenManager().VerifyToken(xuid, customPayloadPacket->data.data); + } + + if (tokenValid) + { + m_identityVerified = true; + app.DebugPrintf("SECURITY: Identity token verified for player %ls\n", player->getName().c_str()); + INetworkPlayer *npLog = getNetworkPlayer(); + if (npLog != nullptr) + ServerRuntime::ServerLogManager::OnIdentityTokenVerified(npLog->GetSmallId()); + } + else + { + app.DebugPrintf("SECURITY: Identity token MISMATCH for player %ls - will disconnect\n", player->getName().c_str()); + app.DebugPrintf("SECURITY: If this player lost their token, use: revoketoken %ls\n", player->getName().c_str()); + INetworkPlayer *npLog = getNetworkPlayer(); + if (npLog != nullptr) + ServerRuntime::ServerLogManager::OnIdentityTokenMismatch(npLog->GetSmallId(), player->getName()); + // Defer disconnect to avoid re-entrancy issues during packet dispatch + setWasKicked(); + closeOnTick(); + } + return; + } +#endif + #if 0 if (CustomPayloadPacket.CUSTOM_BOOK_PACKET.equals(customPayloadPacket.identifier)) { diff --git a/Minecraft.Client/PlayerConnection.h b/Minecraft.Client/PlayerConnection.h index fe7a9afa..4b3c5f01 100644 --- a/Minecraft.Client/PlayerConnection.h +++ b/Minecraft.Client/PlayerConnection.h @@ -137,6 +137,21 @@ public: // 4J Added bool hasClientTickedOnce() { return m_bHasClientTickedOnce; } + // Identity token verification state (accessed from both recv and main threads) + std::atomic m_identityVerified; + std::atomic m_identityChallengeTick; + + // Security gate: buffer packets until cipher handshake completes + bool m_securityGateOpen; + vector> m_securityBuffer; + + bool isIdentityVerified() const { return m_identityVerified; } + int getIdentityChallengeTick() const { return m_identityChallengeTick; } + void setIdentityChallengeTick(int tick) { m_identityChallengeTick = tick; } + void setIdentityVerified(bool v) { m_identityVerified = v; } + bool isSecurityGateOpen() const { return m_securityGateOpen; } + void openSecurityGate(); + private: bool m_bCloseOnTick; vector m_texturesRequested; diff --git a/Minecraft.Client/PlayerList.cpp b/Minecraft.Client/PlayerList.cpp index af6f09a5..3d7e8f9c 100644 --- a/Minecraft.Client/PlayerList.cpp +++ b/Minecraft.Client/PlayerList.cpp @@ -43,7 +43,13 @@ #include "..\Minecraft.Server\ServerLogger.h" #include "..\Minecraft.Server\ServerLogManager.h" #include "..\Minecraft.Server\ServerProperties.h" +#include "..\Minecraft.Server\Security\SecurityConfig.h" +#include "..\Minecraft.Server\Security\ConnectionCipher.h" +#include "..\Minecraft.Server\Security\CipherHandshakeEnforcer.h" +#include "..\Minecraft.Server\Security\IdentityTokenManager.h" extern bool g_Win64DedicatedServer; +static unsigned int s_playerListTickCount = 0; +static const int kIdentityResponseGraceTicks = 200; // 10 seconds at 20 TPS #endif // 4J - this class is fairly substantially altered as there didn't seem any point in porting code for banning, whitelisting, ops etc. @@ -267,6 +273,22 @@ bool PlayerList::placeNewPlayer(Connection *connection, shared_ptr app.DebugPrintf("RECONNECT: placeNewPlayer smallId=%d entityId=%d dim=%d\n", newSmallId, player->entityId, level->dimension->id); +#if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD) + // Close the security gate before sending any game data. All packets will be + // buffered until the cipher handshake completes, preventing unsecured clients + // from receiving XUIDs or game state during the grace period. + if (g_Win64DedicatedServer && + ServerRuntime::Security::GetSettings().enableStreamCipher && + ServerRuntime::Security::GetSettings().requireSecureClient) + { + INetworkPlayer *gateNp = connection->getSocket() ? connection->getSocket()->getPlayer() : nullptr; + if (gateNp != nullptr && !gateNp->IsLocal()) + { + playerConnection->m_securityGateOpen = false; + } + } +#endif + playerConnection->send(std::make_shared(L"", player->entityId, level->getLevelData()->getGenerator(), level->getSeed(), player->gameMode->getGameModeForPlayer()->getId(), @@ -338,6 +360,39 @@ bool PlayerList::placeNewPlayer(Connection *connection, shared_ptr } } } + +#if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD) + // Initiate stream cipher handshake if enabled. + // Send MC|CKey with the generated key. Old clients will ignore the unknown channel. + if (g_Win64DedicatedServer && ServerRuntime::Security::GetSettings().enableStreamCipher) + { + BYTE smallId = 0; + Socket *cipherSock = connection->getSocket(); + INetworkPlayer *cipherNp = cipherSock ? cipherSock->getPlayer() : nullptr; + if (cipherNp != nullptr && !cipherNp->IsLocal()) + { + smallId = cipherNp->GetSmallId(); + uint8_t key[ServerRuntime::Security::StreamCipher::KEY_SIZE]; + if (ServerRuntime::Security::GetCipherRegistry().PrepareKey(smallId, key)) + { + byteArray keyData(ServerRuntime::Security::StreamCipher::KEY_SIZE); + memcpy(keyData.data, key, ServerRuntime::Security::StreamCipher::KEY_SIZE); + playerConnection->send(std::make_shared( + CustomPayloadPacket::CIPHER_KEY_CHANNEL, keyData)); + SecureZeroMemory(key, sizeof(key)); + app.DebugPrintf("Server: Sent MC|CKey to player %ls (smallId=%d)\n", + player->getName().c_str(), smallId); + + // Register with enforcer for timeout tracking + if (ServerRuntime::Security::GetSettings().requireSecureClient) + { + ServerRuntime::Security::GetHandshakeEnforcer().OnCipherKeySent(smallId, s_playerListTickCount); + } + } + } + } +#endif + return true; } @@ -570,6 +625,16 @@ void PlayerList::move(shared_ptr player) void PlayerList::remove(shared_ptr player) { +#if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD) + if (g_Win64DedicatedServer && player->connection != nullptr) + { + INetworkPlayer *np = player->connection->getNetworkPlayer(); + if (np != nullptr) + { + ServerRuntime::Security::GetHandshakeEnforcer().OnDisconnected(np->GetSmallId()); + } + } +#endif save(player); //4J Stu - We don't want to save the map data for guests, so when we are sure that the player is gone delete the map if(player->isGuest()) playerIo->deleteMapFilesForPlayer(player); @@ -1038,6 +1103,131 @@ void PlayerList::repositionAcrossDimension(shared_ptr entity, int lastDi void PlayerList::tick() { +#if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD) + ++s_playerListTickCount; + + // Cipher handshake enforcement: kick clients that haven't completed the handshake + if (g_Win64DedicatedServer && + ServerRuntime::Security::GetSettings().enableStreamCipher && + ServerRuntime::Security::GetSettings().requireSecureClient) + { + std::vector expired; + std::vector completed; + ServerRuntime::Security::GetHandshakeEnforcer().CheckTimeouts(s_playerListTickCount, expired, completed); + + for (unsigned char smallId : expired) + { + app.DebugPrintf("SECURITY: Kicking unsecured client (smallId=%d) - cipher handshake timed out\n", smallId); + ServerRuntime::ServerLogManager::OnUnsecuredClientKicked(smallId); + EnterCriticalSection(&m_closePlayersCS); + m_smallIdsToClose.push_back(smallId); + LeaveCriticalSection(&m_closePlayersCS); + } + + // Report cipher completion and open security gate for all completed handshakes + for (unsigned char smallId : completed) + { + // Open the security gate -- flush buffered game packets now that cipher is active + for (auto &p : players) + { + if (p == nullptr || p->connection == nullptr) continue; + INetworkPlayer *np = p->connection->getNetworkPlayer(); + if (np != nullptr && np->GetSmallId() == smallId) + { + if (!p->connection->isSecurityGateOpen()) + { + p->connection->openSecurityGate(); + } + break; + } + } + + if (ServerRuntime::Security::GetSettings().requireChallengeToken) + { + ServerRuntime::ServerLogManager::OnCipherHandshakeCompleted(smallId); + } + else + { + ServerRuntime::ServerLogManager::OnCipherCompletedNoTokenRequired(smallId); + } + } + + // For newly-completed cipher handshakes, initiate identity token exchange + if (ServerRuntime::Security::GetSettings().requireChallengeToken) + { + for (unsigned char smallId : completed) + { + // Find the player by smallId + for (auto &p : players) + { + if (p == nullptr || p->connection == nullptr) continue; + INetworkPlayer *np = p->connection->getNetworkPlayer(); + if (np == nullptr || np->GetSmallId() != smallId) continue; + + PlayerUID xuid = p->connection->m_offlineXUID; + if (xuid == INVALID_XUID) xuid = p->connection->m_onlineXUID; + + if (p->connection->getIdentityChallengeTick() >= 0) + { + // Already challenged, skip + } + else if (ServerRuntime::Security::GetIdentityTokenManager().HasToken(xuid)) + { + // Returning player - challenge them + p->connection->send(std::make_shared( + CustomPayloadPacket::IDENTITY_TOKEN_CHALLENGE, byteArray())); + p->connection->setIdentityChallengeTick(s_playerListTickCount); + app.DebugPrintf("Server: Sent identity challenge to %ls (smallId=%d)\n", + p->getName().c_str(), smallId); + } + else + { + // New player - issue a token over the encrypted channel + uint8_t token[ServerRuntime::Security::IdentityTokenManager::TOKEN_SIZE]; + if (ServerRuntime::Security::GetIdentityTokenManager().IssueToken(xuid, token)) + { + byteArray tokenData(ServerRuntime::Security::IdentityTokenManager::TOKEN_SIZE); + memcpy(tokenData.data, token, ServerRuntime::Security::IdentityTokenManager::TOKEN_SIZE); + p->connection->send(std::make_shared( + CustomPayloadPacket::IDENTITY_TOKEN_ISSUE, tokenData)); + SecureZeroMemory(token, sizeof(token)); + p->connection->setIdentityVerified(true); + app.DebugPrintf("Server: Issued identity token to %ls (smallId=%d)\n", + p->getName().c_str(), smallId); + ServerRuntime::ServerLogManager::OnIdentityTokenIssued(smallId); + } + } + break; + } + } + + // Enforce identity token response timeout + for (auto &p : players) + { + if (p == nullptr || p->connection == nullptr) continue; + int challengeTick = p->connection->getIdentityChallengeTick(); + if (challengeTick >= 0 && !p->connection->isIdentityVerified() && + (s_playerListTickCount - challengeTick) > kIdentityResponseGraceTicks) + { + app.DebugPrintf("SECURITY: Kicking %ls - identity token response timed out\n", + p->getName().c_str()); + INetworkPlayer *npLog = p->connection->getNetworkPlayer(); + if (npLog != nullptr) + ServerRuntime::ServerLogManager::OnIdentityTokenTimeout(npLog->GetSmallId(), p->getName()); + p->connection->setIdentityChallengeTick(-1); // prevent re-queuing + INetworkPlayer *np = p->connection->getNetworkPlayer(); + if (np != nullptr) + { + EnterCriticalSection(&m_closePlayersCS); + m_smallIdsToClose.push_back(np->GetSmallId()); + LeaveCriticalSection(&m_closePlayersCS); + } + } + } + } + } +#endif + // 4J - brought changes to how often this is sent forward from 1.2.3 if (++sendAllPlayerInfoIn > SEND_PLAYER_INFO_INTERVAL) { diff --git a/Minecraft.Client/ServerConnection.cpp b/Minecraft.Client/ServerConnection.cpp index 0f96e032..ee4d6a60 100644 --- a/Minecraft.Client/ServerConnection.cpp +++ b/Minecraft.Client/ServerConnection.cpp @@ -10,6 +10,10 @@ #include "..\Minecraft.World\Socket.h" #include "..\Minecraft.World\net.minecraft.world.level.h" #include "MultiPlayerLevel.h" +#if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD) +#include "..\Minecraft.Server\Security\SecurityConfig.h" +#include "..\Minecraft.Server\ServerLogManager.h" +#endif ServerConnection::ServerConnection(MinecraftServer *server) { @@ -40,6 +44,17 @@ void ServerConnection::addPlayerConnection(shared_ptr uc) void ServerConnection::handleConnection(shared_ptr uc) { EnterCriticalSection(&pending_cs); +#if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD) + int maxPending = ServerRuntime::Security::GetSettings().maxPendingConnections; + if (maxPending > 0 && static_cast(pending.size()) >= maxPending) + { + LeaveCriticalSection(&pending_cs); + app.DebugPrintf("SECURITY: Rejecting connection, too many pending (%d/%d)\n", + static_cast(pending.size()), maxPending); + uc->disconnect(DisconnectPacket::eDisconnect_ServerFull); + return; + } +#endif pending.push_back(uc); LeaveCriticalSection(&pending_cs); } diff --git a/Minecraft.Client/Windows64/Network/WinsockNetLayer.cpp b/Minecraft.Client/Windows64/Network/WinsockNetLayer.cpp index 62cf1e8c..58e7a41f 100644 --- a/Minecraft.Client/Windows64/Network/WinsockNetLayer.cpp +++ b/Minecraft.Client/Windows64/Network/WinsockNetLayer.cpp @@ -11,6 +11,10 @@ #if defined(MINECRAFT_SERVER_BUILD) #include "..\..\..\Minecraft.Server\Access\Access.h" #include "..\..\..\Minecraft.Server\ServerLogManager.h" +#include "..\..\..\Minecraft.Server\ServerLogger.h" +#include "..\..\..\Minecraft.Server\Security\SecurityConfig.h" +#include "..\..\..\Minecraft.Server\Security\RateLimiter.h" +#include "..\..\..\Minecraft.Server\Security\ConnectionCipher.h" #endif #include "..\..\..\Minecraft.World\DisconnectPacket.h" #include "..\..\Minecraft.h" @@ -25,6 +29,28 @@ static bool RecvExact(SOCKET sock, BYTE* buf, int len); static bool TryGetNumericRemoteIp(const sockaddr_in &remoteAddress, std::string *outIp); #endif +// Raw serialized byte patterns for cipher handshake packets (CustomPayloadPacket ID 250). +// Used by recv threads to detect handshake messages at the byte level before packet parsing, +// enabling atomic cipher activation at the exact byte boundary. + +// MC|CAck: 7-char channel, empty payload. Client sends this; server recv thread matches it. +static const BYTE kCipherAckPattern[] = { + 0xFA, // packet ID 250 + 0x00, 0x07, // channel length = 7 + 0x00, 0x4D, 0x00, 0x43, 0x00, 0x7C, 0x00, 0x43, 0x00, 0x41, 0x00, 0x63, 0x00, 0x6B, // "MC|CAck" UTF-16BE + 0x00, 0x00 // data length = 0 +}; +static const int kCipherAckPatternSize = sizeof(kCipherAckPattern); // 19 + +// MC|COn: 6-char channel, empty payload. Client recv thread matches this from server. +static const BYTE kCipherOnPattern[] = { + 0xFA, // packet ID 250 + 0x00, 0x06, // channel length = 6 + 0x00, 0x4D, 0x00, 0x43, 0x00, 0x7C, 0x00, 0x43, 0x00, 0x4F, 0x00, 0x6E, // "MC|COn" UTF-16BE + 0x00, 0x00 // data length = 0 +}; +static const int kCipherOnPatternSize = sizeof(kCipherOnPattern); // 17 + SOCKET WinsockNetLayer::s_listenSocket = INVALID_SOCKET; SOCKET WinsockNetLayer::s_hostConnectionSocket = INVALID_SOCKET; HANDLE WinsockNetLayer::s_acceptThread = nullptr; @@ -78,6 +104,12 @@ int WinsockNetLayer::s_joinPort = 0; BYTE WinsockNetLayer::s_joinAssignedSmallId = 0; DisconnectPacket::eDisconnectReason WinsockNetLayer::s_joinRejectReason = DisconnectPacket::eDisconnect_Quitting; +ServerRuntime::Security::StreamCipher WinsockNetLayer::s_clientSendCipher; +ServerRuntime::Security::StreamCipher WinsockNetLayer::s_clientRecvCipher; +CRITICAL_SECTION WinsockNetLayer::s_clientCipherLock; +uint8_t WinsockNetLayer::s_clientPendingKey[ServerRuntime::Security::StreamCipher::KEY_SIZE] = {}; +bool WinsockNetLayer::s_clientKeyStored = false; + bool g_Win64MultiplayerHost = false; bool g_Win64MultiplayerJoin = false; int g_Win64MultiplayerPort = WIN64_NET_DEFAULT_PORT; @@ -106,6 +138,7 @@ bool WinsockNetLayer::Initialize() InitializeCriticalSection(&s_disconnectLock); InitializeCriticalSection(&s_freeSmallIdLock); InitializeCriticalSection(&s_smallIdToSocketLock); + InitializeCriticalSection(&s_clientCipherLock); for (int i = 0; i < 256; i++) s_smallIdToSocket[i] = INVALID_SOCKET; @@ -219,6 +252,8 @@ void WinsockNetLayer::Shutdown() s_freeSmallIds.clear(); LeaveCriticalSection(&s_freeSmallIdLock); + ResetClientCipher(); + DeleteCriticalSection(&s_clientCipherLock); DeleteCriticalSection(&s_sendLock); DeleteCriticalSection(&s_connectionsLock); DeleteCriticalSection(&s_advertiseLock); @@ -231,6 +266,163 @@ void WinsockNetLayer::Shutdown() } } +void WinsockNetLayer::StoreClientCipherKey(const uint8_t key[ServerRuntime::Security::StreamCipher::KEY_SIZE]) +{ + EnterCriticalSection(&s_clientCipherLock); + memcpy(s_clientPendingKey, key, ServerRuntime::Security::StreamCipher::KEY_SIZE); + s_clientKeyStored = true; + LeaveCriticalSection(&s_clientCipherLock); +} + +bool WinsockNetLayer::SendAckAndActivateClientSendCipher() +{ + if (s_hostConnectionSocket == INVALID_SOCKET) + return false; + + // Atomic: send the MC|CAck plaintext then activate the send cipher, all under s_sendLock. + // No other send can interleave between the ack and cipher activation. + EnterCriticalSection(&s_sendLock); + + // Write framed packet: 4-byte length header + ack pattern + BYTE header[4]; + header[0] = static_cast((kCipherAckPatternSize >> 24) & 0xFF); + header[1] = static_cast((kCipherAckPatternSize >> 16) & 0xFF); + header[2] = static_cast((kCipherAckPatternSize >> 8) & 0xFF); + header[3] = static_cast(kCipherAckPatternSize & 0xFF); + + bool ok = true; + int totalSent = 0; + while (ok && totalSent < 4) + { + int sent = send(s_hostConnectionSocket, (const char *)header + totalSent, 4 - totalSent, 0); + if (sent == SOCKET_ERROR || sent == 0) { ok = false; break; } + totalSent += sent; + } + totalSent = 0; + while (ok && totalSent < kCipherAckPatternSize) + { + int sent = send(s_hostConnectionSocket, (const char *)kCipherAckPattern + totalSent, kCipherAckPatternSize - totalSent, 0); + if (sent == SOCKET_ERROR || sent == 0) { ok = false; break; } + totalSent += sent; + } + + if (ok) + { + // Activate send cipher immediately after the ack is on the wire + EnterCriticalSection(&s_clientCipherLock); + s_clientSendCipher.Initialize(s_clientPendingKey); + LeaveCriticalSection(&s_clientCipherLock); + app.DebugPrintf("Client: Send cipher activated (MC|CAck sent)\n"); + } + else + { + // Partial send corrupts the stream - force disconnect to prevent desync + app.DebugPrintf("Client: MC|CAck send failed, closing connection\n"); + closesocket(s_hostConnectionSocket); + s_hostConnectionSocket = INVALID_SOCKET; + } + + LeaveCriticalSection(&s_sendLock); + return ok; +} + +void WinsockNetLayer::ActivateClientRecvCipher() +{ + EnterCriticalSection(&s_clientCipherLock); + s_clientRecvCipher.Initialize(s_clientPendingKey); + SecureZeroMemory(s_clientPendingKey, sizeof(s_clientPendingKey)); + s_clientKeyStored = false; + LeaveCriticalSection(&s_clientCipherLock); +} + +void WinsockNetLayer::ResetClientCipher() +{ + EnterCriticalSection(&s_clientCipherLock); + s_clientSendCipher.Reset(); + s_clientRecvCipher.Reset(); + SecureZeroMemory(s_clientPendingKey, sizeof(s_clientPendingKey)); + s_clientKeyStored = false; + LeaveCriticalSection(&s_clientCipherLock); +} + +bool WinsockNetLayer::TryEncryptClientOutgoing(uint8_t *data, int length) +{ + if (data == nullptr || length <= 0) + return false; + + EnterCriticalSection(&s_clientCipherLock); + bool active = s_clientSendCipher.IsActive(); + if (active) + { + s_clientSendCipher.Encrypt(data, length); + } + LeaveCriticalSection(&s_clientCipherLock); + return active; +} + +#if defined(MINECRAFT_SERVER_BUILD) +bool WinsockNetLayer::SendCOnAndCommitServerCipher(BYTE smallId) +{ + // Verify a pending key exists before sending MC|COn (prevents rogue ack from triggering spurious activation) + auto ®istry = ServerRuntime::Security::GetCipherRegistry(); + + SOCKET sock = GetSocketForSmallId(smallId); + if (sock == INVALID_SOCKET) + return false; + + // Verify a pending key exists before sending (rejects rogue acks) + if (!registry.HasPendingKey(smallId)) + { + app.DebugPrintf("Server: Ignoring MC|CAck for smallId=%d (no pending key)\n", smallId); + return false; + } + + // Atomic: send MC|COn plaintext then commit the cipher, all under s_sendLock. + // No other send to this smallId can happen between MC|COn and CommitCipher. + EnterCriticalSection(&s_sendLock); + + BYTE header[4]; + header[0] = static_cast((kCipherOnPatternSize >> 24) & 0xFF); + header[1] = static_cast((kCipherOnPatternSize >> 16) & 0xFF); + header[2] = static_cast((kCipherOnPatternSize >> 8) & 0xFF); + header[3] = static_cast(kCipherOnPatternSize & 0xFF); + + bool ok = true; + int totalSent = 0; + while (ok && totalSent < 4) + { + int sent = send(sock, (const char *)header + totalSent, 4 - totalSent, 0); + if (sent == SOCKET_ERROR || sent == 0) { ok = false; break; } + totalSent += sent; + } + totalSent = 0; + while (ok && totalSent < kCipherOnPatternSize) + { + int sent = send(sock, (const char *)kCipherOnPattern + totalSent, kCipherOnPatternSize - totalSent, 0); + if (sent == SOCKET_ERROR || sent == 0) { ok = false; break; } + totalSent += sent; + } + + if (ok) + { + // Commit AFTER the send - MC|COn is the last plaintext packet + registry.CommitCipher(smallId); + app.DebugPrintf("Server: Cipher committed for smallId=%d (MC|COn sent)\n", smallId); + } + else + { + // Partial send corrupts the stream - force close + app.DebugPrintf("Server: MC|COn send failed for smallId=%d, closing socket\n", smallId); + registry.CancelPending(smallId); + closesocket(sock); + ClearSocketForSmallId(smallId); + } + + LeaveCriticalSection(&s_sendLock); + return ok; +} +#endif + bool WinsockNetLayer::HostGame(int port, const char* bindIp) { if (!s_initialized && !Initialize()) return false; @@ -828,10 +1020,37 @@ bool WinsockNetLayer::SendToSmallId(BYTE targetSmallId, const void* data, int da { SOCKET sock = GetSocketForSmallId(targetSmallId); if (sock == INVALID_SOCKET) return false; + +#if defined(MINECRAFT_SERVER_BUILD) + // Encrypt outgoing data if a cipher is active for this connection. + // TryEncryptOutgoing atomically checks and encrypts under a single lock + // to avoid TOCTOU races with DeactivateCipher on disconnect. + if (g_Win64DedicatedServer && dataSize > 0) + { + std::vector buf(static_cast(data), + static_cast(data) + dataSize); + if (ServerRuntime::Security::GetCipherRegistry().TryEncryptOutgoing( + targetSmallId, buf.data(), dataSize)) + { + return SendOnSocket(sock, buf.data(), dataSize); + } + } +#endif return SendOnSocket(sock, data, dataSize); } else { + // Client sending to server - encrypt if send cipher is active + EnterCriticalSection(&s_clientCipherLock); + if (s_clientSendCipher.IsActive() && dataSize > 0) + { + std::vector buf(static_cast(data), + static_cast(data) + dataSize); + s_clientSendCipher.Encrypt(buf.data(), dataSize); + LeaveCriticalSection(&s_clientCipherLock); + return SendOnSocket(s_hostConnectionSocket, buf.data(), dataSize); + } + LeaveCriticalSection(&s_clientCipherLock); return SendOnSocket(s_hostConnectionSocket, data, dataSize); } } @@ -896,6 +1115,128 @@ static bool TryGetNumericRemoteIp(const sockaddr_in &remoteAddress, std::string *outIp = ip; return true; } + +enum EProxyParseResult +{ + eProxyParse_Success, // Valid PROXY TCP4 header, IP extracted + eProxyParse_Unknown, // Valid PROXY UNKNOWN header, no IP available + eProxyParse_Malformed, // Invalid header format + eProxyParse_Timeout, // Recv timed out + eProxyParse_SocketError // Socket error during read +}; + +/** + * Parse a PROXY protocol v1 header from the socket. + * Must be called immediately after accept(), before any game data is read. + * Sets a 5-second recv timeout, reads the header, restores timeout on all paths. + */ +static EProxyParseResult TryReadProxyProtocolHeader(SOCKET sock, std::string *outSrcIp) +{ + if (outSrcIp != nullptr) + outSrcIp->clear(); + + // Set 5-second recv timeout for the header read + DWORD timeout = 5000; + setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, (const char *)&timeout, sizeof(timeout)); + + auto restoreTimeout = [sock]() { + DWORD noTimeout = 0; + setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, (const char *)&noTimeout, sizeof(noTimeout)); + }; + + // Peek at first 6 bytes to check for "PROXY " prefix + char peekBuf[6]; + int peekResult = recv(sock, peekBuf, 6, MSG_PEEK); + if (peekResult == 0) + { + restoreTimeout(); + return eProxyParse_SocketError; + } + if (peekResult == SOCKET_ERROR) + { + restoreTimeout(); + int err = WSAGetLastError(); + return (err == WSAETIMEDOUT) ? eProxyParse_Timeout : eProxyParse_SocketError; + } + if (peekResult < 6 || memcmp(peekBuf, "PROXY ", 6) != 0) + { + restoreTimeout(); + return eProxyParse_Malformed; + } + + // Consume header byte-by-byte until \r\n (max 107 bytes per PROXY v1 spec) + char lineBuf[108] = {}; + int lineLen = 0; + bool foundEnd = false; + + while (lineLen < 107) + { + char ch; + int r = recv(sock, &ch, 1, 0); + if (r != 1) + { + restoreTimeout(); + int err = WSAGetLastError(); + return (r == SOCKET_ERROR && err == WSAETIMEDOUT) ? eProxyParse_Timeout : eProxyParse_SocketError; + } + lineBuf[lineLen++] = ch; + + if (lineLen >= 2 && lineBuf[lineLen - 2] == '\r' && lineBuf[lineLen - 1] == '\n') + { + foundEnd = true; + lineBuf[lineLen - 2] = '\0'; // null-terminate, strip \r\n + break; + } + } + + restoreTimeout(); + + if (!foundEnd) + { + return eProxyParse_Malformed; + } + + // Parse: "PROXY TCP4 " + // or: "PROXY UNKNOWN" + char *tokens[6] = {}; + int tokenCount = 0; + char *ctx = nullptr; + char *tok = strtok_s(lineBuf, " ", &ctx); + while (tok != nullptr && tokenCount < 6) + { + tokens[tokenCount++] = tok; + tok = strtok_s(nullptr, " ", &ctx); + } + + if (tokenCount < 2 || strcmp(tokens[0], "PROXY") != 0) + { + return eProxyParse_Malformed; + } + + if (strcmp(tokens[1], "UNKNOWN") == 0) + { + return eProxyParse_Unknown; + } + + if (strcmp(tokens[1], "TCP4") != 0 || tokenCount < 6) + { + return eProxyParse_Malformed; + } + + // Validate src_ip with inet_pton + struct in_addr addr; + if (inet_pton(AF_INET, tokens[2], &addr) != 1) + { + return eProxyParse_Malformed; + } + + if (outSrcIp != nullptr) + { + *outSrcIp = tokens[2]; + } + + return eProxyParse_Success; +} #endif void WinsockNetLayer::HandleDataReceived(BYTE fromSmallId, BYTE toSmallId, unsigned char* data, unsigned int dataSize) @@ -948,7 +1289,36 @@ DWORD WINAPI WinsockNetLayer::AcceptThreadProc(LPVOID param) #if defined(MINECRAFT_SERVER_BUILD) std::string remoteIp; - const bool hasRemoteIp = TryGetNumericRemoteIp(remoteAddress, &remoteIp); + bool hasRemoteIp = TryGetNumericRemoteIp(remoteAddress, &remoteIp); + + // PROXY protocol v1: parse real client IP from tunnel header + if (g_Win64DedicatedServer && ServerRuntime::Security::GetSettings().proxyProtocol) + { + std::string proxiedIp; + EProxyParseResult proxyResult = TryReadProxyProtocolHeader(clientSocket, &proxiedIp); + if (proxyResult == eProxyParse_Success) + { + ServerRuntime::LogInfof("network", "PROXY: real client IP %s (tunnel: %s)", + proxiedIp.c_str(), hasRemoteIp ? remoteIp.c_str() : "unknown"); + remoteIp = proxiedIp; + hasRemoteIp = true; + } + else if (proxyResult == eProxyParse_Unknown) + { + ServerRuntime::LogInfof("network", "PROXY: UNKNOWN header, keeping tunnel IP"); + } + else + { + ServerRuntime::LogWarnf("network", "PROXY: header parse failed (result=%d) from %s", + (int)proxyResult, hasRemoteIp ? remoteIp.c_str() : "unknown"); + const char *rejectIp = hasRemoteIp ? remoteIp.c_str() : "unknown"; + ServerRuntime::ServerLogManager::OnRejectedTcpConnection(rejectIp, + ServerRuntime::ServerLogManager::eTcpRejectReason_InvalidProxyHeader); + closesocket(clientSocket); + continue; + } + } + const char *remoteIpForLog = hasRemoteIp ? remoteIp.c_str() : "unknown"; if (g_Win64DedicatedServer) { @@ -960,6 +1330,22 @@ DWORD WINAPI WinsockNetLayer::AcceptThreadProc(LPVOID param) closesocket(clientSocket); continue; } + + // Rate limiting: reject connections that exceed the per-IP sliding window + if (hasRemoteIp) + { + const auto &secSettings = ServerRuntime::Security::GetSettings(); + bool allowed = ServerRuntime::Security::GetGlobalRateLimiter().AllowConnection( + remoteIp, + secSettings.rateLimitConnectionsPerWindow, + secSettings.rateLimitWindowSeconds * 1000); + if (!allowed) + { + ServerRuntime::ServerLogManager::OnRejectedTcpConnection(remoteIpForLog, ServerRuntime::ServerLogManager::eTcpRejectReason_RateLimited); + closesocket(clientSocket); + continue; + } + } } #endif @@ -1138,6 +1524,25 @@ DWORD WINAPI WinsockNetLayer::RecvThreadProc(LPVOID param) break; } +#if defined(MINECRAFT_SERVER_BUILD) + // Check for MC|CAck cipher handshake (raw byte match, before decryption). + // The ack is always plaintext - it's the last plaintext packet from the client. + if (g_Win64DedicatedServer && + packetSize == kCipherAckPatternSize && + memcmp(&recvBuf[0], kCipherAckPattern, kCipherAckPatternSize) == 0) + { + // Atomically send MC|COn plaintext then commit the cipher + SendCOnAndCommitServerCipher(clientSmallId); + continue; // consumed - do not pass to game packet handler + } + + // Decrypt incoming data if a cipher is active for this connection + if (g_Win64DedicatedServer) + { + ServerRuntime::Security::GetCipherRegistry().DecryptIncoming(clientSmallId, &recvBuf[0], packetSize); + } +#endif + HandleDataReceived(clientSmallId, s_hostSmallId, &recvBuf[0], packetSize); } @@ -1180,6 +1585,14 @@ bool WinsockNetLayer::PopDisconnectedSmallId(BYTE* outSmallId) void WinsockNetLayer::PushFreeSmallId(BYTE smallId) { +#if defined(MINECRAFT_SERVER_BUILD) + // Clean up any active cipher for this connection + if (g_Win64DedicatedServer) + { + ServerRuntime::Security::GetCipherRegistry().DeactivateCipher(smallId); + } +#endif + // SmallIds 0..(XUSER_MAX_COUNT-1) are permanently reserved for the host's // local pads and must never be recycled to remote clients. if (smallId < (BYTE)XUSER_MAX_COUNT) @@ -1416,10 +1829,29 @@ DWORD WINAPI WinsockNetLayer::ClientRecvThreadProc(LPVOID param) break; } + // Check for MC|COn cipher activation signal (raw byte match, before decryption). + // This is always sent in plaintext as the last plaintext packet from the server. + if (packetSize == kCipherOnPatternSize && + memcmp(&recvBuf[0], kCipherOnPattern, kCipherOnPatternSize) == 0) + { + ActivateClientRecvCipher(); + app.DebugPrintf("Client: Recv cipher activated (MC|COn received)\n"); + continue; // consumed - do not pass to game packet handler + } + + // Decrypt incoming data if recv cipher is active + EnterCriticalSection(&s_clientCipherLock); + if (s_clientRecvCipher.IsActive()) + { + s_clientRecvCipher.Decrypt(&recvBuf[0], packetSize); + } + LeaveCriticalSection(&s_clientCipherLock); + HandleDataReceived(s_hostSmallId, s_localSmallId, &recvBuf[0], packetSize); } s_connected = false; + ResetClientCipher(); return 0; } diff --git a/Minecraft.Client/Windows64/Network/WinsockNetLayer.h b/Minecraft.Client/Windows64/Network/WinsockNetLayer.h index f3c7e299..221fd688 100644 --- a/Minecraft.Client/Windows64/Network/WinsockNetLayer.h +++ b/Minecraft.Client/Windows64/Network/WinsockNetLayer.h @@ -9,6 +9,7 @@ #include #include "..\..\Common\Network\NetworkPlayerInterface.h" #include "..\..\..\Minecraft.World\DisconnectPacket.h" +#include "..\..\..\Minecraft.Server\Security\StreamCipher.h" #pragma comment(lib, "Ws2_32.lib") @@ -16,7 +17,7 @@ #define WIN64_NET_MAX_CLIENTS 255 #define WIN64_SMALLID_REJECT 0xFF #define WIN64_NET_RECV_BUFFER_SIZE 65536 -#define WIN64_NET_MAX_PACKET_SIZE (4 * 1024 * 1024) +#define WIN64_NET_MAX_PACKET_SIZE (512 * 1024) #define WIN64_LAN_DISCOVERY_PORT 25566 #define WIN64_LAN_BROADCAST_MAGIC 0x4D434C4E @@ -190,8 +191,38 @@ private: static BYTE s_splitScreenSmallId[XUSER_MAX_COUNT]; static HANDLE s_splitScreenRecvThread[XUSER_MAX_COUNT]; + // Client-side stream cipher (non-host only, one connection to server) + static ServerRuntime::Security::StreamCipher s_clientSendCipher; + static ServerRuntime::Security::StreamCipher s_clientRecvCipher; + static CRITICAL_SECTION s_clientCipherLock; + static uint8_t s_clientPendingKey[ServerRuntime::Security::StreamCipher::KEY_SIZE]; + static bool s_clientKeyStored; // protected by s_clientCipherLock + public: static void ClearSocketForSmallId(BYTE smallId); + + /** Store the cipher key received from the server. Does not activate yet. */ + static void StoreClientCipherKey(const uint8_t key[ServerRuntime::Security::StreamCipher::KEY_SIZE]); + + /** Send MC|CAck directly to socket then activate client send cipher. Atomic under s_sendLock. */ + static bool SendAckAndActivateClientSendCipher(); + + /** Activate client recv cipher. Called from ClientRecvThreadProc on MC|COn detection. */ + static void ActivateClientRecvCipher(); + + /** Reset client ciphers on disconnect. */ + static void ResetClientCipher(); + + /** + * Encrypt data in-place for client->server send if the client send cipher is active. + * Returns true if data was encrypted. Thread-safe. + */ + static bool TryEncryptClientOutgoing(uint8_t *data, int length); + +#if defined(MINECRAFT_SERVER_BUILD) + /** Atomically send MC|COn plaintext then commit server cipher. Called from RecvThreadProc. */ + static bool SendCOnAndCommitServerCipher(BYTE smallId); +#endif }; extern bool g_Win64MultiplayerHost; diff --git a/Minecraft.Client/cmake/sources/Common.cmake b/Minecraft.Client/cmake/sources/Common.cmake index 3936a9c3..ccac43ea 100644 --- a/Minecraft.Client/cmake/sources/Common.cmake +++ b/Minecraft.Client/cmake/sources/Common.cmake @@ -410,6 +410,8 @@ source_group("Windows64/Iggy/gdraw" FILES ${_MINECRAFT_CLIENT_COMMON_WINDOWS64_I set(_MINECRAFT_CLIENT_COMMON_WINDOWS64_NETWORK "${CMAKE_CURRENT_SOURCE_DIR}/Windows64/Network/WinsockNetLayer.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/Windows64/Network/WinsockNetLayer.h" + "${CMAKE_CURRENT_SOURCE_DIR}/../Minecraft.Server/Security/StreamCipher.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/../Minecraft.Server/Security/StreamCipher.h" ) source_group("Windows64/Network" FILES ${_MINECRAFT_CLIENT_COMMON_WINDOWS64_NETWORK}) diff --git a/Minecraft.Server/Access/Access.cpp b/Minecraft.Server/Access/Access.cpp index 5767a955..13ab67b1 100644 --- a/Minecraft.Server/Access/Access.cpp +++ b/Minecraft.Server/Access/Access.cpp @@ -27,6 +27,7 @@ namespace ServerRuntime std::mutex writeLock; std::shared_ptr banManager; std::shared_ptr whitelistManager; + std::shared_ptr opManager; bool whitelistEnabled = false; }; @@ -63,6 +64,18 @@ namespace ServerRuntime std::lock_guard stateLock(g_accessState.stateLock); g_accessState.whitelistManager = whitelistManager; } + + static std::shared_ptr GetOpManagerSnapshot() + { + std::lock_guard stateLock(g_accessState.stateLock); + return g_accessState.opManager; + } + + static void PublishOpManagerSnapshot(const std::shared_ptr &opManager) + { + std::lock_guard stateLock(g_accessState.stateLock); + g_accessState.opManager = opManager; + } } std::string FormatXuid(PlayerUID xuid) @@ -101,6 +114,7 @@ namespace ServerRuntime // Build the replacement manager privately so readers keep using the last published snapshot during disk I/O. std::shared_ptr banManager = std::make_shared(baseDirectory); std::shared_ptr whitelistManager = std::make_shared(baseDirectory); + std::shared_ptr opManager = std::make_shared(baseDirectory); if (!banManager->EnsureBanFilesExist()) { LogError("access", "failed to ensure dedicated server ban files exist"); @@ -111,6 +125,11 @@ namespace ServerRuntime LogError("access", "failed to ensure dedicated server whitelist file exists"); return false; } + if (!opManager->EnsureOpFileExists()) + { + LogError("access", "failed to ensure dedicated server ops file exists"); + return false; + } if (!banManager->Reload()) { @@ -122,15 +141,23 @@ namespace ServerRuntime LogError("access", "failed to load dedicated server whitelist file"); return false; } + if (!opManager->Reload()) + { + LogError("access", "failed to load dedicated server ops file"); + return false; + } std::vector playerEntries; std::vector ipEntries; std::vector whitelistEntries; + std::vector opEntries; banManager->SnapshotBannedPlayers(&playerEntries); banManager->SnapshotBannedIps(&ipEntries); whitelistManager->SnapshotWhitelistedPlayers(&whitelistEntries); + opManager->SnapshotOps(&opEntries); PublishBanManagerSnapshot(banManager); PublishWhitelistManagerSnapshot(whitelistManager); + PublishOpManagerSnapshot(opManager); { std::lock_guard stateLock(g_accessState.stateLock); g_accessState.whitelistEnabled = whitelistEnabled; @@ -138,10 +165,11 @@ namespace ServerRuntime LogInfof( "access", - "loaded %u player bans, %u ip bans, and %u whitelist entries (whitelist=%s)", + "loaded %u player bans, %u ip bans, %u whitelist entries, and %u ops (whitelist=%s)", (unsigned)playerEntries.size(), (unsigned)ipEntries.size(), (unsigned)whitelistEntries.size(), + (unsigned)opEntries.size(), whitelistEnabled ? "enabled" : "disabled"); return true; } @@ -151,6 +179,7 @@ namespace ServerRuntime std::lock_guard writeLock(g_accessState.writeLock); PublishBanManagerSnapshot(std::shared_ptr{}); PublishWhitelistManagerSnapshot(std::shared_ptr{}); + PublishOpManagerSnapshot(std::shared_ptr{}); std::lock_guard stateLock(g_accessState.stateLock); g_accessState.whitelistEnabled = false; } @@ -214,7 +243,9 @@ namespace ServerRuntime bool IsInitialized() { - return GetBanManagerSnapshot() != nullptr && GetWhitelistManagerSnapshot() != nullptr; + return GetBanManagerSnapshot() != nullptr + && GetWhitelistManagerSnapshot() != nullptr + && GetOpManagerSnapshot() != nullptr; } bool IsWhitelistEnabled() @@ -456,5 +487,111 @@ namespace ServerRuntime return whitelistManager->SnapshotWhitelistedPlayers(outEntries); } + + bool IsPlayerOp(PlayerUID xuid) + { + const std::string formatted = FormatXuid(xuid); + if (formatted.empty()) + { + return false; + } + + std::shared_ptr opManager = GetOpManagerSnapshot(); + return (opManager != nullptr) ? opManager->IsPlayerOp(formatted) : false; + } + + bool AddOp(PlayerUID xuid, const std::string &name, const OpMetadata &metadata) + { + const std::string formatted = FormatXuid(xuid); + if (formatted.empty()) + { + return false; + } + + std::lock_guard writeLock(g_accessState.writeLock); + std::shared_ptr current = GetOpManagerSnapshot(); + if (current == nullptr) + { + return false; + } + + auto opManager = std::make_shared(*current); + OpPlayerEntry entry; + entry.xuid = formatted; + entry.name = name; + entry.metadata = metadata; + if (!opManager->AddOp(entry)) + { + return false; + } + + PublishOpManagerSnapshot(opManager); + return true; + } + + bool RemoveOp(PlayerUID xuid) + { + const std::string formatted = FormatXuid(xuid); + if (formatted.empty()) + { + return false; + } + + std::lock_guard writeLock(g_accessState.writeLock); + std::shared_ptr current = GetOpManagerSnapshot(); + if (current == nullptr) + { + return false; + } + + auto opManager = std::make_shared(*current); + if (!opManager->RemoveOpByXuid(formatted)) + { + return false; + } + + PublishOpManagerSnapshot(opManager); + return true; + } + + bool ReloadOps() + { + std::lock_guard writeLock(g_accessState.writeLock); + const auto current = GetOpManagerSnapshot(); + if (current == nullptr) + { + return false; + } + + auto opManager = std::make_shared(*current); + if (!opManager->EnsureOpFileExists()) + { + return false; + } + if (!opManager->Reload()) + { + return false; + } + + PublishOpManagerSnapshot(opManager); + return true; + } + + bool SnapshotOps(std::vector *outEntries) + { + if (outEntries == nullptr) + { + return false; + } + + const auto opManager = GetOpManagerSnapshot(); + if (opManager == nullptr) + { + outEntries->clear(); + return false; + } + + return opManager->SnapshotOps(outEntries); + } } } diff --git a/Minecraft.Server/Access/Access.h b/Minecraft.Server/Access/Access.h index 80e61e55..17e66699 100644 --- a/Minecraft.Server/Access/Access.h +++ b/Minecraft.Server/Access/Access.h @@ -2,6 +2,7 @@ #include "BanManager.h" #include "WhitelistManager.h" +#include "OpManager.h" namespace ServerRuntime { @@ -14,6 +15,7 @@ namespace ServerRuntime void Shutdown(); bool Reload(); bool ReloadWhitelist(); + bool ReloadOps(); bool IsInitialized(); bool IsWhitelistEnabled(); void SetWhitelistEnabled(bool enabled); @@ -21,6 +23,7 @@ namespace ServerRuntime bool IsPlayerBanned(PlayerUID xuid); bool IsIpBanned(const std::string &ip); bool IsPlayerWhitelisted(PlayerUID xuid); + bool IsPlayerOp(PlayerUID xuid); bool AddPlayerBan(PlayerUID xuid, const std::string &name, const BanMetadata &metadata); bool AddIpBan(const std::string &ip, const BanMetadata &metadata); @@ -28,6 +31,8 @@ namespace ServerRuntime bool RemoveIpBan(const std::string &ip); bool AddWhitelistedPlayer(PlayerUID xuid, const std::string &name, const WhitelistMetadata &metadata); bool RemoveWhitelistedPlayer(PlayerUID xuid); + bool AddOp(PlayerUID xuid, const std::string &name, const OpMetadata &metadata); + bool RemoveOp(PlayerUID xuid); /** * Copies the current cached player bans for inspection or command output @@ -40,6 +45,7 @@ namespace ServerRuntime */ bool SnapshotBannedIps(std::vector *outEntries); bool SnapshotWhitelistedPlayers(std::vector *outEntries); + bool SnapshotOps(std::vector *outEntries); std::string FormatXuid(PlayerUID xuid); bool TryParseXuid(const std::string &text, PlayerUID *outXuid); diff --git a/Minecraft.Server/Access/OpManager.cpp b/Minecraft.Server/Access/OpManager.cpp new file mode 100644 index 00000000..b8b76d1e --- /dev/null +++ b/Minecraft.Server/Access/OpManager.cpp @@ -0,0 +1,284 @@ +#include "stdafx.h" + +#include "OpManager.h" + +#include "..\Common\AccessStorageUtils.h" +#include "..\Common\FileUtils.h" +#include "..\Common\StringUtils.h" +#include "..\ServerLogger.h" +#include "..\vendor\nlohmann\json.hpp" + +#include + +namespace ServerRuntime +{ + namespace Access + { + using OrderedJson = nlohmann::ordered_json; + + namespace + { + static const char *kOpFileName = "ops.json"; + } + + OpManager::OpManager(const std::string &baseDirectory) + : m_baseDirectory(baseDirectory.empty() ? "." : baseDirectory) + { + } + + bool OpManager::EnsureOpFileExists() const + { + const std::string path = GetOpFilePath(); + if (!AccessStorageUtils::EnsureJsonListFileExists(path)) + { + LogErrorf("access", "failed to create %s", path.c_str()); + return false; + } + return true; + } + + bool OpManager::Reload() + { + std::vector ops; + if (!LoadOps(&ops)) + { + return false; + } + + m_ops.swap(ops); + return true; + } + + bool OpManager::Save() const + { + std::vector ops; + return SnapshotOps(&ops) && SaveOps(ops); + } + + bool OpManager::LoadOps(std::vector *outEntries) const + { + if (outEntries == nullptr) + { + return false; + } + outEntries->clear(); + + std::string text; + const std::string path = GetOpFilePath(); + if (!FileUtils::ReadTextFile(path, &text)) + { + LogErrorf("access", "failed to read %s", path.c_str()); + return false; + } + + if (text.empty()) + { + text = "[]"; + } + + OrderedJson root; + try + { + root = OrderedJson::parse(StringUtils::StripUtf8Bom(text)); + } + catch (const nlohmann::json::exception &e) + { + LogErrorf("access", "failed to parse %s: %s", path.c_str(), e.what()); + return false; + } + + if (!root.is_array()) + { + LogErrorf("access", "failed to parse %s: root json value is not an array", path.c_str()); + return false; + } + + for (const auto &object : root) + { + if (!object.is_object()) + { + LogWarnf("access", "skipping op entry that is not an object in %s", path.c_str()); + continue; + } + + std::string rawXuid; + if (!AccessStorageUtils::TryGetStringField(object, "xuid", &rawXuid)) + { + LogWarnf("access", "skipping op entry without xuid in %s", path.c_str()); + continue; + } + + OpPlayerEntry entry; + entry.xuid = AccessStorageUtils::NormalizeXuid(rawXuid); + if (entry.xuid.empty()) + { + LogWarnf("access", "skipping op entry with empty xuid in %s", path.c_str()); + continue; + } + + AccessStorageUtils::TryGetStringField(object, "name", &entry.name); + AccessStorageUtils::TryGetStringField(object, "created", &entry.metadata.created); + AccessStorageUtils::TryGetStringField(object, "source", &entry.metadata.source); + + outEntries->push_back(entry); + } + + return true; + } + + bool OpManager::SaveOps(const std::vector &entries) const + { + OrderedJson root = OrderedJson::array(); + for (const auto &entry : entries) + { + OrderedJson object = OrderedJson::object(); + object["xuid"] = AccessStorageUtils::NormalizeXuid(entry.xuid); + object["name"] = entry.name; + object["created"] = entry.metadata.created; + object["source"] = entry.metadata.source; + root.push_back(object); + } + + const std::string path = GetOpFilePath(); + const std::string json = root.empty() ? std::string("[]\n") : (root.dump(2) + "\n"); + if (!FileUtils::WriteTextFileAtomic(path, json)) + { + LogErrorf("access", "failed to write %s", path.c_str()); + return false; + } + return true; + } + + const std::vector &OpManager::GetOps() const + { + return m_ops; + } + + bool OpManager::SnapshotOps(std::vector *outEntries) const + { + if (outEntries == nullptr) + { + return false; + } + + *outEntries = m_ops; + return true; + } + + bool OpManager::IsPlayerOp(const std::string &xuid) const + { + const auto normalized = AccessStorageUtils::NormalizeXuid(xuid); + if (normalized.empty()) + { + return false; + } + + return std::any_of( + m_ops.begin(), + m_ops.end(), + [&normalized](const OpPlayerEntry &entry) + { + return entry.xuid == normalized; + }); + } + + bool OpManager::AddOp(const OpPlayerEntry &entry) + { + std::vector updatedEntries; + if (!SnapshotOps(&updatedEntries)) + { + return false; + } + + auto normalized = entry; + normalized.xuid = AccessStorageUtils::NormalizeXuid(normalized.xuid); + if (normalized.xuid.empty()) + { + return false; + } + + const auto existing = std::find_if( + updatedEntries.begin(), + updatedEntries.end(), + [&normalized](const OpPlayerEntry &candidate) + { + return candidate.xuid == normalized.xuid; + }); + + if (existing != updatedEntries.end()) + { + *existing = normalized; + if (!SaveOps(updatedEntries)) + { + return false; + } + + m_ops.swap(updatedEntries); + return true; + } + + updatedEntries.push_back(normalized); + if (!SaveOps(updatedEntries)) + { + return false; + } + + m_ops.swap(updatedEntries); + return true; + } + + bool OpManager::RemoveOpByXuid(const std::string &xuid) + { + const auto normalized = AccessStorageUtils::NormalizeXuid(xuid); + if (normalized.empty()) + { + return false; + } + + std::vector updatedEntries; + if (!SnapshotOps(&updatedEntries)) + { + return false; + } + + const auto oldSize = updatedEntries.size(); + updatedEntries.erase( + std::remove_if( + updatedEntries.begin(), + updatedEntries.end(), + [&normalized](const OpPlayerEntry &entry) { return entry.xuid == normalized; }), + updatedEntries.end()); + + if (updatedEntries.size() == oldSize) + { + return false; + } + + if (!SaveOps(updatedEntries)) + { + return false; + } + + m_ops.swap(updatedEntries); + return true; + } + + std::string OpManager::GetOpFilePath() const + { + return BuildPath(kOpFileName); + } + + OpMetadata OpManager::BuildDefaultMetadata(const char *source) + { + OpMetadata metadata; + metadata.created = StringUtils::GetCurrentUtcTimestampIso8601(); + metadata.source = (source != nullptr) ? source : "Server"; + return metadata; + } + + std::string OpManager::BuildPath(const char *fileName) const + { + return AccessStorageUtils::BuildPathFromBaseDirectory(m_baseDirectory, fileName); + } + } +} diff --git a/Minecraft.Server/Access/OpManager.h b/Minecraft.Server/Access/OpManager.h new file mode 100644 index 00000000..22505f49 --- /dev/null +++ b/Minecraft.Server/Access/OpManager.h @@ -0,0 +1,61 @@ +#pragma once + +#include +#include + +namespace ServerRuntime +{ + namespace Access + { + struct OpMetadata + { + std::string created; + std::string source; + }; + + struct OpPlayerEntry + { + std::string xuid; + std::string name; + OpMetadata metadata; + }; + + /** + * Persistent OP (operator) list manager. + * + * Stores XUID-based operator entries in `ops.json`. + * Used as the authoritative source of truth for who has OP privileges, + * preventing in-memory-only OP escalation via crafted packets. + */ + class OpManager + { + public: + explicit OpManager(const std::string &baseDirectory = "."); + + bool EnsureOpFileExists() const; + bool Reload(); + bool Save() const; + + bool LoadOps(std::vector *outEntries) const; + bool SaveOps(const std::vector &entries) const; + + const std::vector &GetOps() const; + bool SnapshotOps(std::vector *outEntries) const; + + bool IsPlayerOp(const std::string &xuid) const; + bool AddOp(const OpPlayerEntry &entry); + bool RemoveOpByXuid(const std::string &xuid); + + std::string GetOpFilePath() const; + + static OpMetadata BuildDefaultMetadata(const char *source = "Server"); + + private: + std::string BuildPath(const char *fileName) const; + + private: + std::string m_baseDirectory; + std::vector m_ops; + }; + } +} diff --git a/Minecraft.Server/Console/ServerCliEngine.cpp b/Minecraft.Server/Console/ServerCliEngine.cpp index 82bbdcc8..7fded978 100644 --- a/Minecraft.Server/Console/ServerCliEngine.cpp +++ b/Minecraft.Server/Console/ServerCliEngine.cpp @@ -23,6 +23,7 @@ #include "commands\tp\CliCommandTp.h" #include "commands\weather\CliCommandWeather.h" #include "commands\whitelist\CliCommandWhitelist.h" +#include "commands\revoketoken\CliCommandRevokeToken.h" #include "..\Common\StringUtils.h" #include "..\ServerShutdown.h" #include "..\ServerLogger.h" @@ -100,6 +101,7 @@ namespace ServerRuntime m_registry->Register(std::unique_ptr(new CliCommandPardonIp())); m_registry->Register(std::unique_ptr(new CliCommandBanList())); m_registry->Register(std::unique_ptr(new CliCommandWhitelist())); + m_registry->Register(std::unique_ptr(new CliCommandRevokeToken())); m_registry->Register(std::unique_ptr(new CliCommandTp())); m_registry->Register(std::unique_ptr(new CliCommandTime())); m_registry->Register(std::unique_ptr(new CliCommandWeather())); diff --git a/Minecraft.Server/Console/commands/revoketoken/CliCommandRevokeToken.cpp b/Minecraft.Server/Console/commands/revoketoken/CliCommandRevokeToken.cpp new file mode 100644 index 00000000..9388b184 --- /dev/null +++ b/Minecraft.Server/Console/commands/revoketoken/CliCommandRevokeToken.cpp @@ -0,0 +1,85 @@ +#include "stdafx.h" + +#include "CliCommandRevokeToken.h" + +#include "..\..\ServerCliEngine.h" +#include "..\..\ServerCliParser.h" +#include "..\..\..\Access\Access.h" +#include "..\..\..\Security\IdentityTokenManager.h" +#include "..\..\..\ServerLogManager.h" + +namespace ServerRuntime +{ + const char *CliCommandRevokeToken::Name() const + { + return "revoketoken"; + } + + const char *CliCommandRevokeToken::Usage() const + { + return "revoketoken "; + } + + const char *CliCommandRevokeToken::Description() const + { + return "Revoke a player's identity token. They will be issued a new one on next login."; + } + + bool CliCommandRevokeToken::Execute(const ServerCliParsedLine &line, ServerCliEngine *engine) + { + if (line.tokens.size() < 2) + { + engine->LogWarn(std::string("Usage: ") + Usage()); + return false; + } + + PlayerUID xuid = INVALID_XUID; + + // Try parsing as XUID first + if (ServerRuntime::Access::TryParseXuid(line.tokens[1], &xuid)) + { + // Direct XUID + } + else + { + // Try name lookup from cache + std::vector cachedXuids; + int count = ServerRuntime::ServerLogManager::GetCachedXuids(line.tokens[1], &cachedXuids); + if (count == 0) + { + engine->LogWarn("Unknown player: " + line.tokens[1]); + engine->LogWarn("The player must have attempted to connect, or use: revoketoken "); + return false; + } + if (count > 1) + { + engine->LogWarn("Ambiguous: " + std::to_string(count) + " XUIDs seen for '" + line.tokens[1] + "':"); + for (size_t i = 0; i < cachedXuids.size(); ++i) + { + std::string label = (i == cachedXuids.size() - 1) ? " (most recent)" : ""; + engine->LogWarn(" " + ServerRuntime::Access::FormatXuid(cachedXuids[i]) + label); + } + engine->LogWarn("Re-run with the explicit XUID: revoketoken "); + return false; + } + xuid = cachedXuids.back(); + engine->LogInfo("Resolved '" + line.tokens[1] + "' to XUID " + ServerRuntime::Access::FormatXuid(xuid)); + } + + if (!ServerRuntime::Security::GetIdentityTokenManager().HasToken(xuid)) + { + engine->LogWarn("No identity token found for XUID " + ServerRuntime::Access::FormatXuid(xuid)); + return false; + } + + if (!ServerRuntime::Security::GetIdentityTokenManager().RevokeToken(xuid)) + { + engine->LogError("Failed to revoke token."); + return false; + } + + engine->LogInfo("Revoked identity token for XUID " + ServerRuntime::Access::FormatXuid(xuid) + + ". Player will receive a new token on next login."); + return true; + } +} diff --git a/Minecraft.Server/Console/commands/revoketoken/CliCommandRevokeToken.h b/Minecraft.Server/Console/commands/revoketoken/CliCommandRevokeToken.h new file mode 100644 index 00000000..89cf7ad1 --- /dev/null +++ b/Minecraft.Server/Console/commands/revoketoken/CliCommandRevokeToken.h @@ -0,0 +1,15 @@ +#pragma once + +#include "..\IServerCliCommand.h" + +namespace ServerRuntime +{ + class CliCommandRevokeToken : public IServerCliCommand + { + public: + virtual const char *Name() const; + virtual const char *Usage() const; + virtual const char *Description() const; + virtual bool Execute(const ServerCliParsedLine &line, ServerCliEngine *engine); + }; +} diff --git a/Minecraft.Server/Console/commands/whitelist/CliCommandWhitelist.cpp b/Minecraft.Server/Console/commands/whitelist/CliCommandWhitelist.cpp index 03724278..5ddf3938 100644 --- a/Minecraft.Server/Console/commands/whitelist/CliCommandWhitelist.cpp +++ b/Minecraft.Server/Console/commands/whitelist/CliCommandWhitelist.cpp @@ -7,6 +7,7 @@ #include "..\..\..\Access\Access.h" #include "..\..\..\Common\StringUtils.h" #include "..\..\..\ServerProperties.h" +#include "..\..\..\ServerLogManager.h" #include #include @@ -181,14 +182,44 @@ namespace ServerRuntime { if (line.tokens.size() < 3) { - engine->LogWarn("Usage: whitelist add [name ...]"); + engine->LogWarn("Usage: whitelist add [display name ...]"); return false; } PlayerUID xuid = INVALID_XUID; - if (!TryParseWhitelistXuid(line.tokens[2], engine, &xuid)) + std::string name; + + if (ServerRuntime::Access::TryParseXuid(line.tokens[2], &xuid)) { - return false; + // Argument is a XUID + name = StringUtils::JoinTokens(line.tokens, 3); + } + else + { + // Argument is a player name -- look up XUID from recent login cache + std::vector cachedXuids; + int count = ServerRuntime::ServerLogManager::GetCachedXuids(line.tokens[2], &cachedXuids); + if (count == 0) + { + engine->LogWarn("Unknown player: " + line.tokens[2]); + engine->LogWarn("The player must attempt to connect first so the server can learn their XUID."); + engine->LogWarn("Alternatively, use: whitelist add "); + return false; + } + if (count > 1) + { + engine->LogWarn("Ambiguous: " + std::to_string(count) + " different XUIDs have been seen for '" + line.tokens[2] + "':"); + for (size_t i = 0; i < cachedXuids.size(); ++i) + { + std::string label = (i == cachedXuids.size() - 1) ? " (most recent)" : ""; + engine->LogWarn(" " + ServerRuntime::Access::FormatXuid(cachedXuids[i]) + label); + } + engine->LogWarn("Re-run with the explicit XUID: whitelist add [name]"); + return false; + } + xuid = cachedXuids.back(); + name = line.tokens[2]; + engine->LogInfo("Resolved '" + name + "' to XUID " + ServerRuntime::Access::FormatXuid(xuid)); } if (ServerRuntime::Access::IsPlayerWhitelisted(xuid)) @@ -198,7 +229,6 @@ namespace ServerRuntime } const auto metadata = ServerRuntime::Access::WhitelistManager::BuildDefaultMetadata("Console"); - const auto name = StringUtils::JoinTokens(line.tokens, 3); if (!ServerRuntime::Access::AddWhitelistedPlayer(xuid, name, metadata)) { engine->LogError("Failed to write whitelist entry."); diff --git a/Minecraft.Server/Security/CipherHandshakeEnforcer.cpp b/Minecraft.Server/Security/CipherHandshakeEnforcer.cpp new file mode 100644 index 00000000..2052f94c --- /dev/null +++ b/Minecraft.Server/Security/CipherHandshakeEnforcer.cpp @@ -0,0 +1,60 @@ +#include "stdafx.h" +#include "CipherHandshakeEnforcer.h" +#include "ConnectionCipher.h" + +namespace ServerRuntime +{ + namespace Security + { + CipherHandshakeEnforcer::CipherHandshakeEnforcer() + { + memset(m_sentTick, 0, sizeof(m_sentTick)); + memset(m_tracked, 0, sizeof(m_tracked)); + } + + CipherHandshakeEnforcer::~CipherHandshakeEnforcer() + { + } + + void CipherHandshakeEnforcer::OnCipherKeySent(unsigned char smallId, unsigned int currentTick) + { + m_sentTick[smallId] = currentTick; + m_tracked[smallId] = true; + } + + void CipherHandshakeEnforcer::CheckTimeouts(unsigned int currentTick, + std::vector &outExpired, + std::vector &outCompleted) + { + auto ®istry = GetCipherRegistry(); + + for (int i = 0; i < MAX_CONNECTIONS; ++i) + { + if (!m_tracked[i]) + continue; + + if (registry.IsCipherActive(static_cast(i))) + { + outCompleted.push_back(static_cast(i)); + m_tracked[i] = false; + } + else if ((currentTick - m_sentTick[i]) > static_cast(kGraceTicks)) + { + outExpired.push_back(static_cast(i)); + m_tracked[i] = false; + } + } + } + + void CipherHandshakeEnforcer::OnDisconnected(unsigned char smallId) + { + m_tracked[smallId] = false; + } + + CipherHandshakeEnforcer &GetHandshakeEnforcer() + { + static CipherHandshakeEnforcer s_instance; + return s_instance; + } + } +} diff --git a/Minecraft.Server/Security/CipherHandshakeEnforcer.h b/Minecraft.Server/Security/CipherHandshakeEnforcer.h new file mode 100644 index 00000000..77baed3c --- /dev/null +++ b/Minecraft.Server/Security/CipherHandshakeEnforcer.h @@ -0,0 +1,64 @@ +#pragma once + +#ifdef _WINDOWS64 +#include +#endif + +#include + +namespace ServerRuntime +{ + namespace Security + { + /** + * Tracks pending cipher handshakes and kicks clients that don't complete + * within the grace period. + * + * When require-secure-client is enabled, old/unpatched clients that ignore + * MC|CKey are disconnected before they receive any PlayerInfoPacket data + * containing other players' XUIDs. + * + * Called from the main tick thread only (PlayerList::tick). + */ + class CipherHandshakeEnforcer + { + public: + // 5 seconds at 20 TPS. The security gate buffers all game data until + // cipher completes, so no data leaks regardless of grace period length. + // 5 seconds accommodates high-latency connections. + static const int kGraceTicks = 100; + + CipherHandshakeEnforcer(); + ~CipherHandshakeEnforcer(); + + CipherHandshakeEnforcer(const CipherHandshakeEnforcer &) = delete; + CipherHandshakeEnforcer &operator=(const CipherHandshakeEnforcer &) = delete; + + /** + * Register that MC|CKey was sent to this smallId at the given tick. + */ + void OnCipherKeySent(unsigned char smallId, unsigned int currentTick); + + /** + * Check for timed-out handshakes. Returns smallIds that exceeded the + * grace period without the cipher becoming active. Also returns + * smallIds that just completed (cipher became active) in outCompleted. + */ + void CheckTimeouts(unsigned int currentTick, + std::vector &outExpired, + std::vector &outCompleted); + + /** + * Clean up tracking for a disconnected connection. + */ + void OnDisconnected(unsigned char smallId); + + private: + static const int MAX_CONNECTIONS = 256; + unsigned int m_sentTick[MAX_CONNECTIONS]; // 0 = not tracked + bool m_tracked[MAX_CONNECTIONS]; + }; + + CipherHandshakeEnforcer &GetHandshakeEnforcer(); + } +} diff --git a/Minecraft.Server/Security/ConnectionCipher.cpp b/Minecraft.Server/Security/ConnectionCipher.cpp new file mode 100644 index 00000000..58e2a79c --- /dev/null +++ b/Minecraft.Server/Security/ConnectionCipher.cpp @@ -0,0 +1,115 @@ +#include "stdafx.h" +#include "ConnectionCipher.h" + +#include + +namespace ServerRuntime +{ + namespace Security + { + ConnectionCipherRegistry::ConnectionCipherRegistry() + { + InitializeCriticalSection(&m_lock); + memset(m_pending, 0, sizeof(m_pending)); + memset(m_pendingKeys, 0, sizeof(m_pendingKeys)); + } + + ConnectionCipherRegistry::~ConnectionCipherRegistry() + { + SecureZeroMemory(m_pendingKeys, sizeof(m_pendingKeys)); + DeleteCriticalSection(&m_lock); + } + + bool ConnectionCipherRegistry::PrepareKey(unsigned char smallId, uint8_t outKey[StreamCipher::KEY_SIZE]) + { + uint8_t key[StreamCipher::KEY_SIZE]; + if (!StreamCipher::GenerateKey(key)) + { + return false; + } + + EnterCriticalSection(&m_lock); + memcpy(m_pendingKeys[smallId], key, StreamCipher::KEY_SIZE); + m_pending[smallId] = true; + LeaveCriticalSection(&m_lock); + + memcpy(outKey, key, StreamCipher::KEY_SIZE); + SecureZeroMemory(key, sizeof(key)); + return true; + } + + bool ConnectionCipherRegistry::CommitCipher(unsigned char smallId) + { + EnterCriticalSection(&m_lock); + if (!m_pending[smallId]) + { + LeaveCriticalSection(&m_lock); + return false; + } + + m_ciphers[smallId].Initialize(m_pendingKeys[smallId]); + SecureZeroMemory(m_pendingKeys[smallId], StreamCipher::KEY_SIZE); + m_pending[smallId] = false; + LeaveCriticalSection(&m_lock); + return true; + } + + void ConnectionCipherRegistry::CancelPending(unsigned char smallId) + { + EnterCriticalSection(&m_lock); + SecureZeroMemory(m_pendingKeys[smallId], StreamCipher::KEY_SIZE); + m_pending[smallId] = false; + LeaveCriticalSection(&m_lock); + } + + bool ConnectionCipherRegistry::HasPendingKey(unsigned char smallId) const + { + EnterCriticalSection(&m_lock); + bool pending = m_pending[smallId]; + LeaveCriticalSection(&m_lock); + return pending; + } + + void ConnectionCipherRegistry::DeactivateCipher(unsigned char smallId) + { + EnterCriticalSection(&m_lock); + m_ciphers[smallId].Reset(); + SecureZeroMemory(m_pendingKeys[smallId], StreamCipher::KEY_SIZE); + m_pending[smallId] = false; + LeaveCriticalSection(&m_lock); + } + + bool ConnectionCipherRegistry::TryEncryptOutgoing(unsigned char smallId, uint8_t *data, int length) + { + EnterCriticalSection(&m_lock); + bool active = m_ciphers[smallId].IsActive(); + if (active) + { + m_ciphers[smallId].Encrypt(data, length); + } + LeaveCriticalSection(&m_lock); + return active; + } + + bool ConnectionCipherRegistry::IsCipherActive(unsigned char smallId) const + { + EnterCriticalSection(&m_lock); + bool active = m_ciphers[smallId].IsActive(); + LeaveCriticalSection(&m_lock); + return active; + } + + void ConnectionCipherRegistry::DecryptIncoming(unsigned char smallId, uint8_t *data, int length) + { + EnterCriticalSection(&m_lock); + m_ciphers[smallId].Decrypt(data, length); + LeaveCriticalSection(&m_lock); + } + + ConnectionCipherRegistry &GetCipherRegistry() + { + static ConnectionCipherRegistry s_instance; + return s_instance; + } + } +} diff --git a/Minecraft.Server/Security/ConnectionCipher.h b/Minecraft.Server/Security/ConnectionCipher.h new file mode 100644 index 00000000..bd3c294c --- /dev/null +++ b/Minecraft.Server/Security/ConnectionCipher.h @@ -0,0 +1,97 @@ +#pragma once + +#include "StreamCipher.h" + +#ifdef _WINDOWS64 +#include +#endif + +namespace ServerRuntime +{ + namespace Security + { + /** + * Per-connection cipher registry for the dedicated server. + * + * Handshake protocol (4-message, via CustomPayloadPacket): + * 1. Server calls PrepareKey(smallId) -> sends MC|CKey with key to client + * 2. Client stores key, sends MC|CAck, activates send cipher + * 3. Server recv thread detects MC|CAck -> calls SendCOnAndCommit which + * atomically sends MC|COn plaintext then calls CommitCipher(smallId) + * 4. Client recv thread detects MC|COn -> activates recv cipher + * + * Backwards compatible: old clients ignore MC|CKey, server never gets ack, + * cipher stays inactive. Old servers never send MC|CKey, client stays plaintext. + */ + class ConnectionCipherRegistry + { + public: + ConnectionCipherRegistry(); + ~ConnectionCipherRegistry(); + + ConnectionCipherRegistry(const ConnectionCipherRegistry &) = delete; + ConnectionCipherRegistry &operator=(const ConnectionCipherRegistry &) = delete; + ConnectionCipherRegistry(ConnectionCipherRegistry &&) = delete; + ConnectionCipherRegistry &operator=(ConnectionCipherRegistry &&) = delete; + + /** + * Generate a random key and store it in pending state for the given smallId. + * Does NOT activate the cipher. Call CommitCipher() after the client acks. + * Returns the generated key in outKey. + */ + bool PrepareKey(unsigned char smallId, uint8_t outKey[StreamCipher::KEY_SIZE]); + + /** + * Activate a previously prepared cipher. Called from the recv thread + * when the client's MC|CAck is detected by raw byte matching. + * Returns false if no key was pending for this smallId. + */ + bool CommitCipher(unsigned char smallId); + + /** + * Cancel a pending key (e.g., client disconnected before ack). + */ + void CancelPending(unsigned char smallId); + + /** + * Check if a key is pending for the given smallId (no side effects). + */ + bool HasPendingKey(unsigned char smallId) const; + + /** + * Deactivate the cipher and cancel any pending key for a disconnected connection. + */ + void DeactivateCipher(unsigned char smallId); + + /** + * Atomically check if cipher is active and encrypt outgoing data. + * Returns true if data was encrypted, false if cipher is inactive (data untouched). + */ + bool TryEncryptOutgoing(unsigned char smallId, uint8_t *data, int length); + + /** + * Check if the cipher is active (handshake completed) for a given smallId. + * Thread-safe, read-only query. + */ + bool IsCipherActive(unsigned char smallId) const; + + /** + * Decrypt incoming data from a specific connection. + * No-op if the cipher is not active for this connection. + */ + void DecryptIncoming(unsigned char smallId, uint8_t *data, int length); + + private: + static const int MAX_CONNECTIONS = 256; + StreamCipher m_ciphers[MAX_CONNECTIONS]; + bool m_pending[MAX_CONNECTIONS]; + uint8_t m_pendingKeys[MAX_CONNECTIONS][StreamCipher::KEY_SIZE]; + mutable CRITICAL_SECTION m_lock; + }; + + /** + * Global cipher registry singleton. + */ + ConnectionCipherRegistry &GetCipherRegistry(); + } +} diff --git a/Minecraft.Server/Security/IdentityTokenManager.cpp b/Minecraft.Server/Security/IdentityTokenManager.cpp new file mode 100644 index 00000000..3cba5eed --- /dev/null +++ b/Minecraft.Server/Security/IdentityTokenManager.cpp @@ -0,0 +1,280 @@ +#include "stdafx.h" +#include "IdentityTokenManager.h" +#include "StreamCipher.h" + +#include "..\Common\FileUtils.h" +#include "..\Common\StringUtils.h" +#include "..\ServerLogger.h" +#include "..\vendor\nlohmann\json.hpp" + +#include + +namespace ServerRuntime +{ + namespace Security + { + using OrderedJson = nlohmann::ordered_json; + + IdentityTokenManager::IdentityTokenManager() + : m_initialized(false) + { + InitializeCriticalSection(&m_lock); + } + + IdentityTokenManager::~IdentityTokenManager() + { + DeleteCriticalSection(&m_lock); + } + + static std::string BytesToBase64(const uint8_t *data, int length) + { + static const char kTable[] = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + std::string out; + out.reserve(((length + 2) / 3) * 4); + for (int i = 0; i < length; i += 3) + { + uint32_t n = static_cast(data[i]) << 16; + if (i + 1 < length) n |= static_cast(data[i + 1]) << 8; + if (i + 2 < length) n |= static_cast(data[i + 2]); + out.push_back(kTable[(n >> 18) & 0x3F]); + out.push_back(kTable[(n >> 12) & 0x3F]); + out.push_back((i + 1 < length) ? kTable[(n >> 6) & 0x3F] : '='); + out.push_back((i + 2 < length) ? kTable[n & 0x3F] : '='); + } + return out; + } + + static bool Base64ToBytes(const std::string &encoded, std::vector &out) + { + static const int kDecodeTable[128] = { + -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1, + -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1, + -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,62,-1,-1,-1,63, + 52,53,54,55,56,57,58,59,60,61,-1,-1,-1,-1,-1,-1, + -1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9,10,11,12,13,14, + 15,16,17,18,19,20,21,22,23,24,25,-1,-1,-1,-1,-1, + -1,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40, + 41,42,43,44,45,46,47,48,49,50,51,-1,-1,-1,-1,-1 + }; + out.clear(); + out.reserve(encoded.size() * 3 / 4); + uint32_t buf = 0; + int bits = 0; + for (char c : encoded) + { + if (c == '=') break; + if (c < 0 || c >= 128 || kDecodeTable[(int)c] < 0) return false; + buf = (buf << 6) | kDecodeTable[(int)c]; + bits += 6; + if (bits >= 8) + { + bits -= 8; + out.push_back(static_cast((buf >> bits) & 0xFF)); + } + } + return true; + } + + static std::string FormatXuid(PlayerUID xuid) + { + char buffer[32] = {}; + sprintf_s(buffer, sizeof(buffer), "0x%016llx", (unsigned long long)xuid); + return buffer; + } + + bool IdentityTokenManager::Initialize(const std::string &filePath) + { + EnterCriticalSection(&m_lock); + m_filePath = filePath; + m_tokens.clear(); + bool ok = Load(); + m_initialized = true; + LeaveCriticalSection(&m_lock); + + if (ok) + { + LogInfof("security", "loaded %u identity tokens from %s", + (unsigned)m_tokens.size(), filePath.c_str()); + } + else + { + LogInfof("security", "no existing identity tokens found, starting fresh"); + } + return true; + } + + void IdentityTokenManager::Shutdown() + { + EnterCriticalSection(&m_lock); + m_tokens.clear(); + m_initialized = false; + LeaveCriticalSection(&m_lock); + } + + bool IdentityTokenManager::HasToken(PlayerUID xuid) const + { + EnterCriticalSection(&m_lock); + bool found = m_tokens.find(xuid) != m_tokens.end(); + LeaveCriticalSection(&m_lock); + return found; + } + + bool IdentityTokenManager::GetToken(PlayerUID xuid, uint8_t outToken[TOKEN_SIZE]) const + { + EnterCriticalSection(&m_lock); + auto it = m_tokens.find(xuid); + if (it == m_tokens.end() || it->second.size() != TOKEN_SIZE) + { + LeaveCriticalSection(&m_lock); + return false; + } + memcpy(outToken, it->second.data(), TOKEN_SIZE); + LeaveCriticalSection(&m_lock); + return true; + } + + bool IdentityTokenManager::IssueToken(PlayerUID xuid, uint8_t outToken[TOKEN_SIZE]) + { + // Generate a random 32-byte token using two 16-byte CryptGenRandom calls + uint8_t token[TOKEN_SIZE]; + bool ok1 = StreamCipher::GenerateKey(token); + bool ok2 = StreamCipher::GenerateKey(token + StreamCipher::KEY_SIZE); + if (!ok1 || !ok2) + { + SecureZeroMemory(token, sizeof(token)); + return false; + } + + EnterCriticalSection(&m_lock); + m_tokens[xuid] = std::vector(token, token + TOKEN_SIZE); + bool saved = Save(); + LeaveCriticalSection(&m_lock); + + if (saved) + { + memcpy(outToken, token, TOKEN_SIZE); + SecureZeroMemory(token, sizeof(token)); + return true; + } + + SecureZeroMemory(token, sizeof(token)); + return false; + } + + bool IdentityTokenManager::VerifyToken(PlayerUID xuid, const uint8_t token[TOKEN_SIZE]) const + { + EnterCriticalSection(&m_lock); + auto it = m_tokens.find(xuid); + if (it == m_tokens.end() || it->second.size() != TOKEN_SIZE) + { + LeaveCriticalSection(&m_lock); + return false; + } + + // Constant-time comparison to prevent timing attacks + uint8_t diff = 0; + for (int i = 0; i < TOKEN_SIZE; ++i) + { + diff |= it->second[i] ^ token[i]; + } + LeaveCriticalSection(&m_lock); + return diff == 0; + } + + bool IdentityTokenManager::RevokeToken(PlayerUID xuid) + { + EnterCriticalSection(&m_lock); + auto it = m_tokens.find(xuid); + if (it == m_tokens.end()) + { + LeaveCriticalSection(&m_lock); + return false; + } + SecureZeroMemory(it->second.data(), it->second.size()); + m_tokens.erase(it); + bool saved = Save(); + LeaveCriticalSection(&m_lock); + return saved; + } + + bool IdentityTokenManager::Load() + { + std::string text; + if (!FileUtils::ReadTextFile(m_filePath, &text)) + { + return false; + } + + if (text.empty()) + { + return true; + } + + OrderedJson root; + try + { + root = OrderedJson::parse(StringUtils::StripUtf8Bom(text)); + } + catch (const nlohmann::json::exception &) + { + LogErrorf("security", "failed to parse %s", m_filePath.c_str()); + return false; + } + + if (!root.is_object() || !root.contains("tokens") || !root["tokens"].is_object()) + { + return true; + } + + for (auto it = root["tokens"].begin(); it != root["tokens"].end(); ++it) + { + const std::string &xuidStr = it.key(); + if (!it.value().is_string()) continue; + + unsigned long long parsed = 0; + if (!StringUtils::TryParseUnsignedLongLong(xuidStr, &parsed) || parsed == 0ULL) + continue; + + std::vector tokenBytes; + if (!Base64ToBytes(it.value().get(), tokenBytes)) + continue; + if (tokenBytes.size() != TOKEN_SIZE) + continue; + + m_tokens[static_cast(parsed)] = tokenBytes; + } + + return true; + } + + bool IdentityTokenManager::Save() const + { + OrderedJson root = OrderedJson::object(); + OrderedJson tokens = OrderedJson::object(); + + for (const auto &pair : m_tokens) + { + std::string xuidStr = FormatXuid(pair.first); + std::string tokenB64 = BytesToBase64(pair.second.data(), TOKEN_SIZE); + tokens[xuidStr] = tokenB64; + } + + root["tokens"] = tokens; + + std::string json = root.dump(2) + "\n"; + if (!FileUtils::WriteTextFileAtomic(m_filePath, json)) + { + LogErrorf("security", "failed to write %s", m_filePath.c_str()); + return false; + } + return true; + } + + IdentityTokenManager &GetIdentityTokenManager() + { + static IdentityTokenManager s_instance; + return s_instance; + } + } +} diff --git a/Minecraft.Server/Security/IdentityTokenManager.h b/Minecraft.Server/Security/IdentityTokenManager.h new file mode 100644 index 00000000..1fd6850d --- /dev/null +++ b/Minecraft.Server/Security/IdentityTokenManager.h @@ -0,0 +1,63 @@ +#pragma once + +#include +#include +#include +#include + +#ifdef _WINDOWS64 +#include +#endif + +typedef unsigned __int64 PlayerUID; + +namespace ServerRuntime +{ + namespace Security + { + /** + * Persistent XUID-to-token binding for identity verification. + * + * On first login, the server issues a random 32-byte token to the client + * over the encrypted cipher channel. The client stores it locally. + * On subsequent logins, the server challenges the client to present + * its stored token. Mismatch = kicked. + * + * This prevents XUID replay attacks: an attacker who steals a XUID + * still needs the token, which was only sent over the encrypted channel. + * + * Tokens are stored in `identity-tokens.json` and persist across restarts. + */ + class IdentityTokenManager + { + public: + static const int TOKEN_SIZE = 32; + + IdentityTokenManager(); + ~IdentityTokenManager(); + + IdentityTokenManager(const IdentityTokenManager &) = delete; + IdentityTokenManager &operator=(const IdentityTokenManager &) = delete; + + bool Initialize(const std::string &filePath); + void Shutdown(); + + bool HasToken(PlayerUID xuid) const; + bool GetToken(PlayerUID xuid, uint8_t outToken[TOKEN_SIZE]) const; + bool IssueToken(PlayerUID xuid, uint8_t outToken[TOKEN_SIZE]); + bool VerifyToken(PlayerUID xuid, const uint8_t token[TOKEN_SIZE]) const; + bool RevokeToken(PlayerUID xuid); + + private: + bool Load(); + bool Save() const; + + std::string m_filePath; + std::unordered_map> m_tokens; + mutable CRITICAL_SECTION m_lock; + bool m_initialized; + }; + + IdentityTokenManager &GetIdentityTokenManager(); + } +} diff --git a/Minecraft.Server/Security/RateLimiter.cpp b/Minecraft.Server/Security/RateLimiter.cpp new file mode 100644 index 00000000..a7a6d3ee --- /dev/null +++ b/Minecraft.Server/Security/RateLimiter.cpp @@ -0,0 +1,78 @@ +#include "stdafx.h" +#include "RateLimiter.h" + +namespace ServerRuntime +{ + namespace Security + { + RateLimiter::RateLimiter() + { + InitializeCriticalSection(&m_lock); + } + + RateLimiter::~RateLimiter() + { + DeleteCriticalSection(&m_lock); + } + + bool RateLimiter::AllowConnection(const std::string &ip, int maxPerWindow, int windowMs) + { + if (maxPerWindow <= 0 || windowMs <= 0) + { + return true; + } + + ULONGLONG now = GetTickCount64(); + ULONGLONG windowDuration = static_cast(windowMs); + + EnterCriticalSection(&m_lock); + + auto ×tamps = m_connectionTimes[ip]; + + // Remove timestamps outside the sliding window + while (!timestamps.empty() && (now - timestamps.front()) > windowDuration) + { + timestamps.pop_front(); + } + + bool allowed = timestamps.size() < static_cast(maxPerWindow); + if (allowed) + { + timestamps.push_back(now); + } + + LeaveCriticalSection(&m_lock); + return allowed; + } + + void RateLimiter::EvictStale(int evictionAgeMs) + { + ULONGLONG now = GetTickCount64(); + ULONGLONG evictionAge = static_cast(evictionAgeMs); + + EnterCriticalSection(&m_lock); + + auto it = m_connectionTimes.begin(); + while (it != m_connectionTimes.end()) + { + if (it->second.empty() || + (now - it->second.back()) > evictionAge) + { + it = m_connectionTimes.erase(it); + } + else + { + ++it; + } + } + + LeaveCriticalSection(&m_lock); + } + + RateLimiter &GetGlobalRateLimiter() + { + static RateLimiter s_instance; + return s_instance; + } + } +} diff --git a/Minecraft.Server/Security/RateLimiter.h b/Minecraft.Server/Security/RateLimiter.h new file mode 100644 index 00000000..aab7a5cb --- /dev/null +++ b/Minecraft.Server/Security/RateLimiter.h @@ -0,0 +1,47 @@ +#pragma once + +#include +#include +#include + +#ifdef _WINDOWS64 +#include +#endif + +namespace ServerRuntime +{ + namespace Security + { + class RateLimiter + { + public: + RateLimiter(); + ~RateLimiter(); + + RateLimiter(const RateLimiter &) = delete; + RateLimiter &operator=(const RateLimiter &) = delete; + RateLimiter(RateLimiter &&) = delete; + RateLimiter &operator=(RateLimiter &&) = delete; + + /** + * Returns true if the connection from this IP should be allowed. + * Returns false if the IP has exceeded maxPerWindow connections within windowMs milliseconds. + */ + bool AllowConnection(const std::string &ip, int maxPerWindow, int windowMs); + + /** + * Removes stale entries older than evictionAgeMs from the tracking map. + */ + void EvictStale(int evictionAgeMs = 300000); + + private: + CRITICAL_SECTION m_lock; + std::unordered_map> m_connectionTimes; + }; + + /** + * Global rate limiter instance for the dedicated server accept loop. + */ + RateLimiter &GetGlobalRateLimiter(); + } +} diff --git a/Minecraft.Server/Security/SecurityConfig.cpp b/Minecraft.Server/Security/SecurityConfig.cpp new file mode 100644 index 00000000..7cbe3cb5 --- /dev/null +++ b/Minecraft.Server/Security/SecurityConfig.cpp @@ -0,0 +1,27 @@ +#include "stdafx.h" +#include "SecurityConfig.h" + +namespace ServerRuntime +{ + namespace Security + { + namespace + { + // Initialized once from main() before any worker threads start. + // Default member initializers in SecuritySettings provide safe hardened + // defaults if GetSettings() is called before InitializeSettings(). + // This global must NOT be written after threads are running. + SecuritySettings g_settings; + } + + void InitializeSettings(const SecuritySettings &settings) + { + g_settings = settings; + } + + const SecuritySettings &GetSettings() + { + return g_settings; + } + } +} diff --git a/Minecraft.Server/Security/SecurityConfig.h b/Minecraft.Server/Security/SecurityConfig.h new file mode 100644 index 00000000..476dc6b2 --- /dev/null +++ b/Minecraft.Server/Security/SecurityConfig.h @@ -0,0 +1,22 @@ +#pragma once + +namespace ServerRuntime +{ + namespace Security + { + struct SecuritySettings + { + bool hidePlayerListPreLogin = true; + int rateLimitConnectionsPerWindow = 5; + int rateLimitWindowSeconds = 30; + int maxPendingConnections = 10; + bool requireChallengeToken = false; + bool enableStreamCipher = true; + bool requireSecureClient = true; + bool proxyProtocol = false; + }; + + void InitializeSettings(const SecuritySettings &settings); + const SecuritySettings &GetSettings(); + } +} diff --git a/Minecraft.Server/Security/StreamCipher.cpp b/Minecraft.Server/Security/StreamCipher.cpp new file mode 100644 index 00000000..acc7b4b8 --- /dev/null +++ b/Minecraft.Server/Security/StreamCipher.cpp @@ -0,0 +1,90 @@ +#include "stdafx.h" +#include "StreamCipher.h" + +#ifdef _WINDOWS64 +#include +#include +#pragma comment(lib, "Advapi32.lib") +#endif + +#include + +namespace ServerRuntime +{ + namespace Security + { + StreamCipher::StreamCipher() + : m_sendPos(0) + , m_recvPos(0) + , m_active(false) + { + memset(m_key, 0, sizeof(m_key)); + } + + void StreamCipher::Initialize(const uint8_t key[KEY_SIZE]) + { + memcpy(m_key, key, KEY_SIZE); + m_sendPos = 0; + m_recvPos = 0; + m_active = true; + } + + void StreamCipher::Reset() + { + SecureZeroMemory(m_key, sizeof(m_key)); + m_sendPos = 0; + m_recvPos = 0; + m_active = false; + } + + void StreamCipher::Encrypt(uint8_t *data, int length) + { + if (!m_active || data == nullptr || length <= 0) + { + return; + } + + for (int i = 0; i < length; ++i) + { + data[i] ^= m_key[m_sendPos]; + m_sendPos = (m_sendPos + 1) % KEY_SIZE; + } + } + + void StreamCipher::Decrypt(uint8_t *data, int length) + { + if (!m_active || data == nullptr || length <= 0) + { + return; + } + + for (int i = 0; i < length; ++i) + { + data[i] ^= m_key[m_recvPos]; + m_recvPos = (m_recvPos + 1) % KEY_SIZE; + } + } + + bool StreamCipher::GenerateKey(uint8_t outKey[KEY_SIZE]) + { +#ifdef _WINDOWS64 + HCRYPTPROV hProv = 0; + if (!CryptAcquireContext(&hProv, nullptr, nullptr, PROV_RSA_AES, CRYPT_VERIFYCONTEXT)) + { + return false; + } + + BOOL result = CryptGenRandom(hProv, KEY_SIZE, outKey); + CryptReleaseContext(hProv, 0); + return result != FALSE; +#else + // Fallback: not cryptographically random, but better than nothing + for (int i = 0; i < KEY_SIZE; ++i) + { + outKey[i] = static_cast(rand() & 0xFF); + } + return true; +#endif + } + } +} diff --git a/Minecraft.Server/Security/StreamCipher.h b/Minecraft.Server/Security/StreamCipher.h new file mode 100644 index 00000000..75343fa2 --- /dev/null +++ b/Minecraft.Server/Security/StreamCipher.h @@ -0,0 +1,70 @@ +#pragma once + +#include + +namespace ServerRuntime +{ + namespace Security + { + /** + * Lightweight XOR stream cipher for traffic obfuscation. + * + * This is NOT cryptographically secure. It prevents passive packet sniffing + * (e.g., Wireshark-based XUID harvesting) but does not protect against + * active man-in-the-middle attacks. For real encryption, use TLS via a + * reverse proxy (stunnel, nginx stream). + * + * Usage: + * 1. Server generates a random 16-byte key during PreLogin handshake + * 2. Key is sent to the client (in a SecurityHandshakePacket) + * 3. Both sides create a StreamCipher with the same key + * 4. All subsequent TCP traffic is XOR'd through the cipher + * 5. The cipher maintains separate send/recv rolling key positions + */ + class StreamCipher + { + public: + static const int KEY_SIZE = 16; + + StreamCipher(); + + /** + * Initialize with a key. Call before any encrypt/decrypt. + */ + void Initialize(const uint8_t key[KEY_SIZE]); + + /** + * XOR-encrypt data in place for sending. + * Advances the send key position. + */ + void Encrypt(uint8_t *data, int length); + + /** + * XOR-decrypt data in place after receiving. + * Advances the recv key position. + */ + void Decrypt(uint8_t *data, int length); + + /** + * Returns true if the cipher has been initialized with a key. + */ + bool IsActive() const { return m_active; } + + /** + * Reset to inactive state and securely wipe key material. + */ + void Reset(); + + /** + * Generates a cryptographically random key using CryptGenRandom (Windows). + */ + static bool GenerateKey(uint8_t outKey[KEY_SIZE]); + + private: + uint8_t m_key[KEY_SIZE]; + int m_sendPos; + int m_recvPos; + bool m_active; + }; + } +} diff --git a/Minecraft.Server/ServerLogManager.cpp b/Minecraft.Server/ServerLogManager.cpp index 84805f7e..0f1a51fe 100644 --- a/Minecraft.Server/ServerLogManager.cpp +++ b/Minecraft.Server/ServerLogManager.cpp @@ -7,6 +7,7 @@ #include #include +#include extern bool g_Win64DedicatedServer; @@ -26,6 +27,12 @@ namespace ServerRuntime { std::string remoteIp; std::string playerName; + PlayerUID offlineXuid = INVALID_XUID; + PlayerUID onlineXuid = INVALID_XUID; + bool isGuest = false; + bool cipherActive = false; + bool tokenIssued = false; + bool tokenVerified = false; }; /** @@ -36,6 +43,10 @@ namespace ServerRuntime { std::mutex stateLock; std::array entries; + + // Name->XUIDs cache from recent login attempts (case-insensitive name key) + // Multiple XUIDs per name for ambiguity detection + std::unordered_map> nameToXuidCache; }; ServerLogState g_serverLogState; @@ -54,6 +65,12 @@ namespace ServerRuntime entry->remoteIp.clear(); entry->playerName.clear(); + entry->offlineXuid = INVALID_XUID; + entry->onlineXuid = INVALID_XUID; + entry->isGuest = false; + entry->cipherActive = false; + entry->tokenIssued = false; + entry->tokenVerified = false; } static std::string NormalizeRemoteIp(const char *ip) @@ -148,6 +165,9 @@ namespace ServerRuntime case eTcpRejectReason_BannedIp: return "banned-ip"; case eTcpRejectReason_GameNotReady: return "game-not-ready"; case eTcpRejectReason_ServerFull: return "server-full"; + case eTcpRejectReason_RateLimited: return "rate-limited"; + case eTcpRejectReason_TooManyPending: return "too-many-pending"; + case eTcpRejectReason_InvalidProxyHeader: return "invalid-proxy-header"; default: return "unknown"; } } @@ -283,8 +303,17 @@ namespace ServerRuntime LogInfof("network", "accepted tcp connection from %s as smallId=%u", remoteIp.c_str(), (unsigned)smallId); } - // Once login succeeds, bind the resolved player name onto the cached transport entry. - void OnAcceptedPlayerLogin(unsigned char smallId, const std::wstring &playerName) + static std::string FormatXuidForLog(PlayerUID xuid) + { + if (xuid == INVALID_XUID) return "none"; + char buf[32] = {}; + sprintf_s(buf, sizeof(buf), "0x%016llx", (unsigned long long)xuid); + return buf; + } + + // Once login succeeds, bind the resolved player name and identity onto the cached transport entry. + void OnAcceptedPlayerLogin(unsigned char smallId, const std::wstring &playerName, + PlayerUID offlineXuid, PlayerUID onlineXuid, bool isGuest) { if (!IsDedicatedServerLoggingEnabled()) { @@ -297,13 +326,29 @@ namespace ServerRuntime std::lock_guard stateLock(g_serverLogState.stateLock); ConnectionLogEntry &entry = g_serverLogState.entries[smallId]; entry.playerName = playerNameUtf8; + entry.offlineXuid = offlineXuid; + entry.onlineXuid = onlineXuid; + entry.isGuest = isGuest; if (!entry.remoteIp.empty()) { remoteIp = entry.remoteIp; } } - LogInfof("network", "accepted player login: name=\"%s\" ip=%s smallId=%u", playerNameUtf8.c_str(), remoteIp.c_str(), (unsigned)smallId); + std::string xuidStr = FormatXuidForLog(offlineXuid); + std::string logMsg = "accepted player login: name=\"" + playerNameUtf8 + + "\" ip=" + remoteIp + + " xuid=" + xuidStr; + if (onlineXuid != INVALID_XUID && onlineXuid != offlineXuid) + { + logMsg += " online-xuid=" + FormatXuidForLog(onlineXuid); + } + if (isGuest) + { + logMsg += " guest=yes"; + } + logMsg += " smallId=" + std::to_string((unsigned)smallId); + LogInfof("network", "%s", logMsg.c_str()); } // Read the cached IP for the rejection log, then clear the slot because the player never fully joined. @@ -398,5 +443,234 @@ namespace ServerRuntime std::lock_guard stateLock(g_serverLogState.stateLock); ResetConnectionLogEntry(&g_serverLogState.entries[smallId]); } + + // ---- Security milestone tracking ---- + + static void TryEmitPlayerSecuredSummary(unsigned char smallId, const ConnectionLogEntry &entry) + { + // Only emit when cipher is confirmed active (the primary security gate) + if (!entry.cipherActive) return; + // If tokens are required, wait until token status is resolved + if (!entry.tokenIssued && !entry.tokenVerified) return; + + const char *tokenStatus = entry.tokenVerified ? "verified" : (entry.tokenIssued ? "issued" : "n/a"); + std::string xuidStr = FormatXuidForLog(entry.offlineXuid); + std::string logMsg = "player secured: name=\"" + entry.playerName + + "\" xuid=" + xuidStr + + " ip=" + (entry.remoteIp.empty() ? "unknown" : entry.remoteIp) + + " cipher=active token=" + tokenStatus; + if (entry.isGuest) + { + logMsg += " guest=yes"; + } + LogInfof("security", "%s", logMsg.c_str()); + } + + void OnCipherHandshakeCompleted(unsigned char smallId) + { + if (!IsDedicatedServerLoggingEnabled()) return; + + std::lock_guard stateLock(g_serverLogState.stateLock); + ConnectionLogEntry &entry = g_serverLogState.entries[smallId]; + entry.cipherActive = true; + + // If tokens are not required, emit summary now + // (check if player name is cached -- it should be by this point) + if (!entry.playerName.empty()) + { + // Defer: token status may still arrive. Summary emits from token methods + // or if tokens are disabled, we need to check config. + // For simplicity: always defer to token methods. If tokens are disabled, + // the caller in PlayerList.cpp will call a direct emit. + } + } + + void OnCipherCompletedNoTokenRequired(unsigned char smallId) + { + // Called when cipher completes and require-challenge-token is false + if (!IsDedicatedServerLoggingEnabled()) return; + + std::lock_guard stateLock(g_serverLogState.stateLock); + ConnectionLogEntry &entry = g_serverLogState.entries[smallId]; + entry.cipherActive = true; + + if (!entry.playerName.empty()) + { + std::string xuidStr = FormatXuidForLog(entry.offlineXuid); + LogInfof("security", "player secured: name=\"%s\" xuid=%s ip=%s cipher=active token=n/a%s", + entry.playerName.c_str(), xuidStr.c_str(), + entry.remoteIp.empty() ? "unknown" : entry.remoteIp.c_str(), + entry.isGuest ? " guest=yes" : ""); + } + } + + void OnIdentityTokenIssued(unsigned char smallId) + { + if (!IsDedicatedServerLoggingEnabled()) return; + + std::lock_guard stateLock(g_serverLogState.stateLock); + ConnectionLogEntry &entry = g_serverLogState.entries[smallId]; + entry.tokenIssued = true; + TryEmitPlayerSecuredSummary(smallId, entry); + } + + void OnIdentityTokenVerified(unsigned char smallId) + { + if (!IsDedicatedServerLoggingEnabled()) return; + + std::lock_guard stateLock(g_serverLogState.stateLock); + ConnectionLogEntry &entry = g_serverLogState.entries[smallId]; + entry.tokenVerified = true; + TryEmitPlayerSecuredSummary(smallId, entry); + } + + void OnIdentityTokenMismatch(unsigned char smallId, const std::wstring &playerName) + { + if (!IsDedicatedServerLoggingEnabled()) return; + + std::string name = NormalizePlayerName(playerName); + std::string ip("unknown"); + { + std::lock_guard stateLock(g_serverLogState.stateLock); + const auto &entry = g_serverLogState.entries[smallId]; + if (!entry.remoteIp.empty()) ip = entry.remoteIp; + } + LogWarnf("security", "identity token mismatch for player \"%s\" (ip=%s) - use: revoketoken %s", + name.c_str(), ip.c_str(), name.c_str()); + } + + void OnIdentityTokenTimeout(unsigned char smallId, const std::wstring &playerName) + { + if (!IsDedicatedServerLoggingEnabled()) return; + + std::string name = NormalizePlayerName(playerName); + std::string ip("unknown"); + { + std::lock_guard stateLock(g_serverLogState.stateLock); + const auto &entry = g_serverLogState.entries[smallId]; + if (!entry.remoteIp.empty()) ip = entry.remoteIp; + } + LogWarnf("security", "kicked player \"%s\" (ip=%s) - identity token response timed out", + name.c_str(), ip.c_str()); + } + + void OnUnsecuredClientKicked(unsigned char smallId) + { + if (!IsDedicatedServerLoggingEnabled()) return; + + std::string ip("unknown"); + { + std::lock_guard stateLock(g_serverLogState.stateLock); + const auto &entry = g_serverLogState.entries[smallId]; + if (!entry.remoteIp.empty()) ip = entry.remoteIp; + } + LogWarnf("security", "kicked unsecured client (smallId=%u, ip=%s) - cipher handshake timed out", + (unsigned)smallId, ip.c_str()); + } + + void OnXuidSpoofDetected(unsigned char smallId, const std::wstring &claimedName, + const char *newIp, const char *existingIp) + { + if (!IsDedicatedServerLoggingEnabled()) return; + + std::string name = NormalizePlayerName(claimedName); + LogWarnf("security", "XUID spoof suspected for \"%s\" - new IP %s conflicts with existing IP %s", + name.c_str(), + (newIp != nullptr) ? newIp : "unknown", + (existingIp != nullptr) ? existingIp : "unknown"); + } + + void OnUnauthorizedCommand(unsigned char smallId, const std::wstring &playerName, const char *action) + { + if (!IsDedicatedServerLoggingEnabled()) return; + + std::string name = NormalizePlayerName(playerName); + std::string ip("unknown"); + { + std::lock_guard stateLock(g_serverLogState.stateLock); + const auto &entry = g_serverLogState.entries[smallId]; + if (!entry.remoteIp.empty()) ip = entry.remoteIp; + } + LogWarnf("security", "non-OP player \"%s\" attempted %s (ip=%s)", + name.c_str(), (action != nullptr) ? action : "unknown-action", ip.c_str()); + } + + // ---- Name-to-XUID cache ---- + + // Normalize a player name for cache key consistency (lowercase + trim) + static std::string NormalizeNameKey(const std::string &name) + { + return StringUtils::ToLowerAscii(StringUtils::TrimAscii(name)); + } + + // Maximum entries in the name->XUID cache to prevent unbounded growth + static const size_t kMaxCacheEntries = 256; + // Maximum XUIDs tracked per name + static const size_t kMaxXuidsPerName = 8; + + void CachePlayerXuid(const std::wstring &playerName, PlayerUID xuid) + { + if (playerName.empty() || xuid == INVALID_XUID) + { + return; + } + + // Note: playerName is from the LoginPacket and is attacker-controlled. + // This cache is an operator convenience tool for `whitelist add `, + // not a security mechanism. The operator sees the resolved XUID and can + // verify it before whitelisting. Ambiguous names are blocked. + std::string key = NormalizeNameKey(StringUtils::WideToUtf8(playerName)); + + std::lock_guard stateLock(g_serverLogState.stateLock); + + // Evict oldest cache entry if at capacity + if (g_serverLogState.nameToXuidCache.size() >= kMaxCacheEntries && + g_serverLogState.nameToXuidCache.find(key) == g_serverLogState.nameToXuidCache.end()) + { + g_serverLogState.nameToXuidCache.erase(g_serverLogState.nameToXuidCache.begin()); + } + + auto &entries = g_serverLogState.nameToXuidCache[key]; + // Move matching XUID to the back (most recent) or append if new + for (auto it = entries.begin(); it != entries.end(); ++it) + { + if (*it == xuid) + { + entries.erase(it); + break; + } + } + entries.push_back(xuid); + // Cap per-name vector + while (entries.size() > kMaxXuidsPerName) + { + entries.erase(entries.begin()); + } + } + + int GetCachedXuids(const std::string &playerName, std::vector *outXuids) + { + if (playerName.empty()) + { + if (outXuids != nullptr) outXuids->clear(); + return 0; + } + + std::string key = NormalizeNameKey(playerName); + + std::lock_guard stateLock(g_serverLogState.stateLock); + auto it = g_serverLogState.nameToXuidCache.find(key); + if (it == g_serverLogState.nameToXuidCache.end() || it->second.empty()) + { + if (outXuids != nullptr) outXuids->clear(); + return 0; + } + + if (outXuids != nullptr) + { + *outXuids = it->second; + } + return static_cast(it->second.size()); + } } } diff --git a/Minecraft.Server/ServerLogManager.h b/Minecraft.Server/ServerLogManager.h index 1d4abfb5..6ee86a92 100644 --- a/Minecraft.Server/ServerLogManager.h +++ b/Minecraft.Server/ServerLogManager.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include "..\Minecraft.World\DisconnectPacket.h" @@ -17,7 +18,10 @@ namespace ServerRuntime { eTcpRejectReason_BannedIp = 0, eTcpRejectReason_GameNotReady, - eTcpRejectReason_ServerFull + eTcpRejectReason_ServerFull, + eTcpRejectReason_RateLimited, + eTcpRejectReason_TooManyPending, + eTcpRejectReason_InvalidProxyHeader }; /** @@ -89,10 +93,26 @@ namespace ServerRuntime void OnAcceptedTcpConnection(unsigned char smallId, const char *ip); /** - * Associates a player name with the connection and emits the accepted login log - * 接続にプレイヤー名を関連付けてログイン成功を記録 + * Associates a player name and identity with the connection and emits the accepted login log */ - void OnAcceptedPlayerLogin(unsigned char smallId, const std::wstring &playerName); + void OnAcceptedPlayerLogin(unsigned char smallId, const std::wstring &playerName, + PlayerUID offlineXuid = INVALID_XUID, PlayerUID onlineXuid = INVALID_XUID, bool isGuest = false); + + // Security milestone recording -- accumulates per-connection state for the + // consolidated "player secured" summary line + void OnCipherHandshakeCompleted(unsigned char smallId); + void OnCipherCompletedNoTokenRequired(unsigned char smallId); + void OnIdentityTokenIssued(unsigned char smallId); + void OnIdentityTokenVerified(unsigned char smallId); + void OnIdentityTokenTimeout(unsigned char smallId, const std::wstring &playerName); + + // Security warnings -- emit immediately to CLI + void OnIdentityTokenMismatch(unsigned char smallId, const std::wstring &playerName); + void OnIdentityTokenTimeout(unsigned char smallId, const std::wstring &playerName); + void OnUnsecuredClientKicked(unsigned char smallId); + void OnXuidSpoofDetected(unsigned char smallId, const std::wstring &claimedName, + const char *newIp, const char *existingIp); + void OnUnauthorizedCommand(unsigned char smallId, const std::wstring &playerName, const char *action); /** * Emits a named login rejection log and clears cached metadata for that smallId @@ -123,5 +143,21 @@ namespace ServerRuntime * 指定smallIdに紐づく接続キャッシュを消去 */ void ClearConnection(unsigned char smallId); + + /** + * Cache a player name -> XUID mapping from a login attempt (accepted or rejected). + * Used by `whitelist add ` to resolve names to XUIDs. + */ + void CachePlayerXuid(const std::wstring &playerName, PlayerUID xuid); + + /** + * Get all cached XUIDs for a player name (case-insensitive). + * Returns the number of distinct XUIDs seen. If > 1, the name is ambiguous + * and the operator should use an explicit XUID. + * + * Note: cached names are attacker-controlled (from LoginPacket). This cache + * is an operator convenience tool, not a security mechanism. + */ + int GetCachedXuids(const std::string &playerName, std::vector *outXuids); } } diff --git a/Minecraft.Server/ServerLogger.cpp b/Minecraft.Server/ServerLogger.cpp index 0c7c567f..c7a36a1a 100644 --- a/Minecraft.Server/ServerLogger.cpp +++ b/Minecraft.Server/ServerLogger.cpp @@ -7,10 +7,48 @@ #include #include #include +#include namespace ServerRuntime { static volatile LONG g_minLogLevel = (LONG)eServerLogLevel_Info; +static FILE *g_logFile = NULL; +static std::once_flag g_logFileOnce; + +static void OpenLogFile() +{ + if (g_logFile != NULL) + return; + + errno_t err = fopen_s(&g_logFile, "server.log", "a"); + if (err != 0 || g_logFile == NULL) + { + g_logFile = NULL; + printf("[ServerLogger] Warning: Could not open server.log for writing (errno=%d)\n", (int)err); + fflush(stdout); + } +} + +static void CloseLogFile() +{ + if (g_logFile != NULL) + { + fflush(g_logFile); + fclose(g_logFile); + g_logFile = NULL; + } +} + +static void EnsureLogFileInitialized() +{ + std::call_once(g_logFileOnce, []() { + OpenLogFile(); + if (g_logFile != NULL) + { + atexit(CloseLogFile); + } + }); +} static const char *NormalizeCategory(const char *category) { @@ -121,6 +159,14 @@ static void WriteLogLine(EServerLogLevel level, const char *category, const char SetConsoleTextAttribute(stdoutHandle, originalInfo.wAttributes); } + EnsureLogFileInitialized(); + if (g_logFile != NULL) + { + fprintf(g_logFile, "[%s][%s][%s] %s\n", + timestamp, LogLevelToString(level), safeCategory, safeMessage); + fflush(g_logFile); + } + linenoiseExternalWriteEnd(); } diff --git a/Minecraft.Server/ServerProperties.cpp b/Minecraft.Server/ServerProperties.cpp index 010dfd9f..cd036d02 100644 --- a/Minecraft.Server/ServerProperties.cpp +++ b/Minecraft.Server/ServerProperties.cpp @@ -82,7 +82,15 @@ static const ServerPropertyDefault kServerPropertyDefaults[] = { "spawn-monsters", "true" }, { "spawn-npcs", "true" }, { "tnt", "true" }, - { "trust-players", "true" } + { "trust-players", "true" }, + { "hide-player-list-prelogin", "true" }, + { "rate-limit-connections-per-window", "5" }, + { "rate-limit-window-seconds", "30" }, + { "max-pending-connections", "10" }, + { "require-challenge-token", "false" }, + { "enable-stream-cipher", "true" }, + { "require-secure-client", "true" }, + { "proxy-protocol", "false" } }; static std::string BoolToString(bool value) @@ -883,6 +891,15 @@ ServerPropertiesConfig LoadServerPropertiesConfig() config.maxBuildHeight = ReadNormalizedIntProperty(&merged, "max-build-height", 256, 64, 256, &shouldWrite); config.motd = ReadNormalizedStringProperty(&merged, "motd", "A Minecraft Server", 255, &shouldWrite); + config.hidePlayerListPreLogin = ReadNormalizedBoolProperty(&merged, "hide-player-list-prelogin", true, &shouldWrite); + config.rateLimitConnectionsPerWindow = ReadNormalizedIntProperty(&merged, "rate-limit-connections-per-window", 5, 1, 100, &shouldWrite); + config.rateLimitWindowSeconds = ReadNormalizedIntProperty(&merged, "rate-limit-window-seconds", 30, 5, 300, &shouldWrite); + config.maxPendingConnections = ReadNormalizedIntProperty(&merged, "max-pending-connections", 10, 1, 50, &shouldWrite); + config.requireChallengeToken = ReadNormalizedBoolProperty(&merged, "require-challenge-token", false, &shouldWrite); + config.enableStreamCipher = ReadNormalizedBoolProperty(&merged, "enable-stream-cipher", true, &shouldWrite); + config.requireSecureClient = ReadNormalizedBoolProperty(&merged, "require-secure-client", true, &shouldWrite); + config.proxyProtocol = ReadNormalizedBoolProperty(&merged, "proxy-protocol", false, &shouldWrite); + if (shouldWrite) { if (WriteServerPropertiesFile(kServerPropertiesPath, merged)) diff --git a/Minecraft.Server/ServerProperties.h b/Minecraft.Server/ServerProperties.h index c41f0b79..0e6f8813 100644 --- a/Minecraft.Server/ServerProperties.h +++ b/Minecraft.Server/ServerProperties.h @@ -80,6 +80,24 @@ namespace ServerRuntime /** `hardcore-ban-ip` — whether hardcore death bans include IP bans */ bool hardcoreBanIp; + /** security settings */ + /** `hide-player-list-prelogin` — strip XUIDs from PreLoginPacket response */ + bool hidePlayerListPreLogin; + /** `rate-limit-connections-per-window` — max TCP connections per IP within the rate limit window */ + int rateLimitConnectionsPerWindow; + /** `rate-limit-window-seconds` — sliding window duration for connection rate limiting */ + int rateLimitWindowSeconds; + /** `max-pending-connections` — max simultaneous pending (pre-login) connections */ + int maxPendingConnections; + /** `require-challenge-token` — reserved for future protocol extension (not yet enforced) */ + bool requireChallengeToken; + /** `enable-stream-cipher` — enable XOR stream cipher for traffic obfuscation */ + bool enableStreamCipher; + /** `require-secure-client` — kick clients that do not complete the cipher handshake */ + bool requireSecureClient; + /** `proxy-protocol` — parse PROXY protocol v1 headers from TCP tunnel (e.g. playit.gg) */ + bool proxyProtocol; + /** other MinecraftServer runtime settings */ int maxBuildHeight; std::string levelType; diff --git a/Minecraft.Server/Windows64/ServerMain.cpp b/Minecraft.Server/Windows64/ServerMain.cpp index 38a4c28e..c5ae1346 100644 --- a/Minecraft.Server/Windows64/ServerMain.cpp +++ b/Minecraft.Server/Windows64/ServerMain.cpp @@ -11,6 +11,9 @@ #include "..\ServerLogManager.h" #include "..\ServerProperties.h" #include "..\ServerShutdown.h" +#include "..\Security\SecurityConfig.h" +#include "..\Security\RateLimiter.h" +#include "..\Security\IdentityTokenManager.h" #include "..\WorldManager.h" #include "..\Console\ServerCli.h" #include "Tesselator.h" @@ -416,6 +419,41 @@ int main(int argc, char **argv) return 2; } accessShutdownGuard.Activate(); + + { + ServerRuntime::Security::SecuritySettings secSettings; + secSettings.hidePlayerListPreLogin = serverProperties.hidePlayerListPreLogin; + secSettings.rateLimitConnectionsPerWindow = serverProperties.rateLimitConnectionsPerWindow; + secSettings.rateLimitWindowSeconds = serverProperties.rateLimitWindowSeconds; + secSettings.maxPendingConnections = serverProperties.maxPendingConnections; + secSettings.requireChallengeToken = serverProperties.requireChallengeToken; + secSettings.enableStreamCipher = serverProperties.enableStreamCipher; + secSettings.requireSecureClient = serverProperties.requireSecureClient; + secSettings.proxyProtocol = serverProperties.proxyProtocol; + ServerRuntime::Security::InitializeSettings(secSettings); + LogInfof("startup", "Security: hide-player-list=%s, rate-limit=%d/%ds, max-pending=%d, challenge-token=%s, stream-cipher=%s, require-secure-client=%s", + secSettings.hidePlayerListPreLogin ? "true" : "false", + secSettings.rateLimitConnectionsPerWindow, + secSettings.rateLimitWindowSeconds, + secSettings.maxPendingConnections, + secSettings.requireChallengeToken ? "required" : "optional", + secSettings.enableStreamCipher ? "enabled" : "disabled", + secSettings.requireSecureClient ? "true" : "false"); + if (secSettings.proxyProtocol) + { + LogInfof("startup", "PROXY protocol: enabled (all connections must send PROXY v1 header)"); + } + if (secSettings.requireSecureClient && !secSettings.enableStreamCipher) + { + LogInfof("startup", "WARNING: require-secure-client is enabled but enable-stream-cipher is disabled -- secure client enforcement will have no effect"); + } + + if (secSettings.requireChallengeToken) + { + ServerRuntime::Security::GetIdentityTokenManager().Initialize("identity-tokens.json"); + } + } + LogInfof("startup", "LAN advertise: %s", serverProperties.lanAdvertise ? "enabled" : "disabled"); LogInfof("startup", "Whitelist: %s", serverProperties.whiteListEnabled ? "enabled" : "disabled"); LogInfof("startup", "Spawn protection radius: %d", serverProperties.spawnProtectionRadius); diff --git a/Minecraft.Server/cmake/sources/Common.cmake b/Minecraft.Server/cmake/sources/Common.cmake index 00991cee..c41e75d3 100644 --- a/Minecraft.Server/cmake/sources/Common.cmake +++ b/Minecraft.Server/cmake/sources/Common.cmake @@ -521,9 +521,27 @@ set(_MINECRAFT_SERVER_COMMON_SERVER_ACCESS "${CMAKE_CURRENT_SOURCE_DIR}/Access/BanManager.h" "${CMAKE_CURRENT_SOURCE_DIR}/Access/WhitelistManager.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/Access/WhitelistManager.h" + "${CMAKE_CURRENT_SOURCE_DIR}/Access/OpManager.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/Access/OpManager.h" ) source_group("Server/Access" FILES ${_MINECRAFT_SERVER_COMMON_SERVER_ACCESS}) +set(_MINECRAFT_SERVER_COMMON_SERVER_SECURITY + "${CMAKE_CURRENT_SOURCE_DIR}/Security/SecurityConfig.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/Security/SecurityConfig.h" + "${CMAKE_CURRENT_SOURCE_DIR}/Security/RateLimiter.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/Security/RateLimiter.h" + "${CMAKE_CURRENT_SOURCE_DIR}/Security/StreamCipher.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/Security/StreamCipher.h" + "${CMAKE_CURRENT_SOURCE_DIR}/Security/ConnectionCipher.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/Security/ConnectionCipher.h" + "${CMAKE_CURRENT_SOURCE_DIR}/Security/CipherHandshakeEnforcer.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/Security/CipherHandshakeEnforcer.h" + "${CMAKE_CURRENT_SOURCE_DIR}/Security/IdentityTokenManager.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/Security/IdentityTokenManager.h" +) +source_group("Server/Security" FILES ${_MINECRAFT_SERVER_COMMON_SERVER_SECURITY}) + set(_MINECRAFT_SERVER_COMMON_SERVER_COMMON "${CMAKE_CURRENT_SOURCE_DIR}/Common/AccessStorageUtils.h" "${CMAKE_CURRENT_SOURCE_DIR}/Common/FileUtils.cpp" @@ -585,6 +603,8 @@ set(_MINECRAFT_SERVER_COMMON_SERVER_CONSOLE_COMMANDS "${CMAKE_CURRENT_SOURCE_DIR}/Console/commands/weather/CliCommandWeather.h" "${CMAKE_CURRENT_SOURCE_DIR}/Console/commands/whitelist/CliCommandWhitelist.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/Console/commands/whitelist/CliCommandWhitelist.h" + "${CMAKE_CURRENT_SOURCE_DIR}/Console/commands/revoketoken/CliCommandRevokeToken.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/Console/commands/revoketoken/CliCommandRevokeToken.h" ) source_group("Server/Console/Commands" FILES ${_MINECRAFT_SERVER_COMMON_SERVER_CONSOLE_COMMANDS}) @@ -598,6 +618,7 @@ set(MINECRAFT_SERVER_COMMON ${_MINECRAFT_SERVER_COMMON_ROOT} ${_MINECRAFT_SERVER_COMMON_SERVER} ${_MINECRAFT_SERVER_COMMON_SERVER_ACCESS} + ${_MINECRAFT_SERVER_COMMON_SERVER_SECURITY} ${_MINECRAFT_SERVER_COMMON_SERVER_COMMON} ${_MINECRAFT_SERVER_COMMON_SERVER_CONSOLE} ${_MINECRAFT_SERVER_COMMON_SERVER_CONSOLE_COMMANDS} diff --git a/Minecraft.World/CustomPayloadPacket.cpp b/Minecraft.World/CustomPayloadPacket.cpp index e86f01de..2ef505cc 100644 --- a/Minecraft.World/CustomPayloadPacket.cpp +++ b/Minecraft.World/CustomPayloadPacket.cpp @@ -14,7 +14,16 @@ const wstring CustomPayloadPacket::SET_ADVENTURE_COMMAND_PACKET = L"MC|AdvCdm"; const wstring CustomPayloadPacket::SET_BEACON_PACKET = L"MC|Beacon"; const wstring CustomPayloadPacket::SET_ITEM_NAME_PACKET = L"MC|ItemName"; +const wstring CustomPayloadPacket::CIPHER_KEY_CHANNEL = L"MC|CKey"; +const wstring CustomPayloadPacket::CIPHER_ACK_CHANNEL = L"MC|CAck"; +const wstring CustomPayloadPacket::CIPHER_ON_CHANNEL = L"MC|COn"; + +const wstring CustomPayloadPacket::IDENTITY_TOKEN_ISSUE = L"MC|CTIssue"; +const wstring CustomPayloadPacket::IDENTITY_TOKEN_CHALLENGE = L"MC|CTChallenge"; +const wstring CustomPayloadPacket::IDENTITY_TOKEN_RESPONSE = L"MC|CTResponse"; + CustomPayloadPacket::CustomPayloadPacket() + : length(0) { } @@ -22,6 +31,7 @@ CustomPayloadPacket::CustomPayloadPacket(const wstring &identifier, byteArray da { this->identifier = identifier; this->data = data; + this->length = 0; if (data.data != nullptr) { diff --git a/Minecraft.World/CustomPayloadPacket.h b/Minecraft.World/CustomPayloadPacket.h index 82a3f6e2..b06951b8 100644 --- a/Minecraft.World/CustomPayloadPacket.h +++ b/Minecraft.World/CustomPayloadPacket.h @@ -17,6 +17,16 @@ public: static const wstring SET_BEACON_PACKET; static const wstring SET_ITEM_NAME_PACKET; + // Security: stream cipher handshake channels + static const wstring CIPHER_KEY_CHANNEL; // server->client: carries 16-byte key + static const wstring CIPHER_ACK_CHANNEL; // client->server: ack (empty payload) + static const wstring CIPHER_ON_CHANNEL; // server->client: activation signal (empty payload) + + // Security: identity token channels + static const wstring IDENTITY_TOKEN_ISSUE; // server->client: issue new 32-byte token + static const wstring IDENTITY_TOKEN_CHALLENGE; // server->client: request stored token + static const wstring IDENTITY_TOKEN_RESPONSE; // client->server: present stored token + wstring identifier; int length; byteArray data; diff --git a/Minecraft.World/Packet.cpp b/Minecraft.World/Packet.cpp index 05bf932d..989a8cea 100644 --- a/Minecraft.World/Packet.cpp +++ b/Minecraft.World/Packet.cpp @@ -401,20 +401,32 @@ void Packet::writeUtf(const wstring& value, DataOutputStream *dos) // throws IOE wstring Packet::readUtf(DataInputStream *dis, int maxLength) // throws IOException TODO 4J JEV, should this declare a throws? { + // Global safety cap to prevent memory exhaustion from malicious string lengths + static const int kMaxGlobalStringLength = 8192; + if (maxLength > kMaxGlobalStringLength) + { + maxLength = kMaxGlobalStringLength; + } short stringLength = dis->readShort(); - if (stringLength > maxLength || stringLength <= 0) + if (stringLength <= 0) { - return L""; - // throw new IOException( stream.str() ); + if (stringLength < 0) + { + app.DebugPrintf("SECURITY: readUtf received negative string length %d\n", stringLength); + } + return L""; } - if (stringLength < 0) + if (stringLength > maxLength) { - assert(false); - // throw new IOException(L"Received string length is less than zero! Weird string!"); + app.DebugPrintf("SECURITY: readUtf received string length %d exceeding max %d\n", stringLength, maxLength); + // Consume the declared bytes to keep the stream synchronized + dis->skip(static_cast(stringLength) * 2); + return L""; } wstring builder = L""; + builder.reserve(stringLength); for (int i = 0; i < stringLength; i++) { wchar_t rc = dis->readChar(); diff --git a/Minecraft.World/PreLoginPacket.cpp b/Minecraft.World/PreLoginPacket.cpp index ddcfe197..ab273aeb 100644 --- a/Minecraft.World/PreLoginPacket.cpp +++ b/Minecraft.World/PreLoginPacket.cpp @@ -6,7 +6,7 @@ -PreLoginPacket::PreLoginPacket() +PreLoginPacket::PreLoginPacket() { loginKey = L""; m_playerXuids = nullptr; @@ -20,7 +20,7 @@ PreLoginPacket::PreLoginPacket() m_netcodeVersion = 0; } -PreLoginPacket::PreLoginPacket(wstring userName) +PreLoginPacket::PreLoginPacket(wstring userName) { this->loginKey = userName; m_playerXuids = nullptr; @@ -34,7 +34,7 @@ PreLoginPacket::PreLoginPacket(wstring userName) m_netcodeVersion = 0; } -PreLoginPacket::PreLoginPacket(wstring userName, PlayerUID *playerXuids, DWORD playerCount, BYTE friendsOnlyBits, DWORD ugcPlayersVersion,char *pszUniqueSaveName, DWORD serverSettings, BYTE hostIndex, DWORD texturePackId) +PreLoginPacket::PreLoginPacket(wstring userName, PlayerUID *playerXuids, DWORD playerCount, BYTE friendsOnlyBits, DWORD ugcPlayersVersion,char *pszUniqueSaveName, DWORD serverSettings, BYTE hostIndex, DWORD texturePackId) { this->loginKey = userName; m_playerXuids = playerXuids; diff --git a/README.md b/README.md index 916a5a6a..a5549540 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,56 @@ This project is based on source code of Minecraft Legacy Console Edition v1.6.05 ## Latest: +### Dedicated Server Security Hardening + +The dedicated server now includes a comprehensive security system to protect against packet-sniffing attacks, XUID harvesting, privilege escalation, and bot flooding. All features are configurable in `server.properties`. Compatible with [playit.gg](https://playit.gg) -- enable `proxy-protocol=true` in your server.properties and enable PROXY Protocol v1 in your playit.gg tunnel settings to get per-player IP tracking, IP bans, and per-player rate limiting. + +**What's protected:** +- Player identities (XUIDs) are hidden from unauthenticated connections +- All game traffic is encrypted between secured clients and the server +- When `require-secure-client` and `enable-stream-cipher` are both enabled, old/unpatched clients are blocked before receiving any game data +- Server commands and privileges require persistent `ops.json` authorization +- Connection flooding is rate-limited per IP +- When `require-challenge-token` is enabled, returning players are verified with a persistent identity token + +**New `server.properties` keys:** + +| Key | Default | Description | +|-----|---------|-------------| +| `enable-stream-cipher` | `true` | Encrypt all game traffic with a per-session stream cipher | +| `require-secure-client` | `true` | Kick clients that don't complete the cipher handshake (blocks old clients) | +| `require-challenge-token` | `false` | Require identity token verification to prevent XUID impersonation | +| `proxy-protocol` | `false` | Parse PROXY protocol v1 headers for real client IPs behind a tunnel | +| `hide-player-list-prelogin` | `true` | Strip player XUIDs from the pre-login response | +| `rate-limit-connections-per-window` | `5` | Max TCP connections per IP within the rate limit window | +| `rate-limit-window-seconds` | `30` | Sliding window duration for rate limiting | +| `max-pending-connections` | `10` | Max simultaneous pre-login connections | + +**Recommended setup (especially for playit.gg):** + +```properties +enable-stream-cipher=true +require-secure-client=true +require-challenge-token=true +proxy-protocol=true +``` + +**New server commands:** + +| Command | Description | +|---------|-------------| +| `whitelist add ` | Whitelist a player by name (they must attempt to connect once first) | +| `revoketoken ` | Revoke a player's identity token (use if a player lost their token) | + +**Server logging:** +- A `server.log` file is now written alongside the server executable +- Security events appear in the CLI with `[security]` tags +- Each join shows a security summary: cipher status, token status, XUID, and real IP + +**Important:** When `require-secure-client=true` and `enable-stream-cipher=true`, only the secured client (`LCREWindows64.zip`) can connect. Old/upstream clients will be blocked before receiving any game data. Set both to `false` if you want to allow all clients. + +--- + Player list map icon color fix: - The colored map icon shown next to each player in the tab player list and teleport menu now matches their actual map marker color. Previously the icon was determined by a broken small-ID lookup that produced incorrect colors. The icon is now computed client-side using the same hash the map renderer uses, keyed by player name for reliable lookup