mirror of
https://git.neolegacy.dev/neoStudiosLCE/neoLegacy.git
synced 2026-07-17 19:40:45 +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:
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;
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user