Files
Vanadium-2023-2025-Leak-Server/Classes/Bluetint.cs
T
skidfire fe3468c65d yikes,
2026-09-06 19:59:27 -04:00

316 lines
13 KiB
C#

using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
using Vanadium.Controllers;
using static Vanadium.Classes.DBs.DBClasses.PlayerDBClasses;
namespace Vanadium.Classes
{
/// <summary>
/// Tracks which clients are running the Bluetint patch, and keeps the ones that are not
/// out of the game.
/// <para>
/// The awkward fact this is built around: the patch announces itself by calling
/// /api/anticheat/hwid, and that call needs a bearer token, so it can only happen AFTER
/// a successful login. A login gate that simply demanded a prior check-in would refuse
/// every first login ever made, patched clients included, and no amount of installing
/// the patch would fix it.
/// </para>
/// <para>
/// So enforcement runs in two steps that between them cannot deadlock. A session that
/// goes quiet past the grace window is kicked and the account is flagged. The flag denies
/// exactly one login and is then cleared, so the next attempt always gets a fresh grace
/// session. An unpatched client therefore alternates between a refusal and a kick and
/// never gets to play, while a client that has since installed the patch is refused at
/// most once and then checks in normally. Nobody can be locked out permanently by this.
/// </para>
/// <para>
/// Worth saying plainly: this stops people running an unpatched client. It does not stop
/// someone who reverse-engineers the check-in call and replays it, because no check that
/// runs on the player machine ever can. It raises the floor; it is not a wall.
/// </para>
/// </summary>
public static class Bluetint
{
private static readonly ConcurrentDictionary<long, DateTime> _lastHwid = new();
private static readonly ConcurrentDictionary<long, DateTime> _firstSeen = new();
private static readonly ConcurrentDictionary<long, byte> _loggedUnprotected = new();
/// <summary>Accounts whose last session never checked in. Denies one login, then clears.</summary>
private static readonly ConcurrentDictionary<long, DateTime> _unpatched = new();
/// <summary>How stale a check-in may be before the client counts as gone.</summary>
private static readonly TimeSpan ProtectionWindow = TimeSpan.FromMinutes(5);
public const string DefaultMessage = "No skidding. Download the Bluetint patch";
public static void MarkProtected(long playerId)
{
_lastHwid[playerId] = DateTime.UtcNow;
_loggedUnprotected.TryRemove(playerId, out _);
// The patch is here now, so whatever the last session did is no longer relevant.
_unpatched.TryRemove(playerId, out _);
}
/// <summary>True while this account has a recent enough check-in from the patch.</summary>
public static bool IsProtected(long playerId)
{
return _lastHwid.TryGetValue(playerId, out var t)
&& (DateTime.UtcNow - t) <= ProtectionWindow;
}
/// <summary>
/// True when the patch has announced itself at any point since this player came
/// online. Deliberately weaker than <see cref="IsProtected"/>, and the default,
/// because it does not assume the client re-submits on a timer. If the shipped patch
/// only calls in once at startup, a freshness rule would kick every honest player a
/// few minutes into their session, and a mass false positive costs far more than the
/// gap it closes.
/// </summary>
public static bool HasCheckedInThisSession(long playerId)
{
return _lastHwid.ContainsKey(playerId);
}
/// <summary>
/// The login gate. Returns null to allow, or the message the client should show.
/// Called from AuthController.TokenResponse, which every grant type funnels through.
/// </summary>
public static string? DenyLogin(long playerId, Microsoft.AspNetCore.Http.HttpRequest request)
{
if (!ServerConfig.RequirePatchToPlay)
return null;
// Developers are exempt, the same way they are exempt from DevsOnlyToken and the
// PC gate. Without this the first deploy of a broken check locks out the only
// people who can turn it back off.
var player = DBs.PlayerDB.Players.FindById(playerId);
if (player?.PlayerRoles?.Contains(PlayerRoles.Developer) == true)
return null;
// Oculus (Quest) players are exempt: the Bluetint patch is a PC MelonLoader mod
// that cannot run on a standalone headset, so gating them on it would lock out
// every Quest player. There is nothing for them to install, so nothing to check.
if (IsPlatformExempt(player))
return null;
// Strong gate: the patch proves itself in the login request. Only usable once the
// client ships support for it, which is why it is off by default.
if (ServerConfig.RequirePatchHeader)
{
if (!VerifyPatchHeader(request))
{
Console.WriteLine($"[Bluetint] Login refused (no patch header): player {playerId}");
return Message();
}
return null;
}
// Weak gate: deny on the evidence of the previous session, then clear the flag so
// the next attempt gets a fresh chance to check in. This is the half that works
// with the client as it ships today.
if (_unpatched.TryRemove(playerId, out _))
{
Console.WriteLine($"[Bluetint] Login refused (last session never checked in): player {playerId}");
return Message();
}
return null;
}
/// <summary>Configured refusal text, falling back to the built-in default.</summary>
private static string Message()
{
return string.IsNullOrWhiteSpace(ServerConfig.PatchRequiredMessage)
? DefaultMessage
: ServerConfig.PatchRequiredMessage;
}
/// <summary>
/// True when the account must never be gated on the Bluetint patch because of the
/// platform it plays on. Oculus (Quest) is standalone and cannot load a PC
/// MelonLoader mod, so there is nothing for those players to install or check.
/// </summary>
private static bool IsPlatformExempt(FullPlayer player)
{
return player?.PlatformIds != null
&& player.PlatformIds.Any(p => p.Platform == Platforms.Oculus);
}
/// <summary>
/// Verifies X-Bluetint-Patch: an HMAC of the current minute under a shared secret.
/// <para>
/// Time-bucketed so a header captured off the wire stops working within a couple of
/// minutes rather than forever. The secret still lives inside a DLL on the player
/// machine and can be pulled out of it - this raises the cost of copying a login
/// request, nothing more.
/// </para>
/// </summary>
private static bool VerifyPatchHeader(Microsoft.AspNetCore.Http.HttpRequest request)
{
string? provided = request.Headers["X-Bluetint-Patch"].FirstOrDefault();
if (string.IsNullOrWhiteSpace(provided))
return false;
string secret = ServerConfig.PatchHeaderSecret;
if (string.IsNullOrWhiteSpace(secret))
{
// Refusing everyone because an operator forgot to set a secret would take the
// whole game offline, so this fails open and says so.
Console.WriteLine("[Bluetint] RequirePatchHeader is on but PatchHeaderSecret is empty; allowing login");
return true;
}
long minute = DateTimeOffset.UtcNow.ToUnixTimeSeconds() / 60;
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
foreach (long bucket in new[] { minute, minute - 1, minute + 1 })
{
string expected = Convert.ToBase64String(
hmac.ComputeHash(Encoding.UTF8.GetBytes(bucket.ToString())));
if (CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(expected), Encoding.UTF8.GetBytes(provided)))
{
return true;
}
}
return false;
}
public static void StartSweep()
{
_ = Task.Run(async () =>
{
while (true)
{
try
{
await Sweep();
}
catch (Exception ex)
{
// The sweep must never be the thing that takes the server down.
Console.WriteLine($"[Bluetint] Sweep failed: {ex.Message}");
}
await Task.Delay(30000);
}
});
}
/// <summary>
/// Kicks online players whose patch never checked in, and flags them so their next
/// login is refused with the message.
/// </summary>
private static async Task Sweep()
{
var online = new HashSet<long>(NotificationsController.PlayerConnections.Keys);
// Forget people who went offline, so a player who quits and comes back gets a
// fresh grace window instead of inheriting a stale clock.
foreach (var id in _firstSeen.Keys.ToList())
{
if (!online.Contains(id))
{
_firstSeen.TryRemove(id, out _);
_loggedUnprotected.TryRemove(id, out _);
// Drop the check-in too, so "has checked in" means "this session" and
// a reconnect has to prove itself again.
_lastHwid.TryRemove(id, out _);
}
}
if (!ServerConfig.RequirePatchToPlay)
return;
var grace = TimeSpan.FromSeconds(Math.Max(30, ServerConfig.PatchGraceSeconds));
var now = DateTime.UtcNow;
foreach (var playerId in online)
{
if (!_firstSeen.TryGetValue(playerId, out var since))
{
// First time seeing them online. The grace window starts now, not at
// login, so a slow client that is still loading is not punished for it.
_firstSeen[playerId] = now;
continue;
}
bool announced = ServerConfig.PatchRequiresPeriodicCheckIn
? IsProtected(playerId)
: HasCheckedInThisSession(playerId);
if (announced)
{
_loggedUnprotected.TryRemove(playerId, out _);
continue;
}
if (now - since < grace)
continue;
var player = DBs.PlayerDB.Players.FindById(playerId);
if (player?.PlayerRoles?.Contains(PlayerRoles.Developer) == true)
continue;
// Oculus (Quest) players cannot run the PC patch, so they are never kicked
// for lacking it - same exemption as the login gate in DenyLogin.
if (IsPlatformExempt(player))
continue;
// Kick once per session, not once per sweep. A client that ignores the
// dialog should not be sent it every thirty seconds; the flag clears when
// they go offline, so a reconnect gets a fresh one.
if (!_loggedUnprotected.TryAdd(playerId, 0))
continue;
Console.WriteLine($"[Bluetint] Unpatched client, kicking player {playerId}");
_unpatched[playerId] = now;
await Kick(playerId);
}
}
/// <summary>
/// Drops one unpatched player with the message on screen.
/// <para>
/// This reuses the ban dialog because that is the only channel the client already has
/// for showing a reason before it disconnects, but nothing is written to the database
/// and no ban is recorded. The player is not banned - they are told to install the
/// patch. Category Unknown is what makes that work: the message formatter rewrites
/// every other category into "Moderator (...)" or "Cheating (...)", and Unknown is
/// the one that passes the text through exactly as written.
/// </para>
/// </summary>
private static async Task Kick(long playerId)
{
try
{
await NotificationsController.SendBanned(playerId, new ModerationBlockDetails
{
IsBan = true,
ReportCategory = ReportCategory.Unknown,
Duration = 0,
Message = Message(),
ModerationSetUnixTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds()
});
}
catch (Exception ex)
{
Console.WriteLine($"[Bluetint] Could not deliver the message to {playerId}: {ex.Message}");
}
}
}
}