mirror of
https://github.com/neoStudiosLCE/neoLegacy.git
synced 2026-08-20 09:57:09 +00:00
feat: dedicated server security hardening
Comprehensive security system to protect against packet-sniffing attacks, XUID harvesting, privilege escalation, bot flooding, and XUID impersonation. - Stream cipher: per-session XOR cipher with 4-message handshake via CustomPayloadPacket (MC|CKey, MC|CAck, MC|COn). Negotiated per-connection, backwards compatible (old clients/servers fall back to plaintext). - Security gate: buffers all game data until cipher handshake completes, preventing unsecured clients from receiving any XUIDs or game state. - Cipher handshake enforcer: kicks clients that don't complete the handshake within 5 seconds (configurable via require-secure-client). - Identity tokens: persistent per-XUID tokens in identity-tokens.json, issued over the encrypted channel, verified on reconnect. Prevents XUID replay attacks. Client stores server-specific tokens. - PROXY protocol v1: parses real client IPs from playit.gg tunnel headers so rate limiting, IP bans, and XUID spoof detection work per-player. - Rate limiting: per-IP sliding window (default 5 connections/30s) with pending connection cap (default 10). - Privilege hardening: OP requires ops.json, live checks on every command and privilege packet. Host-only server settings changes. - XUID stripping: PreLoginPacket response sends INVALID_XUID placeholders. - Packet validation: readUtf global string cap, reduced max packet size, stream desync protection on oversized strings. - OpManager: persistent ops.json with XUID-based OP list. - Whitelist improvements: whitelist add accepts player names with ambiguity detection, XUID cache from login attempts. - revoketoken command: revoke identity tokens for players who lost theirs. - server.log: persistent log file written alongside console output with flush-per-write to survive crashes. - CLI security logging: consolidated per-join security summary with cipher status, token status, XUID, and real IP. Security warnings for kicks, spoofing, and unauthorized commands.
This commit is contained in:
parent
ed3fffcc6a
commit
ba3ebe666c
|
|
@ -58,6 +58,7 @@
|
||||||
#ifdef _WINDOWS64
|
#ifdef _WINDOWS64
|
||||||
#include "Xbox\Network\NetworkPlayerXbox.h"
|
#include "Xbox\Network\NetworkPlayerXbox.h"
|
||||||
#include "Common\Network\PlatformNetworkManagerStub.h"
|
#include "Common\Network\PlatformNetworkManagerStub.h"
|
||||||
|
#include "Windows64\Network\WinsockNetLayer.h"
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -3787,6 +3788,120 @@ void ClientConnection::handleSoundEvent(shared_ptr<LevelSoundPacket> packet)
|
||||||
|
|
||||||
void ClientConnection::handleCustomPayload(shared_ptr<CustomPayloadPacket> customPayloadPacket)
|
void ClientConnection::handleCustomPayload(shared_ptr<CustomPayloadPacket> 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<unsigned char>(*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>(
|
||||||
|
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>(
|
||||||
|
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>(
|
||||||
|
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)
|
if (CustomPayloadPacket::TRADER_LIST_PACKET.compare(customPayloadPacket->identifier) == 0)
|
||||||
{
|
{
|
||||||
ByteArrayInputStream bais(customPayloadPacket->data);
|
ByteArrayInputStream bais(customPayloadPacket->data);
|
||||||
|
|
|
||||||
|
|
@ -196,9 +196,29 @@ void IQNetPlayer::SendData(IQNetPlayer * player, const void* pvData, DWORD dwDat
|
||||||
{
|
{
|
||||||
if (!WinsockNetLayer::IsHosting() && !m_isRemote)
|
if (!WinsockNetLayer::IsHosting() && !m_isRemote)
|
||||||
{
|
{
|
||||||
|
// Client sending to server via local socket (bypasses SendToSmallId)
|
||||||
SOCKET sock = WinsockNetLayer::GetLocalSocket(m_smallId);
|
SOCKET sock = WinsockNetLayer::GetLocalSocket(m_smallId);
|
||||||
if (sock != INVALID_SOCKET)
|
if (sock != INVALID_SOCKET)
|
||||||
WinsockNetLayer::SendOnSocket(sock, pvData, dwDataSize);
|
{
|
||||||
|
// Encrypt if client send cipher is active
|
||||||
|
if (dwDataSize > 0)
|
||||||
|
{
|
||||||
|
std::vector<BYTE> buf(static_cast<const BYTE*>(pvData),
|
||||||
|
static_cast<const BYTE*>(pvData) + dwDataSize);
|
||||||
|
if (WinsockNetLayer::TryEncryptClientOutgoing(buf.data(), static_cast<int>(dwDataSize)))
|
||||||
|
{
|
||||||
|
WinsockNetLayer::SendOnSocket(sock, buf.data(), static_cast<int>(dwDataSize));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
WinsockNetLayer::SendOnSocket(sock, pvData, dwDataSize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
WinsockNetLayer::SendOnSocket(sock, pvData, dwDataSize);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
#if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD)
|
#if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD)
|
||||||
#include "..\Minecraft.Server\ServerLogManager.h"
|
#include "..\Minecraft.Server\ServerLogManager.h"
|
||||||
#include "..\Minecraft.Server\Access\Access.h"
|
#include "..\Minecraft.Server\Access\Access.h"
|
||||||
|
#include "..\Minecraft.Server\Security\SecurityConfig.h"
|
||||||
#include "..\Minecraft.World\Socket.h"
|
#include "..\Minecraft.World\Socket.h"
|
||||||
#endif
|
#endif
|
||||||
// #ifdef __PS3__
|
// #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 0
|
||||||
if (false)// server->onlineMode) // 4J - removed
|
if (false)// server->onlineMode) // 4J - removed
|
||||||
{
|
{
|
||||||
|
|
@ -203,6 +218,56 @@ void PendingConnection::handleLogin(shared_ptr<LoginPacket> packet)
|
||||||
duplicateXuid = true;
|
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;
|
bool bannedXuid = false;
|
||||||
if (loginXuid != INVALID_XUID)
|
if (loginXuid != INVALID_XUID)
|
||||||
{
|
{
|
||||||
|
|
@ -243,7 +308,11 @@ void PendingConnection::handleLogin(shared_ptr<LoginPacket> packet)
|
||||||
else if (!whitelistSatisfied)
|
else if (!whitelistSatisfied)
|
||||||
{
|
{
|
||||||
#if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD)
|
#if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD)
|
||||||
|
// Cache name->XUID so `whitelist add <name>` can resolve the XUID
|
||||||
|
ServerRuntime::ServerLogManager::CachePlayerXuid(name, loginXuid);
|
||||||
ServerRuntime::ServerLogManager::OnRejectedPlayerLogin(GetPendingConnectionSmallId(connection), name, ServerRuntime::ServerLogManager::eLoginRejectReason_NotWhitelisted);
|
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
|
#endif
|
||||||
disconnect(DisconnectPacket::eDisconnect_Banned);
|
disconnect(DisconnectPacket::eDisconnect_Banned);
|
||||||
}
|
}
|
||||||
|
|
@ -330,11 +399,17 @@ void PendingConnection::handleAcceptedLogin(shared_ptr<LoginPacket> packet)
|
||||||
PlayerUID playerXuid = packet->m_offlineXuid;
|
PlayerUID playerXuid = packet->m_offlineXuid;
|
||||||
if(playerXuid == INVALID_XUID) playerXuid = packet->m_onlineXuid;
|
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<ServerPlayer> playerEntity = server->getPlayers()->getPlayerForLogin(this, name, playerXuid,packet->m_onlineXuid);
|
shared_ptr<ServerPlayer> playerEntity = server->getPlayers()->getPlayerForLogin(this, name, playerXuid,packet->m_onlineXuid);
|
||||||
if (playerEntity != nullptr)
|
if (playerEntity != nullptr)
|
||||||
{
|
{
|
||||||
#if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD)
|
#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
|
#endif
|
||||||
server->getPlayers()->placeNewPlayer(connection, playerEntity, packet);
|
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
|
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
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,11 @@
|
||||||
#include "Options.h"
|
#include "Options.h"
|
||||||
#if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD)
|
#if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD)
|
||||||
#include "..\Minecraft.Server\ServerLogManager.h"
|
#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
|
#endif
|
||||||
|
|
||||||
namespace
|
namespace
|
||||||
|
|
@ -85,6 +90,9 @@ PlayerConnection::PlayerConnection(MinecraftServer *server, Connection *connecti
|
||||||
m_onlineXUID = INVALID_XUID;
|
m_onlineXUID = INVALID_XUID;
|
||||||
m_bHasClientTickedOnce = false;
|
m_bHasClientTickedOnce = false;
|
||||||
m_logSmallId = 0;
|
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.
|
// 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)
|
if (this->connection != NULL && this->connection->getSocket() != NULL)
|
||||||
|
|
@ -620,6 +628,22 @@ void PlayerConnection::onDisconnect(DisconnectPacket::eDisconnectReason reason,
|
||||||
LeaveCriticalSection(&done_cs);
|
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> packet)
|
void PlayerConnection::onUnhandledPacket(shared_ptr<Packet> packet)
|
||||||
{
|
{
|
||||||
// logger.warning(getClass() + " wasn't prepared to deal with a " + packet.getClass());
|
// logger.warning(getClass() + " wasn't prepared to deal with a " + packet.getClass());
|
||||||
|
|
@ -630,6 +654,39 @@ void PlayerConnection::send(shared_ptr<Packet> packet)
|
||||||
{
|
{
|
||||||
if( connection->getSocket() != nullptr )
|
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<CustomPayloadPacket>(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 ) )
|
if( !server->getPlayers()->canReceiveAllPackets( player ) )
|
||||||
{
|
{
|
||||||
// Check if we are allowed to send this packet type
|
// Check if we are allowed to send this packet type
|
||||||
|
|
@ -1070,10 +1127,19 @@ void PlayerConnection::handleServerSettingsChanged(shared_ptr<ServerSettingsChan
|
||||||
{
|
{
|
||||||
if(packet->action==ServerSettingsChangedPacket::HOST_IN_GAME_SETTINGS)
|
if(packet->action==ServerSettingsChangedPacket::HOST_IN_GAME_SETTINGS)
|
||||||
{
|
{
|
||||||
// Need to check that this player has permission to change each individual setting?
|
|
||||||
|
|
||||||
INetworkPlayer *networkPlayer = getNetworkPlayer();
|
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_FireSpreads, app.GetGameHostOption(packet->data,eGameHostOption_FireSpreads));
|
||||||
app.SetGameHostOption(eGameHostOption_TNT, app.GetGameHostOption(packet->data,eGameHostOption_TNT));
|
app.SetGameHostOption(eGameHostOption_TNT, app.GetGameHostOption(packet->data,eGameHostOption_TNT));
|
||||||
|
|
@ -1096,14 +1162,81 @@ void PlayerConnection::handleServerSettingsChanged(shared_ptr<ServerSettingsChan
|
||||||
void PlayerConnection::handleKickPlayer(shared_ptr<KickPlayerPacket> packet)
|
void PlayerConnection::handleKickPlayer(shared_ptr<KickPlayerPacket> packet)
|
||||||
{
|
{
|
||||||
INetworkPlayer *networkPlayer = getNetworkPlayer();
|
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);
|
server->getPlayers()->kickPlayerByShortId(packet->m_networkSmallId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void PlayerConnection::handleGameCommand(shared_ptr<GameCommandPacket> packet)
|
void PlayerConnection::handleGameCommand(shared_ptr<GameCommandPacket> 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<int>(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<int>(packet->command));
|
||||||
|
#endif
|
||||||
MinecraftServer::getInstance()->getCommandDispatcher()->performCommand(player, packet->command, packet->data);
|
MinecraftServer::getInstance()->getCommandDispatcher()->performCommand(player, packet->command, packet->data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1373,10 +1506,21 @@ void PlayerConnection::handleKeepAlive(shared_ptr<KeepAlivePacket> packet)
|
||||||
|
|
||||||
void PlayerConnection::handlePlayerInfo(shared_ptr<PlayerInfoPacket> packet)
|
void PlayerConnection::handlePlayerInfo(shared_ptr<PlayerInfoPacket> packet)
|
||||||
{
|
{
|
||||||
// Need to check that this player has permission to change each individual setting?
|
|
||||||
|
|
||||||
INetworkPlayer *networkPlayer = getNetworkPlayer();
|
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> serverPlayer;
|
shared_ptr<ServerPlayer> serverPlayer;
|
||||||
// Find the player being edited
|
// Find the player being edited
|
||||||
|
|
@ -1454,7 +1598,24 @@ void PlayerConnection::handlePlayerInfo(shared_ptr<PlayerInfoPacket> packet)
|
||||||
serverPlayer->setPlayerGamePrivilege(Player::ePlayerGamePrivilege_CanToggleClassicHunger,Player::getPlayerGamePrivilege(packet->m_playerPrivileges,Player::ePlayerGamePrivilege_CanToggleClassicHunger) );
|
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) );
|
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) );
|
serverPlayer->setPlayerGamePrivilege(Player::ePlayerGamePrivilege_Op,Player::getPlayerGamePrivilege(packet->m_playerPrivileges,Player::ePlayerGamePrivilege_Op) );
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1492,6 +1653,44 @@ void PlayerConnection::handlePlayerAbilities(shared_ptr<PlayerAbilitiesPacket> p
|
||||||
|
|
||||||
void PlayerConnection::handleCustomPayload(shared_ptr<CustomPayloadPacket> customPayloadPacket)
|
void PlayerConnection::handleCustomPayload(shared_ptr<CustomPayloadPacket> 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 0
|
||||||
if (CustomPayloadPacket.CUSTOM_BOOK_PACKET.equals(customPayloadPacket.identifier))
|
if (CustomPayloadPacket.CUSTOM_BOOK_PACKET.equals(customPayloadPacket.identifier))
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -137,6 +137,21 @@ public:
|
||||||
// 4J Added
|
// 4J Added
|
||||||
bool hasClientTickedOnce() { return m_bHasClientTickedOnce; }
|
bool hasClientTickedOnce() { return m_bHasClientTickedOnce; }
|
||||||
|
|
||||||
|
// Identity token verification state (accessed from both recv and main threads)
|
||||||
|
std::atomic<bool> m_identityVerified;
|
||||||
|
std::atomic<int> m_identityChallengeTick;
|
||||||
|
|
||||||
|
// Security gate: buffer packets until cipher handshake completes
|
||||||
|
bool m_securityGateOpen;
|
||||||
|
vector<shared_ptr<Packet>> 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:
|
private:
|
||||||
bool m_bCloseOnTick;
|
bool m_bCloseOnTick;
|
||||||
vector<wstring> m_texturesRequested;
|
vector<wstring> m_texturesRequested;
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,13 @@
|
||||||
#include "..\Minecraft.Server\ServerLogger.h"
|
#include "..\Minecraft.Server\ServerLogger.h"
|
||||||
#include "..\Minecraft.Server\ServerLogManager.h"
|
#include "..\Minecraft.Server\ServerLogManager.h"
|
||||||
#include "..\Minecraft.Server\ServerProperties.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;
|
extern bool g_Win64DedicatedServer;
|
||||||
|
static unsigned int s_playerListTickCount = 0;
|
||||||
|
static const int kIdentityResponseGraceTicks = 200; // 10 seconds at 20 TPS
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
// 4J - this class is fairly substantially altered as there didn't seem any point in porting code for banning, whitelisting, ops etc.
|
// 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<ServerPlayer>
|
||||||
app.DebugPrintf("RECONNECT: placeNewPlayer smallId=%d entityId=%d dim=%d\n",
|
app.DebugPrintf("RECONNECT: placeNewPlayer smallId=%d entityId=%d dim=%d\n",
|
||||||
newSmallId, player->entityId, level->dimension->id);
|
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<LoginPacket>(L"", player->entityId, level->getLevelData()->getGenerator(),
|
playerConnection->send(std::make_shared<LoginPacket>(L"", player->entityId, level->getLevelData()->getGenerator(),
|
||||||
level->getSeed(),
|
level->getSeed(),
|
||||||
player->gameMode->getGameModeForPlayer()->getId(),
|
player->gameMode->getGameModeForPlayer()->getId(),
|
||||||
|
|
@ -338,6 +360,39 @@ bool PlayerList::placeNewPlayer(Connection *connection, shared_ptr<ServerPlayer>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#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>(
|
||||||
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -570,6 +625,16 @@ void PlayerList::move(shared_ptr<ServerPlayer> player)
|
||||||
|
|
||||||
void PlayerList::remove(shared_ptr<ServerPlayer> player)
|
void PlayerList::remove(shared_ptr<ServerPlayer> 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);
|
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
|
//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);
|
if(player->isGuest()) playerIo->deleteMapFilesForPlayer(player);
|
||||||
|
|
@ -1038,6 +1103,131 @@ void PlayerList::repositionAcrossDimension(shared_ptr<Entity> entity, int lastDi
|
||||||
|
|
||||||
void PlayerList::tick()
|
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<unsigned char> expired;
|
||||||
|
std::vector<unsigned char> 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>(
|
||||||
|
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>(
|
||||||
|
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
|
// 4J - brought changes to how often this is sent forward from 1.2.3
|
||||||
if (++sendAllPlayerInfoIn > SEND_PLAYER_INFO_INTERVAL)
|
if (++sendAllPlayerInfoIn > SEND_PLAYER_INFO_INTERVAL)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,10 @@
|
||||||
#include "..\Minecraft.World\Socket.h"
|
#include "..\Minecraft.World\Socket.h"
|
||||||
#include "..\Minecraft.World\net.minecraft.world.level.h"
|
#include "..\Minecraft.World\net.minecraft.world.level.h"
|
||||||
#include "MultiPlayerLevel.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)
|
ServerConnection::ServerConnection(MinecraftServer *server)
|
||||||
{
|
{
|
||||||
|
|
@ -40,6 +44,17 @@ void ServerConnection::addPlayerConnection(shared_ptr<PlayerConnection> uc)
|
||||||
void ServerConnection::handleConnection(shared_ptr<PendingConnection> uc)
|
void ServerConnection::handleConnection(shared_ptr<PendingConnection> uc)
|
||||||
{
|
{
|
||||||
EnterCriticalSection(&pending_cs);
|
EnterCriticalSection(&pending_cs);
|
||||||
|
#if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD)
|
||||||
|
int maxPending = ServerRuntime::Security::GetSettings().maxPendingConnections;
|
||||||
|
if (maxPending > 0 && static_cast<int>(pending.size()) >= maxPending)
|
||||||
|
{
|
||||||
|
LeaveCriticalSection(&pending_cs);
|
||||||
|
app.DebugPrintf("SECURITY: Rejecting connection, too many pending (%d/%d)\n",
|
||||||
|
static_cast<int>(pending.size()), maxPending);
|
||||||
|
uc->disconnect(DisconnectPacket::eDisconnect_ServerFull);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
pending.push_back(uc);
|
pending.push_back(uc);
|
||||||
LeaveCriticalSection(&pending_cs);
|
LeaveCriticalSection(&pending_cs);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,10 @@
|
||||||
#if defined(MINECRAFT_SERVER_BUILD)
|
#if defined(MINECRAFT_SERVER_BUILD)
|
||||||
#include "..\..\..\Minecraft.Server\Access\Access.h"
|
#include "..\..\..\Minecraft.Server\Access\Access.h"
|
||||||
#include "..\..\..\Minecraft.Server\ServerLogManager.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
|
#endif
|
||||||
#include "..\..\..\Minecraft.World\DisconnectPacket.h"
|
#include "..\..\..\Minecraft.World\DisconnectPacket.h"
|
||||||
#include "..\..\Minecraft.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);
|
static bool TryGetNumericRemoteIp(const sockaddr_in &remoteAddress, std::string *outIp);
|
||||||
#endif
|
#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_listenSocket = INVALID_SOCKET;
|
||||||
SOCKET WinsockNetLayer::s_hostConnectionSocket = INVALID_SOCKET;
|
SOCKET WinsockNetLayer::s_hostConnectionSocket = INVALID_SOCKET;
|
||||||
HANDLE WinsockNetLayer::s_acceptThread = nullptr;
|
HANDLE WinsockNetLayer::s_acceptThread = nullptr;
|
||||||
|
|
@ -78,6 +104,12 @@ int WinsockNetLayer::s_joinPort = 0;
|
||||||
BYTE WinsockNetLayer::s_joinAssignedSmallId = 0;
|
BYTE WinsockNetLayer::s_joinAssignedSmallId = 0;
|
||||||
DisconnectPacket::eDisconnectReason WinsockNetLayer::s_joinRejectReason = DisconnectPacket::eDisconnect_Quitting;
|
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_Win64MultiplayerHost = false;
|
||||||
bool g_Win64MultiplayerJoin = false;
|
bool g_Win64MultiplayerJoin = false;
|
||||||
int g_Win64MultiplayerPort = WIN64_NET_DEFAULT_PORT;
|
int g_Win64MultiplayerPort = WIN64_NET_DEFAULT_PORT;
|
||||||
|
|
@ -106,6 +138,7 @@ bool WinsockNetLayer::Initialize()
|
||||||
InitializeCriticalSection(&s_disconnectLock);
|
InitializeCriticalSection(&s_disconnectLock);
|
||||||
InitializeCriticalSection(&s_freeSmallIdLock);
|
InitializeCriticalSection(&s_freeSmallIdLock);
|
||||||
InitializeCriticalSection(&s_smallIdToSocketLock);
|
InitializeCriticalSection(&s_smallIdToSocketLock);
|
||||||
|
InitializeCriticalSection(&s_clientCipherLock);
|
||||||
for (int i = 0; i < 256; i++)
|
for (int i = 0; i < 256; i++)
|
||||||
s_smallIdToSocket[i] = INVALID_SOCKET;
|
s_smallIdToSocket[i] = INVALID_SOCKET;
|
||||||
|
|
||||||
|
|
@ -219,6 +252,8 @@ void WinsockNetLayer::Shutdown()
|
||||||
s_freeSmallIds.clear();
|
s_freeSmallIds.clear();
|
||||||
LeaveCriticalSection(&s_freeSmallIdLock);
|
LeaveCriticalSection(&s_freeSmallIdLock);
|
||||||
|
|
||||||
|
ResetClientCipher();
|
||||||
|
DeleteCriticalSection(&s_clientCipherLock);
|
||||||
DeleteCriticalSection(&s_sendLock);
|
DeleteCriticalSection(&s_sendLock);
|
||||||
DeleteCriticalSection(&s_connectionsLock);
|
DeleteCriticalSection(&s_connectionsLock);
|
||||||
DeleteCriticalSection(&s_advertiseLock);
|
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<BYTE>((kCipherAckPatternSize >> 24) & 0xFF);
|
||||||
|
header[1] = static_cast<BYTE>((kCipherAckPatternSize >> 16) & 0xFF);
|
||||||
|
header[2] = static_cast<BYTE>((kCipherAckPatternSize >> 8) & 0xFF);
|
||||||
|
header[3] = static_cast<BYTE>(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<BYTE>((kCipherOnPatternSize >> 24) & 0xFF);
|
||||||
|
header[1] = static_cast<BYTE>((kCipherOnPatternSize >> 16) & 0xFF);
|
||||||
|
header[2] = static_cast<BYTE>((kCipherOnPatternSize >> 8) & 0xFF);
|
||||||
|
header[3] = static_cast<BYTE>(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)
|
bool WinsockNetLayer::HostGame(int port, const char* bindIp)
|
||||||
{
|
{
|
||||||
if (!s_initialized && !Initialize()) return false;
|
if (!s_initialized && !Initialize()) return false;
|
||||||
|
|
@ -828,10 +1020,37 @@ bool WinsockNetLayer::SendToSmallId(BYTE targetSmallId, const void* data, int da
|
||||||
{
|
{
|
||||||
SOCKET sock = GetSocketForSmallId(targetSmallId);
|
SOCKET sock = GetSocketForSmallId(targetSmallId);
|
||||||
if (sock == INVALID_SOCKET) return false;
|
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<BYTE> buf(static_cast<const BYTE*>(data),
|
||||||
|
static_cast<const BYTE*>(data) + dataSize);
|
||||||
|
if (ServerRuntime::Security::GetCipherRegistry().TryEncryptOutgoing(
|
||||||
|
targetSmallId, buf.data(), dataSize))
|
||||||
|
{
|
||||||
|
return SendOnSocket(sock, buf.data(), dataSize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
return SendOnSocket(sock, data, dataSize);
|
return SendOnSocket(sock, data, dataSize);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
// Client sending to server - encrypt if send cipher is active
|
||||||
|
EnterCriticalSection(&s_clientCipherLock);
|
||||||
|
if (s_clientSendCipher.IsActive() && dataSize > 0)
|
||||||
|
{
|
||||||
|
std::vector<BYTE> buf(static_cast<const BYTE*>(data),
|
||||||
|
static_cast<const BYTE*>(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);
|
return SendOnSocket(s_hostConnectionSocket, data, dataSize);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -896,6 +1115,128 @@ static bool TryGetNumericRemoteIp(const sockaddr_in &remoteAddress, std::string
|
||||||
*outIp = ip;
|
*outIp = ip;
|
||||||
return true;
|
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 <src_ip> <dst_ip> <src_port> <dst_port>"
|
||||||
|
// 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
|
#endif
|
||||||
|
|
||||||
void WinsockNetLayer::HandleDataReceived(BYTE fromSmallId, BYTE toSmallId, unsigned char* data, unsigned int dataSize)
|
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)
|
#if defined(MINECRAFT_SERVER_BUILD)
|
||||||
std::string remoteIp;
|
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";
|
const char *remoteIpForLog = hasRemoteIp ? remoteIp.c_str() : "unknown";
|
||||||
if (g_Win64DedicatedServer)
|
if (g_Win64DedicatedServer)
|
||||||
{
|
{
|
||||||
|
|
@ -960,6 +1330,22 @@ DWORD WINAPI WinsockNetLayer::AcceptThreadProc(LPVOID param)
|
||||||
closesocket(clientSocket);
|
closesocket(clientSocket);
|
||||||
continue;
|
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
|
#endif
|
||||||
|
|
||||||
|
|
@ -1138,6 +1524,25 @@ DWORD WINAPI WinsockNetLayer::RecvThreadProc(LPVOID param)
|
||||||
break;
|
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);
|
HandleDataReceived(clientSmallId, s_hostSmallId, &recvBuf[0], packetSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1180,6 +1585,14 @@ bool WinsockNetLayer::PopDisconnectedSmallId(BYTE* outSmallId)
|
||||||
|
|
||||||
void WinsockNetLayer::PushFreeSmallId(BYTE smallId)
|
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
|
// SmallIds 0..(XUSER_MAX_COUNT-1) are permanently reserved for the host's
|
||||||
// local pads and must never be recycled to remote clients.
|
// local pads and must never be recycled to remote clients.
|
||||||
if (smallId < (BYTE)XUSER_MAX_COUNT)
|
if (smallId < (BYTE)XUSER_MAX_COUNT)
|
||||||
|
|
@ -1416,10 +1829,29 @@ DWORD WINAPI WinsockNetLayer::ClientRecvThreadProc(LPVOID param)
|
||||||
break;
|
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);
|
HandleDataReceived(s_hostSmallId, s_localSmallId, &recvBuf[0], packetSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
s_connected = false;
|
s_connected = false;
|
||||||
|
ResetClientCipher();
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
#include <vector>
|
#include <vector>
|
||||||
#include "..\..\Common\Network\NetworkPlayerInterface.h"
|
#include "..\..\Common\Network\NetworkPlayerInterface.h"
|
||||||
#include "..\..\..\Minecraft.World\DisconnectPacket.h"
|
#include "..\..\..\Minecraft.World\DisconnectPacket.h"
|
||||||
|
#include "..\..\..\Minecraft.Server\Security\StreamCipher.h"
|
||||||
|
|
||||||
#pragma comment(lib, "Ws2_32.lib")
|
#pragma comment(lib, "Ws2_32.lib")
|
||||||
|
|
||||||
|
|
@ -16,7 +17,7 @@
|
||||||
#define WIN64_NET_MAX_CLIENTS 255
|
#define WIN64_NET_MAX_CLIENTS 255
|
||||||
#define WIN64_SMALLID_REJECT 0xFF
|
#define WIN64_SMALLID_REJECT 0xFF
|
||||||
#define WIN64_NET_RECV_BUFFER_SIZE 65536
|
#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_DISCOVERY_PORT 25566
|
||||||
#define WIN64_LAN_BROADCAST_MAGIC 0x4D434C4E
|
#define WIN64_LAN_BROADCAST_MAGIC 0x4D434C4E
|
||||||
|
|
||||||
|
|
@ -190,8 +191,38 @@ private:
|
||||||
static BYTE s_splitScreenSmallId[XUSER_MAX_COUNT];
|
static BYTE s_splitScreenSmallId[XUSER_MAX_COUNT];
|
||||||
static HANDLE s_splitScreenRecvThread[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:
|
public:
|
||||||
static void ClearSocketForSmallId(BYTE smallId);
|
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;
|
extern bool g_Win64MultiplayerHost;
|
||||||
|
|
|
||||||
|
|
@ -410,6 +410,8 @@ source_group("Windows64/Iggy/gdraw" FILES ${_MINECRAFT_CLIENT_COMMON_WINDOWS64_I
|
||||||
set(_MINECRAFT_CLIENT_COMMON_WINDOWS64_NETWORK
|
set(_MINECRAFT_CLIENT_COMMON_WINDOWS64_NETWORK
|
||||||
"${CMAKE_CURRENT_SOURCE_DIR}/Windows64/Network/WinsockNetLayer.cpp"
|
"${CMAKE_CURRENT_SOURCE_DIR}/Windows64/Network/WinsockNetLayer.cpp"
|
||||||
"${CMAKE_CURRENT_SOURCE_DIR}/Windows64/Network/WinsockNetLayer.h"
|
"${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})
|
source_group("Windows64/Network" FILES ${_MINECRAFT_CLIENT_COMMON_WINDOWS64_NETWORK})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ namespace ServerRuntime
|
||||||
std::mutex writeLock;
|
std::mutex writeLock;
|
||||||
std::shared_ptr<BanManager> banManager;
|
std::shared_ptr<BanManager> banManager;
|
||||||
std::shared_ptr<WhitelistManager> whitelistManager;
|
std::shared_ptr<WhitelistManager> whitelistManager;
|
||||||
|
std::shared_ptr<OpManager> opManager;
|
||||||
bool whitelistEnabled = false;
|
bool whitelistEnabled = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -63,6 +64,18 @@ namespace ServerRuntime
|
||||||
std::lock_guard<std::mutex> stateLock(g_accessState.stateLock);
|
std::lock_guard<std::mutex> stateLock(g_accessState.stateLock);
|
||||||
g_accessState.whitelistManager = whitelistManager;
|
g_accessState.whitelistManager = whitelistManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static std::shared_ptr<OpManager> GetOpManagerSnapshot()
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> stateLock(g_accessState.stateLock);
|
||||||
|
return g_accessState.opManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void PublishOpManagerSnapshot(const std::shared_ptr<OpManager> &opManager)
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> stateLock(g_accessState.stateLock);
|
||||||
|
g_accessState.opManager = opManager;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string FormatXuid(PlayerUID xuid)
|
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.
|
// Build the replacement manager privately so readers keep using the last published snapshot during disk I/O.
|
||||||
std::shared_ptr<BanManager> banManager = std::make_shared<BanManager>(baseDirectory);
|
std::shared_ptr<BanManager> banManager = std::make_shared<BanManager>(baseDirectory);
|
||||||
std::shared_ptr<WhitelistManager> whitelistManager = std::make_shared<WhitelistManager>(baseDirectory);
|
std::shared_ptr<WhitelistManager> whitelistManager = std::make_shared<WhitelistManager>(baseDirectory);
|
||||||
|
std::shared_ptr<OpManager> opManager = std::make_shared<OpManager>(baseDirectory);
|
||||||
if (!banManager->EnsureBanFilesExist())
|
if (!banManager->EnsureBanFilesExist())
|
||||||
{
|
{
|
||||||
LogError("access", "failed to ensure dedicated server ban files exist");
|
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");
|
LogError("access", "failed to ensure dedicated server whitelist file exists");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (!opManager->EnsureOpFileExists())
|
||||||
|
{
|
||||||
|
LogError("access", "failed to ensure dedicated server ops file exists");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (!banManager->Reload())
|
if (!banManager->Reload())
|
||||||
{
|
{
|
||||||
|
|
@ -122,15 +141,23 @@ namespace ServerRuntime
|
||||||
LogError("access", "failed to load dedicated server whitelist file");
|
LogError("access", "failed to load dedicated server whitelist file");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (!opManager->Reload())
|
||||||
|
{
|
||||||
|
LogError("access", "failed to load dedicated server ops file");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
std::vector<BannedPlayerEntry> playerEntries;
|
std::vector<BannedPlayerEntry> playerEntries;
|
||||||
std::vector<BannedIpEntry> ipEntries;
|
std::vector<BannedIpEntry> ipEntries;
|
||||||
std::vector<WhitelistedPlayerEntry> whitelistEntries;
|
std::vector<WhitelistedPlayerEntry> whitelistEntries;
|
||||||
|
std::vector<OpPlayerEntry> opEntries;
|
||||||
banManager->SnapshotBannedPlayers(&playerEntries);
|
banManager->SnapshotBannedPlayers(&playerEntries);
|
||||||
banManager->SnapshotBannedIps(&ipEntries);
|
banManager->SnapshotBannedIps(&ipEntries);
|
||||||
whitelistManager->SnapshotWhitelistedPlayers(&whitelistEntries);
|
whitelistManager->SnapshotWhitelistedPlayers(&whitelistEntries);
|
||||||
|
opManager->SnapshotOps(&opEntries);
|
||||||
PublishBanManagerSnapshot(banManager);
|
PublishBanManagerSnapshot(banManager);
|
||||||
PublishWhitelistManagerSnapshot(whitelistManager);
|
PublishWhitelistManagerSnapshot(whitelistManager);
|
||||||
|
PublishOpManagerSnapshot(opManager);
|
||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> stateLock(g_accessState.stateLock);
|
std::lock_guard<std::mutex> stateLock(g_accessState.stateLock);
|
||||||
g_accessState.whitelistEnabled = whitelistEnabled;
|
g_accessState.whitelistEnabled = whitelistEnabled;
|
||||||
|
|
@ -138,10 +165,11 @@ namespace ServerRuntime
|
||||||
|
|
||||||
LogInfof(
|
LogInfof(
|
||||||
"access",
|
"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)playerEntries.size(),
|
||||||
(unsigned)ipEntries.size(),
|
(unsigned)ipEntries.size(),
|
||||||
(unsigned)whitelistEntries.size(),
|
(unsigned)whitelistEntries.size(),
|
||||||
|
(unsigned)opEntries.size(),
|
||||||
whitelistEnabled ? "enabled" : "disabled");
|
whitelistEnabled ? "enabled" : "disabled");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -151,6 +179,7 @@ namespace ServerRuntime
|
||||||
std::lock_guard<std::mutex> writeLock(g_accessState.writeLock);
|
std::lock_guard<std::mutex> writeLock(g_accessState.writeLock);
|
||||||
PublishBanManagerSnapshot(std::shared_ptr<BanManager>{});
|
PublishBanManagerSnapshot(std::shared_ptr<BanManager>{});
|
||||||
PublishWhitelistManagerSnapshot(std::shared_ptr<WhitelistManager>{});
|
PublishWhitelistManagerSnapshot(std::shared_ptr<WhitelistManager>{});
|
||||||
|
PublishOpManagerSnapshot(std::shared_ptr<OpManager>{});
|
||||||
std::lock_guard<std::mutex> stateLock(g_accessState.stateLock);
|
std::lock_guard<std::mutex> stateLock(g_accessState.stateLock);
|
||||||
g_accessState.whitelistEnabled = false;
|
g_accessState.whitelistEnabled = false;
|
||||||
}
|
}
|
||||||
|
|
@ -214,7 +243,9 @@ namespace ServerRuntime
|
||||||
|
|
||||||
bool IsInitialized()
|
bool IsInitialized()
|
||||||
{
|
{
|
||||||
return GetBanManagerSnapshot() != nullptr && GetWhitelistManagerSnapshot() != nullptr;
|
return GetBanManagerSnapshot() != nullptr
|
||||||
|
&& GetWhitelistManagerSnapshot() != nullptr
|
||||||
|
&& GetOpManagerSnapshot() != nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool IsWhitelistEnabled()
|
bool IsWhitelistEnabled()
|
||||||
|
|
@ -456,5 +487,111 @@ namespace ServerRuntime
|
||||||
|
|
||||||
return whitelistManager->SnapshotWhitelistedPlayers(outEntries);
|
return whitelistManager->SnapshotWhitelistedPlayers(outEntries);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool IsPlayerOp(PlayerUID xuid)
|
||||||
|
{
|
||||||
|
const std::string formatted = FormatXuid(xuid);
|
||||||
|
if (formatted.empty())
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::shared_ptr<OpManager> 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<std::mutex> writeLock(g_accessState.writeLock);
|
||||||
|
std::shared_ptr<OpManager> current = GetOpManagerSnapshot();
|
||||||
|
if (current == nullptr)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto opManager = std::make_shared<OpManager>(*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<std::mutex> writeLock(g_accessState.writeLock);
|
||||||
|
std::shared_ptr<OpManager> current = GetOpManagerSnapshot();
|
||||||
|
if (current == nullptr)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto opManager = std::make_shared<OpManager>(*current);
|
||||||
|
if (!opManager->RemoveOpByXuid(formatted))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
PublishOpManagerSnapshot(opManager);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ReloadOps()
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> writeLock(g_accessState.writeLock);
|
||||||
|
const auto current = GetOpManagerSnapshot();
|
||||||
|
if (current == nullptr)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto opManager = std::make_shared<OpManager>(*current);
|
||||||
|
if (!opManager->EnsureOpFileExists())
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!opManager->Reload())
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
PublishOpManagerSnapshot(opManager);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SnapshotOps(std::vector<OpPlayerEntry> *outEntries)
|
||||||
|
{
|
||||||
|
if (outEntries == nullptr)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto opManager = GetOpManagerSnapshot();
|
||||||
|
if (opManager == nullptr)
|
||||||
|
{
|
||||||
|
outEntries->clear();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return opManager->SnapshotOps(outEntries);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
#include "BanManager.h"
|
#include "BanManager.h"
|
||||||
#include "WhitelistManager.h"
|
#include "WhitelistManager.h"
|
||||||
|
#include "OpManager.h"
|
||||||
|
|
||||||
namespace ServerRuntime
|
namespace ServerRuntime
|
||||||
{
|
{
|
||||||
|
|
@ -14,6 +15,7 @@ namespace ServerRuntime
|
||||||
void Shutdown();
|
void Shutdown();
|
||||||
bool Reload();
|
bool Reload();
|
||||||
bool ReloadWhitelist();
|
bool ReloadWhitelist();
|
||||||
|
bool ReloadOps();
|
||||||
bool IsInitialized();
|
bool IsInitialized();
|
||||||
bool IsWhitelistEnabled();
|
bool IsWhitelistEnabled();
|
||||||
void SetWhitelistEnabled(bool enabled);
|
void SetWhitelistEnabled(bool enabled);
|
||||||
|
|
@ -21,6 +23,7 @@ namespace ServerRuntime
|
||||||
bool IsPlayerBanned(PlayerUID xuid);
|
bool IsPlayerBanned(PlayerUID xuid);
|
||||||
bool IsIpBanned(const std::string &ip);
|
bool IsIpBanned(const std::string &ip);
|
||||||
bool IsPlayerWhitelisted(PlayerUID xuid);
|
bool IsPlayerWhitelisted(PlayerUID xuid);
|
||||||
|
bool IsPlayerOp(PlayerUID xuid);
|
||||||
|
|
||||||
bool AddPlayerBan(PlayerUID xuid, const std::string &name, const BanMetadata &metadata);
|
bool AddPlayerBan(PlayerUID xuid, const std::string &name, const BanMetadata &metadata);
|
||||||
bool AddIpBan(const std::string &ip, 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 RemoveIpBan(const std::string &ip);
|
||||||
bool AddWhitelistedPlayer(PlayerUID xuid, const std::string &name, const WhitelistMetadata &metadata);
|
bool AddWhitelistedPlayer(PlayerUID xuid, const std::string &name, const WhitelistMetadata &metadata);
|
||||||
bool RemoveWhitelistedPlayer(PlayerUID xuid);
|
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
|
* Copies the current cached player bans for inspection or command output
|
||||||
|
|
@ -40,6 +45,7 @@ namespace ServerRuntime
|
||||||
*/
|
*/
|
||||||
bool SnapshotBannedIps(std::vector<BannedIpEntry> *outEntries);
|
bool SnapshotBannedIps(std::vector<BannedIpEntry> *outEntries);
|
||||||
bool SnapshotWhitelistedPlayers(std::vector<WhitelistedPlayerEntry> *outEntries);
|
bool SnapshotWhitelistedPlayers(std::vector<WhitelistedPlayerEntry> *outEntries);
|
||||||
|
bool SnapshotOps(std::vector<OpPlayerEntry> *outEntries);
|
||||||
|
|
||||||
std::string FormatXuid(PlayerUID xuid);
|
std::string FormatXuid(PlayerUID xuid);
|
||||||
bool TryParseXuid(const std::string &text, PlayerUID *outXuid);
|
bool TryParseXuid(const std::string &text, PlayerUID *outXuid);
|
||||||
|
|
|
||||||
284
Minecraft.Server/Access/OpManager.cpp
Normal file
284
Minecraft.Server/Access/OpManager.cpp
Normal file
|
|
@ -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 <algorithm>
|
||||||
|
|
||||||
|
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<OpPlayerEntry> ops;
|
||||||
|
if (!LoadOps(&ops))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_ops.swap(ops);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool OpManager::Save() const
|
||||||
|
{
|
||||||
|
std::vector<OpPlayerEntry> ops;
|
||||||
|
return SnapshotOps(&ops) && SaveOps(ops);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool OpManager::LoadOps(std::vector<OpPlayerEntry> *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<OpPlayerEntry> &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<OpPlayerEntry> &OpManager::GetOps() const
|
||||||
|
{
|
||||||
|
return m_ops;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool OpManager::SnapshotOps(std::vector<OpPlayerEntry> *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<OpPlayerEntry> 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<OpPlayerEntry> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
61
Minecraft.Server/Access/OpManager.h
Normal file
61
Minecraft.Server/Access/OpManager.h
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
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<OpPlayerEntry> *outEntries) const;
|
||||||
|
bool SaveOps(const std::vector<OpPlayerEntry> &entries) const;
|
||||||
|
|
||||||
|
const std::vector<OpPlayerEntry> &GetOps() const;
|
||||||
|
bool SnapshotOps(std::vector<OpPlayerEntry> *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<OpPlayerEntry> m_ops;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -23,6 +23,7 @@
|
||||||
#include "commands\tp\CliCommandTp.h"
|
#include "commands\tp\CliCommandTp.h"
|
||||||
#include "commands\weather\CliCommandWeather.h"
|
#include "commands\weather\CliCommandWeather.h"
|
||||||
#include "commands\whitelist\CliCommandWhitelist.h"
|
#include "commands\whitelist\CliCommandWhitelist.h"
|
||||||
|
#include "commands\revoketoken\CliCommandRevokeToken.h"
|
||||||
#include "..\Common\StringUtils.h"
|
#include "..\Common\StringUtils.h"
|
||||||
#include "..\ServerShutdown.h"
|
#include "..\ServerShutdown.h"
|
||||||
#include "..\ServerLogger.h"
|
#include "..\ServerLogger.h"
|
||||||
|
|
@ -100,6 +101,7 @@ namespace ServerRuntime
|
||||||
m_registry->Register(std::unique_ptr<IServerCliCommand>(new CliCommandPardonIp()));
|
m_registry->Register(std::unique_ptr<IServerCliCommand>(new CliCommandPardonIp()));
|
||||||
m_registry->Register(std::unique_ptr<IServerCliCommand>(new CliCommandBanList()));
|
m_registry->Register(std::unique_ptr<IServerCliCommand>(new CliCommandBanList()));
|
||||||
m_registry->Register(std::unique_ptr<IServerCliCommand>(new CliCommandWhitelist()));
|
m_registry->Register(std::unique_ptr<IServerCliCommand>(new CliCommandWhitelist()));
|
||||||
|
m_registry->Register(std::unique_ptr<IServerCliCommand>(new CliCommandRevokeToken()));
|
||||||
m_registry->Register(std::unique_ptr<IServerCliCommand>(new CliCommandTp()));
|
m_registry->Register(std::unique_ptr<IServerCliCommand>(new CliCommandTp()));
|
||||||
m_registry->Register(std::unique_ptr<IServerCliCommand>(new CliCommandTime()));
|
m_registry->Register(std::unique_ptr<IServerCliCommand>(new CliCommandTime()));
|
||||||
m_registry->Register(std::unique_ptr<IServerCliCommand>(new CliCommandWeather()));
|
m_registry->Register(std::unique_ptr<IServerCliCommand>(new CliCommandWeather()));
|
||||||
|
|
|
||||||
|
|
@ -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 <name|xuid>";
|
||||||
|
}
|
||||||
|
|
||||||
|
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<PlayerUID> 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 <xuid>");
|
||||||
|
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 <xuid>");
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
#include "..\..\..\Access\Access.h"
|
#include "..\..\..\Access\Access.h"
|
||||||
#include "..\..\..\Common\StringUtils.h"
|
#include "..\..\..\Common\StringUtils.h"
|
||||||
#include "..\..\..\ServerProperties.h"
|
#include "..\..\..\ServerProperties.h"
|
||||||
|
#include "..\..\..\ServerLogManager.h"
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <array>
|
#include <array>
|
||||||
|
|
@ -181,14 +182,44 @@ namespace ServerRuntime
|
||||||
{
|
{
|
||||||
if (line.tokens.size() < 3)
|
if (line.tokens.size() < 3)
|
||||||
{
|
{
|
||||||
engine->LogWarn("Usage: whitelist add <xuid> [name ...]");
|
engine->LogWarn("Usage: whitelist add <xuid|name> [display name ...]");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
PlayerUID xuid = INVALID_XUID;
|
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<PlayerUID> 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 <xuid>");
|
||||||
|
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 <xuid> [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))
|
if (ServerRuntime::Access::IsPlayerWhitelisted(xuid))
|
||||||
|
|
@ -198,7 +229,6 @@ namespace ServerRuntime
|
||||||
}
|
}
|
||||||
|
|
||||||
const auto metadata = ServerRuntime::Access::WhitelistManager::BuildDefaultMetadata("Console");
|
const auto metadata = ServerRuntime::Access::WhitelistManager::BuildDefaultMetadata("Console");
|
||||||
const auto name = StringUtils::JoinTokens(line.tokens, 3);
|
|
||||||
if (!ServerRuntime::Access::AddWhitelistedPlayer(xuid, name, metadata))
|
if (!ServerRuntime::Access::AddWhitelistedPlayer(xuid, name, metadata))
|
||||||
{
|
{
|
||||||
engine->LogError("Failed to write whitelist entry.");
|
engine->LogError("Failed to write whitelist entry.");
|
||||||
|
|
|
||||||
60
Minecraft.Server/Security/CipherHandshakeEnforcer.cpp
Normal file
60
Minecraft.Server/Security/CipherHandshakeEnforcer.cpp
Normal file
|
|
@ -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<unsigned char> &outExpired,
|
||||||
|
std::vector<unsigned char> &outCompleted)
|
||||||
|
{
|
||||||
|
auto ®istry = GetCipherRegistry();
|
||||||
|
|
||||||
|
for (int i = 0; i < MAX_CONNECTIONS; ++i)
|
||||||
|
{
|
||||||
|
if (!m_tracked[i])
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (registry.IsCipherActive(static_cast<unsigned char>(i)))
|
||||||
|
{
|
||||||
|
outCompleted.push_back(static_cast<unsigned char>(i));
|
||||||
|
m_tracked[i] = false;
|
||||||
|
}
|
||||||
|
else if ((currentTick - m_sentTick[i]) > static_cast<unsigned int>(kGraceTicks))
|
||||||
|
{
|
||||||
|
outExpired.push_back(static_cast<unsigned char>(i));
|
||||||
|
m_tracked[i] = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void CipherHandshakeEnforcer::OnDisconnected(unsigned char smallId)
|
||||||
|
{
|
||||||
|
m_tracked[smallId] = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
CipherHandshakeEnforcer &GetHandshakeEnforcer()
|
||||||
|
{
|
||||||
|
static CipherHandshakeEnforcer s_instance;
|
||||||
|
return s_instance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
64
Minecraft.Server/Security/CipherHandshakeEnforcer.h
Normal file
64
Minecraft.Server/Security/CipherHandshakeEnforcer.h
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#ifdef _WINDOWS64
|
||||||
|
#include <Windows.h>
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
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<unsigned char> &outExpired,
|
||||||
|
std::vector<unsigned char> &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();
|
||||||
|
}
|
||||||
|
}
|
||||||
115
Minecraft.Server/Security/ConnectionCipher.cpp
Normal file
115
Minecraft.Server/Security/ConnectionCipher.cpp
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
#include "stdafx.h"
|
||||||
|
#include "ConnectionCipher.h"
|
||||||
|
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
97
Minecraft.Server/Security/ConnectionCipher.h
Normal file
97
Minecraft.Server/Security/ConnectionCipher.h
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "StreamCipher.h"
|
||||||
|
|
||||||
|
#ifdef _WINDOWS64
|
||||||
|
#include <Windows.h>
|
||||||
|
#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();
|
||||||
|
}
|
||||||
|
}
|
||||||
280
Minecraft.Server/Security/IdentityTokenManager.cpp
Normal file
280
Minecraft.Server/Security/IdentityTokenManager.cpp
Normal file
|
|
@ -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 <algorithm>
|
||||||
|
|
||||||
|
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<uint32_t>(data[i]) << 16;
|
||||||
|
if (i + 1 < length) n |= static_cast<uint32_t>(data[i + 1]) << 8;
|
||||||
|
if (i + 2 < length) n |= static_cast<uint32_t>(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<uint8_t> &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<uint8_t>((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<uint8_t>(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<uint8_t> tokenBytes;
|
||||||
|
if (!Base64ToBytes(it.value().get<std::string>(), tokenBytes))
|
||||||
|
continue;
|
||||||
|
if (tokenBytes.size() != TOKEN_SIZE)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
m_tokens[static_cast<PlayerUID>(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
63
Minecraft.Server/Security/IdentityTokenManager.h
Normal file
63
Minecraft.Server/Security/IdentityTokenManager.h
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <unordered_map>
|
||||||
|
#include <vector>
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
#ifdef _WINDOWS64
|
||||||
|
#include <Windows.h>
|
||||||
|
#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<PlayerUID, std::vector<uint8_t>> m_tokens;
|
||||||
|
mutable CRITICAL_SECTION m_lock;
|
||||||
|
bool m_initialized;
|
||||||
|
};
|
||||||
|
|
||||||
|
IdentityTokenManager &GetIdentityTokenManager();
|
||||||
|
}
|
||||||
|
}
|
||||||
78
Minecraft.Server/Security/RateLimiter.cpp
Normal file
78
Minecraft.Server/Security/RateLimiter.cpp
Normal file
|
|
@ -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<ULONGLONG>(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<size_t>(maxPerWindow);
|
||||||
|
if (allowed)
|
||||||
|
{
|
||||||
|
timestamps.push_back(now);
|
||||||
|
}
|
||||||
|
|
||||||
|
LeaveCriticalSection(&m_lock);
|
||||||
|
return allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
void RateLimiter::EvictStale(int evictionAgeMs)
|
||||||
|
{
|
||||||
|
ULONGLONG now = GetTickCount64();
|
||||||
|
ULONGLONG evictionAge = static_cast<ULONGLONG>(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
47
Minecraft.Server/Security/RateLimiter.h
Normal file
47
Minecraft.Server/Security/RateLimiter.h
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <deque>
|
||||||
|
#include <unordered_map>
|
||||||
|
|
||||||
|
#ifdef _WINDOWS64
|
||||||
|
#include <Windows.h>
|
||||||
|
#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<std::string, std::deque<ULONGLONG>> m_connectionTimes;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global rate limiter instance for the dedicated server accept loop.
|
||||||
|
*/
|
||||||
|
RateLimiter &GetGlobalRateLimiter();
|
||||||
|
}
|
||||||
|
}
|
||||||
27
Minecraft.Server/Security/SecurityConfig.cpp
Normal file
27
Minecraft.Server/Security/SecurityConfig.cpp
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
22
Minecraft.Server/Security/SecurityConfig.h
Normal file
22
Minecraft.Server/Security/SecurityConfig.h
Normal file
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
90
Minecraft.Server/Security/StreamCipher.cpp
Normal file
90
Minecraft.Server/Security/StreamCipher.cpp
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
#include "stdafx.h"
|
||||||
|
#include "StreamCipher.h"
|
||||||
|
|
||||||
|
#ifdef _WINDOWS64
|
||||||
|
#include <Windows.h>
|
||||||
|
#include <wincrypt.h>
|
||||||
|
#pragma comment(lib, "Advapi32.lib")
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
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<uint8_t>(rand() & 0xFF);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
70
Minecraft.Server/Security/StreamCipher.h
Normal file
70
Minecraft.Server/Security/StreamCipher.h
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
|
|
||||||
#include <array>
|
#include <array>
|
||||||
#include <mutex>
|
#include <mutex>
|
||||||
|
#include <unordered_map>
|
||||||
|
|
||||||
extern bool g_Win64DedicatedServer;
|
extern bool g_Win64DedicatedServer;
|
||||||
|
|
||||||
|
|
@ -26,6 +27,12 @@ namespace ServerRuntime
|
||||||
{
|
{
|
||||||
std::string remoteIp;
|
std::string remoteIp;
|
||||||
std::string playerName;
|
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::mutex stateLock;
|
||||||
std::array<ConnectionLogEntry, 256> entries;
|
std::array<ConnectionLogEntry, 256> entries;
|
||||||
|
|
||||||
|
// Name->XUIDs cache from recent login attempts (case-insensitive name key)
|
||||||
|
// Multiple XUIDs per name for ambiguity detection
|
||||||
|
std::unordered_map<std::string, std::vector<PlayerUID>> nameToXuidCache;
|
||||||
};
|
};
|
||||||
|
|
||||||
ServerLogState g_serverLogState;
|
ServerLogState g_serverLogState;
|
||||||
|
|
@ -54,6 +65,12 @@ namespace ServerRuntime
|
||||||
|
|
||||||
entry->remoteIp.clear();
|
entry->remoteIp.clear();
|
||||||
entry->playerName.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)
|
static std::string NormalizeRemoteIp(const char *ip)
|
||||||
|
|
@ -148,6 +165,9 @@ namespace ServerRuntime
|
||||||
case eTcpRejectReason_BannedIp: return "banned-ip";
|
case eTcpRejectReason_BannedIp: return "banned-ip";
|
||||||
case eTcpRejectReason_GameNotReady: return "game-not-ready";
|
case eTcpRejectReason_GameNotReady: return "game-not-ready";
|
||||||
case eTcpRejectReason_ServerFull: return "server-full";
|
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";
|
default: return "unknown";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -283,8 +303,17 @@ namespace ServerRuntime
|
||||||
LogInfof("network", "accepted tcp connection from %s as smallId=%u", remoteIp.c_str(), (unsigned)smallId);
|
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.
|
static std::string FormatXuidForLog(PlayerUID xuid)
|
||||||
void OnAcceptedPlayerLogin(unsigned char smallId, const std::wstring &playerName)
|
{
|
||||||
|
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())
|
if (!IsDedicatedServerLoggingEnabled())
|
||||||
{
|
{
|
||||||
|
|
@ -297,13 +326,29 @@ namespace ServerRuntime
|
||||||
std::lock_guard<std::mutex> stateLock(g_serverLogState.stateLock);
|
std::lock_guard<std::mutex> stateLock(g_serverLogState.stateLock);
|
||||||
ConnectionLogEntry &entry = g_serverLogState.entries[smallId];
|
ConnectionLogEntry &entry = g_serverLogState.entries[smallId];
|
||||||
entry.playerName = playerNameUtf8;
|
entry.playerName = playerNameUtf8;
|
||||||
|
entry.offlineXuid = offlineXuid;
|
||||||
|
entry.onlineXuid = onlineXuid;
|
||||||
|
entry.isGuest = isGuest;
|
||||||
if (!entry.remoteIp.empty())
|
if (!entry.remoteIp.empty())
|
||||||
{
|
{
|
||||||
remoteIp = entry.remoteIp;
|
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.
|
// 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<std::mutex> stateLock(g_serverLogState.stateLock);
|
std::lock_guard<std::mutex> stateLock(g_serverLogState.stateLock);
|
||||||
ResetConnectionLogEntry(&g_serverLogState.entries[smallId]);
|
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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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 <name>`,
|
||||||
|
// 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<std::mutex> 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<PlayerUID> *outXuids)
|
||||||
|
{
|
||||||
|
if (playerName.empty())
|
||||||
|
{
|
||||||
|
if (outXuids != nullptr) outXuids->clear();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string key = NormalizeNameKey(playerName);
|
||||||
|
|
||||||
|
std::lock_guard<std::mutex> 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<int>(it->second.size());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
#include <stdarg.h>
|
#include <stdarg.h>
|
||||||
|
|
||||||
#include "..\Minecraft.World\DisconnectPacket.h"
|
#include "..\Minecraft.World\DisconnectPacket.h"
|
||||||
|
|
@ -17,7 +18,10 @@ namespace ServerRuntime
|
||||||
{
|
{
|
||||||
eTcpRejectReason_BannedIp = 0,
|
eTcpRejectReason_BannedIp = 0,
|
||||||
eTcpRejectReason_GameNotReady,
|
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);
|
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
|
* Emits a named login rejection log and clears cached metadata for that smallId
|
||||||
|
|
@ -123,5 +143,21 @@ namespace ServerRuntime
|
||||||
* 指定smallIdに紐づく接続キャッシュを消去
|
* 指定smallIdに紐づく接続キャッシュを消去
|
||||||
*/
|
*/
|
||||||
void ClearConnection(unsigned char smallId);
|
void ClearConnection(unsigned char smallId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache a player name -> XUID mapping from a login attempt (accepted or rejected).
|
||||||
|
* Used by `whitelist add <name>` 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<PlayerUID> *outXuids);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,48 @@
|
||||||
#include <stdio.h>
|
#include <stdio.h>
|
||||||
#include <stdarg.h>
|
#include <stdarg.h>
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
|
#include <mutex>
|
||||||
|
|
||||||
namespace ServerRuntime
|
namespace ServerRuntime
|
||||||
{
|
{
|
||||||
static volatile LONG g_minLogLevel = (LONG)eServerLogLevel_Info;
|
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)
|
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);
|
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();
|
linenoiseExternalWriteEnd();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,15 @@ static const ServerPropertyDefault kServerPropertyDefaults[] =
|
||||||
{ "spawn-monsters", "true" },
|
{ "spawn-monsters", "true" },
|
||||||
{ "spawn-npcs", "true" },
|
{ "spawn-npcs", "true" },
|
||||||
{ "tnt", "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)
|
static std::string BoolToString(bool value)
|
||||||
|
|
@ -883,6 +891,15 @@ ServerPropertiesConfig LoadServerPropertiesConfig()
|
||||||
config.maxBuildHeight = ReadNormalizedIntProperty(&merged, "max-build-height", 256, 64, 256, &shouldWrite);
|
config.maxBuildHeight = ReadNormalizedIntProperty(&merged, "max-build-height", 256, 64, 256, &shouldWrite);
|
||||||
config.motd = ReadNormalizedStringProperty(&merged, "motd", "A Minecraft Server", 255, &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 (shouldWrite)
|
||||||
{
|
{
|
||||||
if (WriteServerPropertiesFile(kServerPropertiesPath, merged))
|
if (WriteServerPropertiesFile(kServerPropertiesPath, merged))
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,24 @@ namespace ServerRuntime
|
||||||
/** `hardcore-ban-ip` — whether hardcore death bans include IP bans */
|
/** `hardcore-ban-ip` — whether hardcore death bans include IP bans */
|
||||||
bool hardcoreBanIp;
|
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 */
|
/** other MinecraftServer runtime settings */
|
||||||
int maxBuildHeight;
|
int maxBuildHeight;
|
||||||
std::string levelType;
|
std::string levelType;
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,9 @@
|
||||||
#include "..\ServerLogManager.h"
|
#include "..\ServerLogManager.h"
|
||||||
#include "..\ServerProperties.h"
|
#include "..\ServerProperties.h"
|
||||||
#include "..\ServerShutdown.h"
|
#include "..\ServerShutdown.h"
|
||||||
|
#include "..\Security\SecurityConfig.h"
|
||||||
|
#include "..\Security\RateLimiter.h"
|
||||||
|
#include "..\Security\IdentityTokenManager.h"
|
||||||
#include "..\WorldManager.h"
|
#include "..\WorldManager.h"
|
||||||
#include "..\Console\ServerCli.h"
|
#include "..\Console\ServerCli.h"
|
||||||
#include "Tesselator.h"
|
#include "Tesselator.h"
|
||||||
|
|
@ -416,6 +419,41 @@ int main(int argc, char **argv)
|
||||||
return 2;
|
return 2;
|
||||||
}
|
}
|
||||||
accessShutdownGuard.Activate();
|
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", "LAN advertise: %s", serverProperties.lanAdvertise ? "enabled" : "disabled");
|
||||||
LogInfof("startup", "Whitelist: %s", serverProperties.whiteListEnabled ? "enabled" : "disabled");
|
LogInfof("startup", "Whitelist: %s", serverProperties.whiteListEnabled ? "enabled" : "disabled");
|
||||||
LogInfof("startup", "Spawn protection radius: %d", serverProperties.spawnProtectionRadius);
|
LogInfof("startup", "Spawn protection radius: %d", serverProperties.spawnProtectionRadius);
|
||||||
|
|
|
||||||
|
|
@ -521,9 +521,27 @@ set(_MINECRAFT_SERVER_COMMON_SERVER_ACCESS
|
||||||
"${CMAKE_CURRENT_SOURCE_DIR}/Access/BanManager.h"
|
"${CMAKE_CURRENT_SOURCE_DIR}/Access/BanManager.h"
|
||||||
"${CMAKE_CURRENT_SOURCE_DIR}/Access/WhitelistManager.cpp"
|
"${CMAKE_CURRENT_SOURCE_DIR}/Access/WhitelistManager.cpp"
|
||||||
"${CMAKE_CURRENT_SOURCE_DIR}/Access/WhitelistManager.h"
|
"${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})
|
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
|
set(_MINECRAFT_SERVER_COMMON_SERVER_COMMON
|
||||||
"${CMAKE_CURRENT_SOURCE_DIR}/Common/AccessStorageUtils.h"
|
"${CMAKE_CURRENT_SOURCE_DIR}/Common/AccessStorageUtils.h"
|
||||||
"${CMAKE_CURRENT_SOURCE_DIR}/Common/FileUtils.cpp"
|
"${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/weather/CliCommandWeather.h"
|
||||||
"${CMAKE_CURRENT_SOURCE_DIR}/Console/commands/whitelist/CliCommandWhitelist.cpp"
|
"${CMAKE_CURRENT_SOURCE_DIR}/Console/commands/whitelist/CliCommandWhitelist.cpp"
|
||||||
"${CMAKE_CURRENT_SOURCE_DIR}/Console/commands/whitelist/CliCommandWhitelist.h"
|
"${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})
|
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_ROOT}
|
||||||
${_MINECRAFT_SERVER_COMMON_SERVER}
|
${_MINECRAFT_SERVER_COMMON_SERVER}
|
||||||
${_MINECRAFT_SERVER_COMMON_SERVER_ACCESS}
|
${_MINECRAFT_SERVER_COMMON_SERVER_ACCESS}
|
||||||
|
${_MINECRAFT_SERVER_COMMON_SERVER_SECURITY}
|
||||||
${_MINECRAFT_SERVER_COMMON_SERVER_COMMON}
|
${_MINECRAFT_SERVER_COMMON_SERVER_COMMON}
|
||||||
${_MINECRAFT_SERVER_COMMON_SERVER_CONSOLE}
|
${_MINECRAFT_SERVER_COMMON_SERVER_CONSOLE}
|
||||||
${_MINECRAFT_SERVER_COMMON_SERVER_CONSOLE_COMMANDS}
|
${_MINECRAFT_SERVER_COMMON_SERVER_CONSOLE_COMMANDS}
|
||||||
|
|
|
||||||
|
|
@ -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_BEACON_PACKET = L"MC|Beacon";
|
||||||
const wstring CustomPayloadPacket::SET_ITEM_NAME_PACKET = L"MC|ItemName";
|
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()
|
CustomPayloadPacket::CustomPayloadPacket()
|
||||||
|
: length(0)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -22,6 +31,7 @@ CustomPayloadPacket::CustomPayloadPacket(const wstring &identifier, byteArray da
|
||||||
{
|
{
|
||||||
this->identifier = identifier;
|
this->identifier = identifier;
|
||||||
this->data = data;
|
this->data = data;
|
||||||
|
this->length = 0;
|
||||||
|
|
||||||
if (data.data != nullptr)
|
if (data.data != nullptr)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,16 @@ public:
|
||||||
static const wstring SET_BEACON_PACKET;
|
static const wstring SET_BEACON_PACKET;
|
||||||
static const wstring SET_ITEM_NAME_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;
|
wstring identifier;
|
||||||
int length;
|
int length;
|
||||||
byteArray data;
|
byteArray data;
|
||||||
|
|
|
||||||
|
|
@ -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?
|
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();
|
short stringLength = dis->readShort();
|
||||||
if (stringLength > maxLength || stringLength <= 0)
|
if (stringLength <= 0)
|
||||||
{
|
{
|
||||||
return L"";
|
if (stringLength < 0)
|
||||||
// throw new IOException( stream.str() );
|
{
|
||||||
|
app.DebugPrintf("SECURITY: readUtf received negative string length %d\n", stringLength);
|
||||||
|
}
|
||||||
|
return L"";
|
||||||
}
|
}
|
||||||
if (stringLength < 0)
|
if (stringLength > maxLength)
|
||||||
{
|
{
|
||||||
assert(false);
|
app.DebugPrintf("SECURITY: readUtf received string length %d exceeding max %d\n", stringLength, maxLength);
|
||||||
// throw new IOException(L"Received string length is less than zero! Weird string!");
|
// Consume the declared bytes to keep the stream synchronized
|
||||||
|
dis->skip(static_cast<int64_t>(stringLength) * 2);
|
||||||
|
return L"";
|
||||||
}
|
}
|
||||||
|
|
||||||
wstring builder = L"";
|
wstring builder = L"";
|
||||||
|
builder.reserve(stringLength);
|
||||||
for (int i = 0; i < stringLength; i++)
|
for (int i = 0; i < stringLength; i++)
|
||||||
{
|
{
|
||||||
wchar_t rc = dis->readChar();
|
wchar_t rc = dis->readChar();
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
PreLoginPacket::PreLoginPacket()
|
PreLoginPacket::PreLoginPacket()
|
||||||
{
|
{
|
||||||
loginKey = L"";
|
loginKey = L"";
|
||||||
m_playerXuids = nullptr;
|
m_playerXuids = nullptr;
|
||||||
|
|
@ -20,7 +20,7 @@ PreLoginPacket::PreLoginPacket()
|
||||||
m_netcodeVersion = 0;
|
m_netcodeVersion = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
PreLoginPacket::PreLoginPacket(wstring userName)
|
PreLoginPacket::PreLoginPacket(wstring userName)
|
||||||
{
|
{
|
||||||
this->loginKey = userName;
|
this->loginKey = userName;
|
||||||
m_playerXuids = nullptr;
|
m_playerXuids = nullptr;
|
||||||
|
|
@ -34,7 +34,7 @@ PreLoginPacket::PreLoginPacket(wstring userName)
|
||||||
m_netcodeVersion = 0;
|
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;
|
this->loginKey = userName;
|
||||||
m_playerXuids = playerXuids;
|
m_playerXuids = playerXuids;
|
||||||
|
|
|
||||||
50
README.md
50
README.md
|
|
@ -14,6 +14,56 @@ This project is based on source code of Minecraft Legacy Console Edition v1.6.05
|
||||||
|
|
||||||
## Latest:
|
## 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 <name>` | Whitelist a player by name (they must attempt to connect once first) |
|
||||||
|
| `revoketoken <name>` | 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:
|
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
|
- 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
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue