yikes,
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace VanadiumGuard.Server.Bans
|
||||
{
|
||||
/// <summary>One machine as the service knows it, and every account seen on it.</summary>
|
||||
public sealed class MachineRecord
|
||||
{
|
||||
public string Composite { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The component hashes this machine reported. Kept so a ban can still recognise the
|
||||
/// machine after one part is swapped and the composite changes.
|
||||
/// </summary>
|
||||
public Dictionary<string, string> Components { get; set; } = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>Accounts that have opened a guard session from this machine.</summary>
|
||||
public List<string> PlayerIds { get; set; } = new List<string>();
|
||||
|
||||
public DateTimeOffset FirstSeenUtc { get; set; }
|
||||
|
||||
public DateTimeOffset LastSeenUtc { get; set; }
|
||||
|
||||
/// <summary>Null when the machine is not banned. A lifted ban is removed, not flagged.</summary>
|
||||
public MachineBan? Ban { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A hardware ban.
|
||||
/// <para>
|
||||
/// Every field here exists to answer a question someone will eventually ask about this
|
||||
/// record: who did it, when, why, and until when. A ban list with no answers to those is
|
||||
/// a liability rather than a tool - the appeal that cannot be evaluated gets denied by
|
||||
/// default, which is the failure mode worth engineering against.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class MachineBan
|
||||
{
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Identity of the moderator or system that issued it, recorded verbatim.</summary>
|
||||
public string IssuedBy { get; set; } = string.Empty;
|
||||
|
||||
public DateTimeOffset IssuedUtc { get; set; }
|
||||
|
||||
/// <summary>Null bans permanently.</summary>
|
||||
public DateTimeOffset? ExpiresUtc { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The accounts linked to the machine when the ban was issued. A snapshot, so the
|
||||
/// reviewer can see what the moderator was looking at rather than what the link
|
||||
/// table happens to say today.
|
||||
/// </summary>
|
||||
public List<string> LinkedPlayerIdsAtIssue { get; set; } = new List<string>();
|
||||
|
||||
public bool IsActive(DateTimeOffset now)
|
||||
{
|
||||
return ExpiresUtc == null || ExpiresUtc > now;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using VanadiumGuard.Protocol;
|
||||
|
||||
namespace VanadiumGuard.Server.Bans
|
||||
{
|
||||
/// <summary>
|
||||
/// The machine link table and the hardware ban list, held in memory and persisted to one
|
||||
/// JSON file.
|
||||
/// <para>
|
||||
/// Two decisions are worth stating outright. A ban matches on the composite id or on any
|
||||
/// single strong component, because someone who swaps a disk to get back in should not
|
||||
/// thereby be a new person - but only strong components count, since matching on a CPU
|
||||
/// model would ban everyone who bought the same processor. And a ban here refuses the
|
||||
/// guard session, which is a different thing from banning an account: the account ban
|
||||
/// belongs to the game's own moderation system, and this service does not reach into it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A file is the right store at this scale and the wrong one past it. Everything goes
|
||||
/// through this class precisely so the swap to a real database is one file to rewrite.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class MachineRegistry
|
||||
{
|
||||
private static readonly JsonSerializerOptions Json = new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
};
|
||||
|
||||
private readonly ILogger<MachineRegistry> _logger;
|
||||
private readonly string _path;
|
||||
private readonly object _gate = new object();
|
||||
|
||||
private readonly Dictionary<string, MachineRecord> _byComposite =
|
||||
new Dictionary<string, MachineRecord>(StringComparer.Ordinal);
|
||||
|
||||
public MachineRegistry(ILogger<MachineRegistry> logger, string banDirectory)
|
||||
{
|
||||
_logger = logger;
|
||||
_path = Path.Combine(banDirectory, "machines.json");
|
||||
|
||||
Load();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records that a player opened a session from this machine, and returns the stored
|
||||
/// record. Unusable fingerprints are ignored rather than stored under an empty key.
|
||||
/// </summary>
|
||||
public MachineRecord? Observe(MachineFingerprint? fingerprint, string playerId)
|
||||
{
|
||||
if (fingerprint == null || !fingerprint.IsUsable)
|
||||
return null;
|
||||
|
||||
bool changed = false;
|
||||
MachineRecord record;
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_byComposite.TryGetValue(fingerprint.Composite, out MachineRecord? existing))
|
||||
{
|
||||
existing = new MachineRecord
|
||||
{
|
||||
Composite = fingerprint.Composite,
|
||||
Components = new Dictionary<string, string>(fingerprint.Components, StringComparer.Ordinal),
|
||||
FirstSeenUtc = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
_byComposite[fingerprint.Composite] = existing;
|
||||
changed = true;
|
||||
}
|
||||
else if (MergeComponents(existing, fingerprint))
|
||||
{
|
||||
changed = true;
|
||||
}
|
||||
|
||||
existing.LastSeenUtc = DateTimeOffset.UtcNow;
|
||||
|
||||
if (!string.IsNullOrEmpty(playerId) && !existing.PlayerIds.Contains(playerId, StringComparer.Ordinal))
|
||||
{
|
||||
existing.PlayerIds.Add(playerId);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
record = existing;
|
||||
|
||||
// LastSeen alone is not worth a write on every session open. Anything that
|
||||
// changes what a moderator would see is.
|
||||
if (changed)
|
||||
Save();
|
||||
}
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The active ban covering this machine, or null. Checks the exact machine first,
|
||||
/// then every banned machine for a shared strong component.
|
||||
/// </summary>
|
||||
public (MachineRecord Record, MachineBan Ban)? FindActiveBan(MachineFingerprint? fingerprint)
|
||||
{
|
||||
if (fingerprint == null || !fingerprint.IsUsable)
|
||||
return null;
|
||||
|
||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
if (_byComposite.TryGetValue(fingerprint.Composite, out MachineRecord? exact)
|
||||
&& exact.Ban != null
|
||||
&& exact.Ban.IsActive(now))
|
||||
{
|
||||
return (exact, exact.Ban);
|
||||
}
|
||||
|
||||
foreach (MachineRecord record in _byComposite.Values)
|
||||
{
|
||||
if (record.Ban == null || !record.Ban.IsActive(now))
|
||||
continue;
|
||||
|
||||
if (SharesStrongComponent(record, fingerprint))
|
||||
return (record, record.Ban);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bans one machine by its composite id. Returns false when the machine has never
|
||||
/// been seen: banning a hash nobody has reported would put a record in the list that
|
||||
/// can never match anything and can never be reviewed.
|
||||
/// </summary>
|
||||
public bool Ban(string composite, string reason, string issuedBy, DateTimeOffset? expiresUtc)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(composite))
|
||||
return false;
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_byComposite.TryGetValue(composite, out MachineRecord? record))
|
||||
return false;
|
||||
|
||||
record.Ban = new MachineBan
|
||||
{
|
||||
Reason = string.IsNullOrWhiteSpace(reason) ? "unspecified" : reason.Trim(),
|
||||
IssuedBy = string.IsNullOrWhiteSpace(issuedBy) ? "unknown" : issuedBy.Trim(),
|
||||
IssuedUtc = DateTimeOffset.UtcNow,
|
||||
ExpiresUtc = expiresUtc,
|
||||
LinkedPlayerIdsAtIssue = new List<string>(record.PlayerIds),
|
||||
};
|
||||
|
||||
Save();
|
||||
}
|
||||
|
||||
_logger.LogWarning(
|
||||
"Machine {Composite} banned by {IssuedBy} until {Expires}: {Reason}",
|
||||
composite, issuedBy, expiresUtc?.ToString("O") ?? "permanent", reason);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bans every machine this player has been seen on. This is how a moderator uses the
|
||||
/// feature in practice: they know an account, not a hash.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> BanMachinesOf(string playerId, string reason, string issuedBy, DateTimeOffset? expiresUtc)
|
||||
{
|
||||
var banned = new List<string>();
|
||||
|
||||
foreach (MachineRecord record in ForPlayer(playerId))
|
||||
{
|
||||
if (Ban(record.Composite, reason, issuedBy, expiresUtc))
|
||||
banned.Add(record.Composite);
|
||||
}
|
||||
|
||||
if (banned.Count == 0)
|
||||
_logger.LogInformation("No machines on record for player {Player}; nothing banned", playerId);
|
||||
|
||||
return banned;
|
||||
}
|
||||
|
||||
/// <summary>Lifts a ban. The record stays; only the ban is removed.</summary>
|
||||
public bool Unban(string composite, string liftedBy, string reason)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_byComposite.TryGetValue(composite, out MachineRecord? record) || record.Ban == null)
|
||||
return false;
|
||||
|
||||
record.Ban = null;
|
||||
Save();
|
||||
}
|
||||
|
||||
_logger.LogWarning("Machine {Composite} unbanned by {LiftedBy}: {Reason}", composite, liftedBy, reason);
|
||||
return true;
|
||||
}
|
||||
|
||||
public MachineRecord? Get(string composite)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return _byComposite.TryGetValue(composite, out MachineRecord? record) ? record : null;
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<MachineRecord> ForPlayer(string playerId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(playerId))
|
||||
return Array.Empty<MachineRecord>();
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
return _byComposite.Values
|
||||
.Where(r => r.PlayerIds.Contains(playerId, StringComparer.Ordinal))
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when the record and the fingerprint agree on a component specific enough to
|
||||
/// mean the same machine.
|
||||
/// </summary>
|
||||
private static bool SharesStrongComponent(MachineRecord record, MachineFingerprint fingerprint)
|
||||
{
|
||||
foreach (string name in MachineComponents.Strong)
|
||||
{
|
||||
if (record.Components.TryGetValue(name, out string? stored)
|
||||
&& fingerprint.Components.TryGetValue(name, out string? offered)
|
||||
&& !string.IsNullOrEmpty(stored)
|
||||
&& string.Equals(stored, offered, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Folds newly reported components into a stored record. Components are added but
|
||||
/// never overwritten: a machine that suddenly reports a different motherboard hash
|
||||
/// under the same composite is either a hardware change or an attempt to overwrite
|
||||
/// the thing a ban matches on, and keeping the first value read makes both harmless.
|
||||
/// </summary>
|
||||
private static bool MergeComponents(MachineRecord record, MachineFingerprint fingerprint)
|
||||
{
|
||||
bool changed = false;
|
||||
|
||||
foreach (KeyValuePair<string, string> pair in fingerprint.Components)
|
||||
{
|
||||
if (!record.Components.ContainsKey(pair.Key))
|
||||
{
|
||||
record.Components[pair.Key] = pair.Value;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
private void Load()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(_path))
|
||||
{
|
||||
_logger.LogInformation("No machine registry at {Path}; starting empty", _path);
|
||||
return;
|
||||
}
|
||||
|
||||
MachineRecord[]? records = JsonSerializer.Deserialize<MachineRecord[]>(
|
||||
File.ReadAllText(_path), Json);
|
||||
|
||||
if (records == null)
|
||||
return;
|
||||
|
||||
foreach (MachineRecord record in records)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(record.Composite))
|
||||
_byComposite[record.Composite] = record;
|
||||
}
|
||||
|
||||
int banned = _byComposite.Values.Count(r => r.Ban != null);
|
||||
_logger.LogInformation("Machine registry loaded: {Total} machines, {Banned} banned", _byComposite.Count, banned);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Starting with an empty ban list would silently let every banned machine
|
||||
// back in, so this is loud and the file is left alone for a human to look at.
|
||||
_logger.LogError(ex, "Machine registry at {Path} could not be read; hardware bans are NOT in effect", _path);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the registry through a temporary file. Called with the lock held.
|
||||
/// <para>
|
||||
/// The rename matters more than it looks: this file is the only record of who is
|
||||
/// banned, and a process killed midway through a plain overwrite would leave a
|
||||
/// truncated file that fails to parse and unbans everyone at the next start.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private void Save()
|
||||
{
|
||||
try
|
||||
{
|
||||
string? directory = Path.GetDirectoryName(_path);
|
||||
if (!string.IsNullOrEmpty(directory))
|
||||
Directory.CreateDirectory(directory);
|
||||
|
||||
string temporary = _path + ".tmp";
|
||||
File.WriteAllText(temporary, JsonSerializer.Serialize(_byComposite.Values.ToArray(), Json));
|
||||
|
||||
File.Move(temporary, _path, overwrite: true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Machine registry could not be written to {Path}", _path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using VanadiumGuard.Protocol;
|
||||
using VanadiumGuard.Server.Policy;
|
||||
using VanadiumGuard.Server.Sessions;
|
||||
|
||||
namespace VanadiumGuard.Server.Challenges
|
||||
{
|
||||
/// <summary>
|
||||
/// Issues memory challenges and grades the answers.
|
||||
/// <para>
|
||||
/// A challenge asks the client to hash a random region of a known module with a random
|
||||
/// salt. Three outcomes matter: the right hash means a real guard is running inside a
|
||||
/// real image; the wrong hash means the memory it read does not match what shipped;
|
||||
/// no answer at all means the guard is not running, which is graded by the liveness
|
||||
/// sweeper rather than here.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class ChallengeService
|
||||
{
|
||||
/// <summary>How long a client has to answer before the challenge is written off.</summary>
|
||||
public static readonly TimeSpan AnswerWindow = TimeSpan.FromSeconds(60);
|
||||
|
||||
private const int MinReadLength = 256;
|
||||
|
||||
private readonly ILogger<ChallengeService> _logger;
|
||||
private readonly IReferenceImageStore _images;
|
||||
private readonly PolicyEngine _policy;
|
||||
|
||||
public ChallengeService(ILogger<ChallengeService> logger, IReferenceImageStore images, PolicyEngine policy)
|
||||
{
|
||||
_logger = logger;
|
||||
_images = images;
|
||||
_policy = policy;
|
||||
}
|
||||
|
||||
/// <summary>Builds the next challenge for a session, or null if none can be issued.</summary>
|
||||
public MemoryChallenge? Issue(GuardSession session, GuardPolicy policy)
|
||||
{
|
||||
string[] modules = policy.ChallengeableModules;
|
||||
if (modules.Length == 0)
|
||||
return null;
|
||||
|
||||
// Try each module once, in random order, so an unavailable reference image for
|
||||
// one module does not silently stop challenges altogether.
|
||||
foreach (string module in Shuffle(modules))
|
||||
{
|
||||
long size = _images.Size(session.GameBuild, module);
|
||||
if (size < MinReadLength)
|
||||
continue;
|
||||
|
||||
int length = RandomNumberGenerator.GetInt32(MinReadLength, ProtocolConstants.MaxChallengeReadLength + 1);
|
||||
if (length > size)
|
||||
length = (int)Math.Min(size, ProtocolConstants.MaxChallengeReadLength);
|
||||
|
||||
uint rva = (uint)RandomNumberGenerator.GetInt32(0, (int)Math.Min(int.MaxValue, size - length + 1));
|
||||
|
||||
var salt = new byte[16];
|
||||
RandomNumberGenerator.Fill(salt);
|
||||
|
||||
var challenge = new MemoryChallenge
|
||||
{
|
||||
ChallengeId = Guid.NewGuid().ToString("N"),
|
||||
Module = module,
|
||||
Rva = rva,
|
||||
Length = length,
|
||||
Salt = Convert.ToHexString(salt).ToLowerInvariant(),
|
||||
IssuedUtc = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
session.AddChallenge(challenge);
|
||||
return challenge;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Grades answers, recording detections for anything that does not add up.</summary>
|
||||
public void Grade(GuardSession session, IReadOnlyList<ChallengeAnswer> answers)
|
||||
{
|
||||
foreach (ChallengeAnswer answer in answers)
|
||||
{
|
||||
MemoryChallenge? challenge = session.ClaimChallenge(answer.ChallengeId);
|
||||
|
||||
if (challenge == null)
|
||||
{
|
||||
// An answer to something never asked, or asked twice. Not worth a
|
||||
// detection on its own, but worth knowing about.
|
||||
_logger.LogInformation(
|
||||
"Unsolicited challenge answer {Id} from session {Session}",
|
||||
answer.ChallengeId, session.SessionId);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!answer.Readable)
|
||||
{
|
||||
_policy.RecordServerSide(
|
||||
session, DetectionCode.ChallengeUnreadable, challenge.Module, 60,
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["error"] = answer.Error ?? "unspecified",
|
||||
["rva"] = challenge.Rva.ToString(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
byte[]? reference = _images.Read(session.GameBuild, challenge.Module, challenge.Rva, challenge.Length);
|
||||
|
||||
if (reference == null)
|
||||
{
|
||||
// No reference image, so this answer proves liveness but nothing else.
|
||||
// Explicitly NOT a detection: failing players because the operator has
|
||||
// not published a build manifest would be the worst kind of bug.
|
||||
_logger.LogDebug(
|
||||
"No reference for build={Build} module={Module}; challenge {Id} counts as liveness only",
|
||||
session.GameBuild, challenge.Module, challenge.ChallengeId);
|
||||
continue;
|
||||
}
|
||||
|
||||
string expected = RequestSigner.HashWithSalt(Convert.FromHexString(challenge.Salt), reference);
|
||||
|
||||
if (string.Equals(expected, answer.Hash, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
_policy.RecordServerSide(
|
||||
session, DetectionCode.ChallengeFailed, challenge.Module, 95,
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["rva"] = "0x" + challenge.Rva.ToString("X"),
|
||||
["length"] = challenge.Length.ToString(),
|
||||
["expected"] = expected,
|
||||
["received"] = answer.Hash,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<string> Shuffle(string[] source)
|
||||
{
|
||||
var copy = (string[])source.Clone();
|
||||
|
||||
for (int i = copy.Length - 1; i > 0; i--)
|
||||
{
|
||||
int j = RandomNumberGenerator.GetInt32(i + 1);
|
||||
(copy[i], copy[j]) = (copy[j], copy[i]);
|
||||
}
|
||||
|
||||
return copy;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.IO;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace VanadiumGuard.Server.Challenges
|
||||
{
|
||||
/// <summary>
|
||||
/// Supplies the known-good bytes a challenge answer is checked against.
|
||||
/// </summary>
|
||||
public interface IReferenceImageStore
|
||||
{
|
||||
/// <summary>Returns the reference bytes, or null when this build or region is unknown.</summary>
|
||||
byte[]? Read(string gameBuild, string module, uint rva, int length);
|
||||
|
||||
/// <summary>Size of the reference image, used to pick a valid random offset.</summary>
|
||||
long Size(string gameBuild, string module);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads reference images from disk, laid out as
|
||||
/// <c>{ReferenceImageDirectory}/{gameBuild}/{module}.bin</c>.
|
||||
/// <para>
|
||||
/// Produce those files from the shipped binaries as part of your build pipeline: dump
|
||||
/// each module's mapped image once, on a clean machine, and publish it alongside the
|
||||
/// build. Without them the server cannot verify a challenge answer, and challenges
|
||||
/// degrade to a liveness check only.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class FileReferenceImageStore : IReferenceImageStore
|
||||
{
|
||||
private readonly ILogger<FileReferenceImageStore> _logger;
|
||||
private readonly string _root;
|
||||
private readonly ConcurrentDictionary<string, byte[]?> _cache = new ConcurrentDictionary<string, byte[]?>();
|
||||
|
||||
public FileReferenceImageStore(ILogger<FileReferenceImageStore> logger, string root)
|
||||
{
|
||||
_logger = logger;
|
||||
_root = root;
|
||||
}
|
||||
|
||||
public byte[]? Read(string gameBuild, string module, uint rva, int length)
|
||||
{
|
||||
byte[]? image = Image(gameBuild, module);
|
||||
if (image == null)
|
||||
return null;
|
||||
|
||||
if (rva + (uint)length > image.Length)
|
||||
return null;
|
||||
|
||||
var slice = new byte[length];
|
||||
Buffer.BlockCopy(image, (int)rva, slice, 0, length);
|
||||
return slice;
|
||||
}
|
||||
|
||||
public long Size(string gameBuild, string module)
|
||||
{
|
||||
return Image(gameBuild, module)?.LongLength ?? 0;
|
||||
}
|
||||
|
||||
private byte[]? Image(string gameBuild, string module)
|
||||
{
|
||||
// Both components land in a file path, so anything that could escape the root
|
||||
// is rejected outright rather than sanitised.
|
||||
if (!IsSafeComponent(gameBuild) || !IsSafeComponent(module))
|
||||
{
|
||||
_logger.LogWarning("Rejected reference lookup for build={Build} module={Module}", gameBuild, module);
|
||||
return null;
|
||||
}
|
||||
|
||||
string key = gameBuild + "/" + module;
|
||||
|
||||
return _cache.GetOrAdd(key, _ =>
|
||||
{
|
||||
string path = Path.Combine(_root, gameBuild, module + ".bin");
|
||||
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
_logger.LogWarning("No reference image at {Path}; challenges for it cannot be verified", path);
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return File.ReadAllBytes(path);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to read reference image {Path}", path);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static bool IsSafeComponent(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value) || value.Length > 128)
|
||||
return false;
|
||||
|
||||
foreach (char c in value)
|
||||
{
|
||||
bool ok = char.IsLetterOrDigit(c) || c == '.' || c == '-' || c == '_';
|
||||
if (!ok)
|
||||
return false;
|
||||
}
|
||||
|
||||
return !value.Contains("..", StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,566 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using VanadiumGuard.Protocol;
|
||||
using VanadiumGuard.Server.Bans;
|
||||
using VanadiumGuard.Server.Challenges;
|
||||
using VanadiumGuard.Server.Hosting;
|
||||
using VanadiumGuard.Server.Policy;
|
||||
using VanadiumGuard.Server.Security;
|
||||
using VanadiumGuard.Server.Sessions;
|
||||
|
||||
namespace VanadiumGuard.Server.Endpoints
|
||||
{
|
||||
/// <summary>Log category marker, so endpoint logs are not filed under an unrelated type.</summary>
|
||||
public sealed class GuardApi
|
||||
{
|
||||
}
|
||||
|
||||
public static class GuardEndpoints
|
||||
{
|
||||
public static void MapGuardEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapPost("/v1/session/open", OpenSessionAsync);
|
||||
app.MapPost("/v1/session/report", ReportAsync);
|
||||
app.MapPost("/v1/session/heartbeat", HeartbeatAsync);
|
||||
app.MapGet("/v1/internal/session/{gameSessionId}/status", Status);
|
||||
|
||||
// Hardware bans. Internal, key-gated, and never reachable from a game client:
|
||||
// these endpoints can end a session and can keep a person out permanently.
|
||||
app.MapPost("/v1/internal/machine/ban", BanMachine);
|
||||
app.MapPost("/v1/internal/machine/unban", UnbanMachine);
|
||||
app.MapGet("/v1/internal/machine/{composite}", GetMachine);
|
||||
app.MapGet("/v1/internal/player/{playerId}/machines", GetPlayerMachines);
|
||||
app.MapGet("/health", () => Results.Ok(new { status = "ok" }));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens a guard session. This is the only unsigned endpoint, because it is where
|
||||
/// the signing key is established. It is authenticated with the player's normal
|
||||
/// game token instead, so a caller cannot open a session as someone else.
|
||||
/// </summary>
|
||||
private static async Task<IResult> OpenSessionAsync(
|
||||
HttpContext context,
|
||||
SessionOpenRequest request,
|
||||
IPlayerAuthenticator authenticator,
|
||||
ISessionStore sessions,
|
||||
PolicyProvider policies,
|
||||
MachineRegistry machines,
|
||||
GuardServerOptions options,
|
||||
ILogger<GuardApi> logger)
|
||||
{
|
||||
if (request.ProtocolVersion != ProtocolConstants.Version)
|
||||
{
|
||||
return Results.Json(
|
||||
new { error = "unsupported protocol version", expected = ProtocolConstants.Version },
|
||||
statusCode: StatusCodes.Status426UpgradeRequired);
|
||||
}
|
||||
|
||||
string? bearer = ExtractBearer(context);
|
||||
string? playerId = await authenticator.AuthenticateAsync(bearer);
|
||||
|
||||
if (string.IsNullOrEmpty(playerId))
|
||||
{
|
||||
// No valid token. Refuse unless anonymous sessions are allowed, in which case
|
||||
// the session is tied to the machine rather than an account - see
|
||||
// GuardServerOptions.AllowAnonymousSessions. An anonymous session still gets a
|
||||
// signing key, policy, hardware-ban checks and liveness; it simply is not
|
||||
// attributed to a player until the client is wired to send a token.
|
||||
if (!options.AllowAnonymousSessions)
|
||||
return Results.Unauthorized();
|
||||
|
||||
playerId = "anon";
|
||||
}
|
||||
|
||||
// The authenticated identity wins over whatever the body claimed. Trusting the
|
||||
// body here would let any player file detections against any other player.
|
||||
if (!string.IsNullOrEmpty(request.PlayerId)
|
||||
&& !string.Equals(request.PlayerId, playerId, StringComparison.Ordinal))
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Session open claimed player {Claimed} but token belongs to {Actual}",
|
||||
request.PlayerId, playerId);
|
||||
}
|
||||
|
||||
// Hardware bans are checked before a session exists at all. Refusing here rather
|
||||
// than issuing a session and immediately disconnecting it means a banned machine
|
||||
// never receives a signing key, never receives the policy, and never learns which
|
||||
// detectors are switched on this week.
|
||||
(MachineRecord Record, MachineBan Ban)? machineBan = machines.FindActiveBan(request.Machine);
|
||||
|
||||
if (machineBan != null)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Refused session for player {Player}: machine {Composite} banned by {IssuedBy} for {Reason}",
|
||||
playerId, machineBan.Value.Record.Composite, machineBan.Value.Ban.IssuedBy, machineBan.Value.Ban.Reason);
|
||||
|
||||
return Results.Json(new SessionRejection
|
||||
{
|
||||
ReasonCode = "VG-7001",
|
||||
|
||||
// Deliberately says nothing about how the machine was recognised. Telling
|
||||
// someone which component matched is telling them which one to change.
|
||||
PlayerMessage =
|
||||
"This system has been blocked from playing. " +
|
||||
"If you believe this is wrong, contact support and quote this code.",
|
||||
ExpiresUtc = machineBan.Value.Ban.ExpiresUtc,
|
||||
}, statusCode: StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
bool identified = request.Machine != null && request.Machine.IsUsable;
|
||||
|
||||
if (!identified && !options.AllowUnidentifiedMachines)
|
||||
{
|
||||
logger.LogWarning("Refused session for player {Player}: no machine identity", playerId);
|
||||
|
||||
return Results.Json(new SessionRejection
|
||||
{
|
||||
ReasonCode = "VG-7003",
|
||||
PlayerMessage =
|
||||
"This system could not be verified. " +
|
||||
"Contact support and quote this code.",
|
||||
}, statusCode: StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
MachineRecord? machine = machines.Observe(request.Machine, playerId);
|
||||
|
||||
// Device attestation. Verified before the session exists, so a bad proof never
|
||||
// gets a signing key issued against it.
|
||||
//
|
||||
// Verified against the player id the CLIENT put in the request, not the id the
|
||||
// server resolved: the client signed the proof over request.PlayerId, so that is
|
||||
// what has to be rebuilt to check the signature. They match for a token-authed
|
||||
// open; they differ for an anonymous one (client sends empty, server resolves
|
||||
// "anon"), and using the resolved id there would reject every honest anonymous
|
||||
// session. Identity is still proven by the token above - this only proves the
|
||||
// device holds its key and the request is fresh.
|
||||
AttestationResult attestation = AttestationVerifier.VerifyProof(
|
||||
request.Attestation, request.PlayerId ?? string.Empty, request.GameBuild ?? "unknown");
|
||||
|
||||
if (attestation is AttestationResult.Malformed or AttestationResult.BadProof or AttestationResult.Stale)
|
||||
{
|
||||
// Presenting a key you cannot prove you hold is not a machine quirk; it is
|
||||
// someone constructing requests by hand.
|
||||
logger.LogWarning(
|
||||
"Attestation rejected for player {Player}: {Result}", playerId, attestation);
|
||||
|
||||
return Results.Json(new SessionRejection
|
||||
{
|
||||
ReasonCode = "VG-8001",
|
||||
PlayerMessage =
|
||||
"This system could not be verified. " +
|
||||
"Contact support and quote this code.",
|
||||
}, statusCode: StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
bool attested = attestation == AttestationResult.Ok
|
||||
&& request.Attestation!.Backing == AttestationBacking.Tpm;
|
||||
|
||||
if (!attested && options.RequireDeviceAttestation)
|
||||
{
|
||||
logger.LogWarning("Refused session for player {Player}: no TPM attestation", playerId);
|
||||
|
||||
return Results.Json(new SessionRejection
|
||||
{
|
||||
ReasonCode = "VG-8002",
|
||||
PlayerMessage =
|
||||
"This system does not support the security features required to play. " +
|
||||
"Contact support and quote this code.",
|
||||
}, statusCode: StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
GuardPolicy policy = policies.Current;
|
||||
byte[] key = RequestSigner.NewSessionKey();
|
||||
|
||||
var session = new GuardSession(
|
||||
sessionId: Guid.NewGuid().ToString("N"),
|
||||
sessionKey: key,
|
||||
playerId: playerId,
|
||||
gameSessionId: request.GameSessionId ?? string.Empty,
|
||||
gameBuild: request.GameBuild ?? "unknown")
|
||||
{
|
||||
PolicyVersion = policy.PolicyVersion,
|
||||
MachineComposite = machine?.Composite ?? string.Empty,
|
||||
AttestationKey = attestation == AttestationResult.Ok ? request.Attestation!.PublicKey : string.Empty,
|
||||
KeyBacking = attestation == AttestationResult.Ok ? request.Attestation!.Backing : AttestationBacking.None,
|
||||
};
|
||||
|
||||
sessions.Add(session);
|
||||
|
||||
logger.LogInformation(
|
||||
"Session {Session} opened for player {Player} build {Build} guard {Guard} machine {Machine} attestation {Attestation}",
|
||||
session.SessionId, playerId, request.GameBuild, request.GuardVersion,
|
||||
machine?.Composite ?? "unidentified",
|
||||
attested ? "tpm:" + DeviceAttestation.Thumbprint(session.AttestationKey) : attestation.ToString().ToLowerInvariant());
|
||||
|
||||
return Results.Ok(new SessionOpenResponse
|
||||
{
|
||||
SessionId = session.SessionId,
|
||||
SessionKey = Convert.ToBase64String(key),
|
||||
Policy = policy,
|
||||
ServerTimeUtc = DateTimeOffset.UtcNow,
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<IResult> ReportAsync(
|
||||
HttpContext context,
|
||||
ISessionStore sessions,
|
||||
PolicyEngine policyEngine)
|
||||
{
|
||||
SignedRequest<ReportRequest> signed =
|
||||
await SignedRequestReader.ReadAsync<ReportRequest>(context, sessions, "/v1/session/report");
|
||||
|
||||
IResult? rejection = HandleRejection(signed.Status, signed.Session, policyEngine);
|
||||
if (rejection != null)
|
||||
return rejection;
|
||||
|
||||
GuardSession session = signed.Session!;
|
||||
ReportRequest payload = signed.Payload!;
|
||||
|
||||
if (payload.DroppedCount > 0)
|
||||
{
|
||||
// The client had more to say than it could queue. Worth knowing, because a
|
||||
// flood of detections is itself informative.
|
||||
policyEngine.RecordServerSide(
|
||||
session, DetectionCode.PolicyRefused, "client-queue-overflow", 30,
|
||||
new Dictionary<string, string> { ["dropped"] = payload.DroppedCount.ToString() });
|
||||
}
|
||||
|
||||
Verdict verdict = policyEngine.Evaluate(session, payload.Detections);
|
||||
|
||||
return Results.Ok(new ReportResponse
|
||||
{
|
||||
Verdict = verdict,
|
||||
Accepted = payload.Detections.Length,
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<IResult> HeartbeatAsync(
|
||||
HttpContext context,
|
||||
ISessionStore sessions,
|
||||
PolicyEngine policyEngine,
|
||||
PolicyProvider policies,
|
||||
ChallengeService challenges)
|
||||
{
|
||||
SignedRequest<HeartbeatRequest> signed =
|
||||
await SignedRequestReader.ReadAsync<HeartbeatRequest>(context, sessions, "/v1/session/heartbeat");
|
||||
|
||||
IResult? rejection = HandleRejection(signed.Status, signed.Session, policyEngine);
|
||||
if (rejection != null)
|
||||
return rejection;
|
||||
|
||||
GuardSession session = signed.Session!;
|
||||
session.MarkHeartbeat();
|
||||
|
||||
challenges.Grade(session, signed.Payload!.Answers);
|
||||
|
||||
GuardPolicy policy = policies.Current;
|
||||
MemoryChallenge? next = challenges.Issue(session, policy);
|
||||
|
||||
return Results.Ok(new HeartbeatResponse
|
||||
{
|
||||
Verdict = policyEngine.Current(session),
|
||||
Challenges = next == null ? Array.Empty<MemoryChallenge>() : new[] { next },
|
||||
NewPolicyVersion = policy.PolicyVersion == session.PolicyVersion ? null : policy.PolicyVersion,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What the game server asks before trusting a player. This is the endpoint that
|
||||
/// makes the whole design server-authoritative: gameplay code consults the service
|
||||
/// rather than the client consulting itself.
|
||||
/// </summary>
|
||||
private static IResult Status(
|
||||
HttpContext context,
|
||||
string gameSessionId,
|
||||
ISessionStore sessions,
|
||||
PolicyEngine policyEngine,
|
||||
GuardServerOptions options)
|
||||
{
|
||||
if (!IsInternalCaller(context, options))
|
||||
return Results.Unauthorized();
|
||||
|
||||
GuardSession? session = sessions.GetByGameSession(gameSessionId);
|
||||
|
||||
if (session == null)
|
||||
{
|
||||
// No guard ever checked in for this match. Whether that is grounds for
|
||||
// action is the game server's call, not this service's; it reports the fact.
|
||||
return Results.Ok(new GuardStatusResponse
|
||||
{
|
||||
GuardPresent = false,
|
||||
GuardHealthy = false,
|
||||
RiskScore = 0,
|
||||
RecommendedAction = VerdictAction.Continue,
|
||||
});
|
||||
}
|
||||
|
||||
TimeSpan silence = DateTimeOffset.UtcNow - session.LastSeenUtc;
|
||||
bool healthy = silence < TimeSpan.FromSeconds(options.SessionIdleTimeoutSeconds);
|
||||
|
||||
return Results.Ok(new GuardStatusResponse
|
||||
{
|
||||
GuardPresent = true,
|
||||
GuardHealthy = healthy,
|
||||
Attested = session.IsAttested,
|
||||
RiskScore = session.RiskScore(),
|
||||
LastHeartbeatUtc = session.LastHeartbeatUtc,
|
||||
Codes = session.Detections().Select(d => (int)d.Detection.Code).Distinct().ToArray(),
|
||||
RecommendedAction = policyEngine.Current(session).Action,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bans a machine, either by its composite id or by naming a player whose machines
|
||||
/// should be banned - moderators know accounts, not hashes.
|
||||
/// <para>
|
||||
/// Reason and issuer are required, not optional. A ban list where either can be blank
|
||||
/// becomes unreviewable within a month, and an unreviewable ban list is one that
|
||||
/// never gets a wrong entry removed.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private static IResult BanMachine(
|
||||
HttpContext context,
|
||||
MachineBanRequest request,
|
||||
MachineRegistry machines,
|
||||
ISessionStore sessions,
|
||||
GuardServerOptions options,
|
||||
ILogger<GuardApi> logger)
|
||||
{
|
||||
if (!IsInternalCaller(context, options))
|
||||
return Results.Unauthorized();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Reason))
|
||||
return Results.BadRequest(new { error = "reason is required" });
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.IssuedBy))
|
||||
return Results.BadRequest(new { error = "issuedBy is required" });
|
||||
|
||||
if (request.ExpiresUtc != null && request.ExpiresUtc <= DateTimeOffset.UtcNow)
|
||||
return Results.BadRequest(new { error = "expiresUtc is already in the past" });
|
||||
|
||||
var banned = new List<string>();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Composite))
|
||||
{
|
||||
if (machines.Ban(request.Composite, request.Reason, request.IssuedBy, request.ExpiresUtc))
|
||||
banned.Add(request.Composite);
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(request.PlayerId))
|
||||
{
|
||||
banned.AddRange(
|
||||
machines.BanMachinesOf(request.PlayerId, request.Reason, request.IssuedBy, request.ExpiresUtc));
|
||||
}
|
||||
else
|
||||
{
|
||||
return Results.BadRequest(new { error = "composite or playerId is required" });
|
||||
}
|
||||
|
||||
if (banned.Count == 0)
|
||||
{
|
||||
// Nothing was banned because nothing matched. Saying so beats reporting a
|
||||
// success the moderator would otherwise believe.
|
||||
return Results.NotFound(new { error = "no machine on record for that composite or player" });
|
||||
}
|
||||
|
||||
int ended = EndSessionsOnMachines(sessions, banned, logger);
|
||||
|
||||
logger.LogWarning(
|
||||
"Hardware ban by {IssuedBy}: {Count} machine(s), {Ended} live session(s) ended, reason {Reason}",
|
||||
request.IssuedBy, banned.Count, ended, request.Reason);
|
||||
|
||||
return Results.Ok(new MachineBanResult
|
||||
{
|
||||
Banned = banned.ToArray(),
|
||||
SessionsEnded = ended,
|
||||
});
|
||||
}
|
||||
|
||||
private static IResult UnbanMachine(
|
||||
HttpContext context,
|
||||
MachineUnbanRequest request,
|
||||
MachineRegistry machines,
|
||||
GuardServerOptions options,
|
||||
ILogger<GuardApi> logger)
|
||||
{
|
||||
if (!IsInternalCaller(context, options))
|
||||
return Results.Unauthorized();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Composite))
|
||||
return Results.BadRequest(new { error = "composite is required" });
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.LiftedBy))
|
||||
return Results.BadRequest(new { error = "liftedBy is required" });
|
||||
|
||||
if (!machines.Unban(request.Composite, request.LiftedBy, request.Reason ?? string.Empty))
|
||||
return Results.NotFound(new { error = "no active ban on that machine" });
|
||||
|
||||
return Results.Ok(new { unbanned = request.Composite });
|
||||
}
|
||||
|
||||
private static IResult GetMachine(
|
||||
HttpContext context,
|
||||
string composite,
|
||||
MachineRegistry machines,
|
||||
GuardServerOptions options)
|
||||
{
|
||||
if (!IsInternalCaller(context, options))
|
||||
return Results.Unauthorized();
|
||||
|
||||
MachineRecord? record = machines.Get(composite);
|
||||
|
||||
return record == null
|
||||
? Results.NotFound()
|
||||
: Results.Ok(ToView(record));
|
||||
}
|
||||
|
||||
private static IResult GetPlayerMachines(
|
||||
HttpContext context,
|
||||
string playerId,
|
||||
MachineRegistry machines,
|
||||
GuardServerOptions options)
|
||||
{
|
||||
if (!IsInternalCaller(context, options))
|
||||
return Results.Unauthorized();
|
||||
|
||||
return Results.Ok(machines.ForPlayer(playerId).Select(ToView).ToArray());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ends the live sessions running on machines that were just banned, so a ban issued
|
||||
/// mid-match takes effect in seconds rather than at the next launch. The verdict is
|
||||
/// forced rather than scored: this is a decision a person already made.
|
||||
/// </summary>
|
||||
private static int EndSessionsOnMachines(
|
||||
ISessionStore sessions, IReadOnlyCollection<string> composites, ILogger<GuardApi> logger)
|
||||
{
|
||||
var banned = new HashSet<string>(composites, StringComparer.Ordinal);
|
||||
int ended = 0;
|
||||
|
||||
foreach (GuardSession session in sessions.All())
|
||||
{
|
||||
if (string.IsNullOrEmpty(session.MachineComposite)
|
||||
|| !banned.Contains(session.MachineComposite)
|
||||
|| session.Terminated)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
session.Force(new Verdict
|
||||
{
|
||||
Action = VerdictAction.Disconnect,
|
||||
ReasonCode = "VG-7001",
|
||||
PlayerMessage =
|
||||
"This system has been blocked from playing. " +
|
||||
"If you believe this is wrong, contact support and quote this code.",
|
||||
|
||||
// The same grace the scoring path uses, so a report already in flight
|
||||
// still lands and the evidence survives the disconnect.
|
||||
DelayMs = 2000,
|
||||
});
|
||||
|
||||
ended++;
|
||||
|
||||
logger.LogWarning(
|
||||
"Session {Session} for player {Player} ended by a hardware ban",
|
||||
session.SessionId, session.PlayerId);
|
||||
}
|
||||
|
||||
return ended;
|
||||
}
|
||||
|
||||
private static MachineRecordView ToView(MachineRecord record)
|
||||
{
|
||||
return new MachineRecordView
|
||||
{
|
||||
Composite = record.Composite,
|
||||
PlayerIds = record.PlayerIds.ToArray(),
|
||||
FirstSeenUtc = record.FirstSeenUtc,
|
||||
LastSeenUtc = record.LastSeenUtc,
|
||||
Banned = record.Ban != null && record.Ban.IsActive(DateTimeOffset.UtcNow),
|
||||
BanReason = record.Ban?.Reason,
|
||||
BannedBy = record.Ban?.IssuedBy,
|
||||
BannedUtc = record.Ban?.IssuedUtc,
|
||||
ExpiresUtc = record.Ban?.ExpiresUtc,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps a validation failure to a response, recording the ones that indicate
|
||||
/// tampering rather than an ordinary network problem.
|
||||
/// </summary>
|
||||
private static IResult? HandleRejection(
|
||||
SignedRequestStatus status, GuardSession? session, PolicyEngine policyEngine)
|
||||
{
|
||||
switch (status)
|
||||
{
|
||||
case SignedRequestStatus.Ok:
|
||||
return null;
|
||||
|
||||
case SignedRequestStatus.MissingHeaders:
|
||||
case SignedRequestStatus.BadBody:
|
||||
return Results.BadRequest(new { error = status.ToString() });
|
||||
|
||||
case SignedRequestStatus.UnknownSession:
|
||||
// Tells the client to reopen rather than retry forever.
|
||||
return Results.Unauthorized();
|
||||
|
||||
case SignedRequestStatus.ClockSkew:
|
||||
return Results.Json(
|
||||
new { error = "clock skew too large" },
|
||||
statusCode: StatusCodes.Status400BadRequest);
|
||||
|
||||
case SignedRequestStatus.BadSignature:
|
||||
if (session != null)
|
||||
policyEngine.RecordServerSide(session, DetectionCode.SignatureInvalid, session.PlayerId, 80);
|
||||
return Results.Unauthorized();
|
||||
|
||||
case SignedRequestStatus.BadAttestation:
|
||||
// The session proved it held a device key at open and is no longer
|
||||
// signing with it. Weighted like a bad signature, because it is the same
|
||||
// claim: these requests are not coming from where they say they are.
|
||||
if (session != null)
|
||||
policyEngine.RecordServerSide(session, DetectionCode.AttestationInvalid, session.PlayerId, 80);
|
||||
return Results.Unauthorized();
|
||||
|
||||
case SignedRequestStatus.Replay:
|
||||
if (session != null)
|
||||
policyEngine.RecordServerSide(session, DetectionCode.SequenceReplay, session.PlayerId, 80);
|
||||
return Results.Json(new { error = "replay" }, statusCode: StatusCodes.Status409Conflict);
|
||||
|
||||
default:
|
||||
return Results.BadRequest();
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ExtractBearer(HttpContext context)
|
||||
{
|
||||
string? header = context.Request.Headers.Authorization;
|
||||
|
||||
if (string.IsNullOrEmpty(header) || !header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||
return null;
|
||||
|
||||
return header.Substring("Bearer ".Length).Trim();
|
||||
}
|
||||
|
||||
private static bool IsInternalCaller(HttpContext context, GuardServerOptions options)
|
||||
{
|
||||
if (string.IsNullOrEmpty(options.InternalApiKey))
|
||||
return false;
|
||||
|
||||
string? provided = context.Request.Headers["X-VG-Internal-Key"];
|
||||
if (string.IsNullOrEmpty(provided))
|
||||
return false;
|
||||
|
||||
return System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(
|
||||
System.Text.Encoding.UTF8.GetBytes(provided),
|
||||
System.Text.Encoding.UTF8.GetBytes(options.InternalApiKey));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
namespace VanadiumGuard.Server.Hosting
|
||||
{
|
||||
/// <summary>Bound from the Guard section of appsettings.json.</summary>
|
||||
public sealed class GuardServerOptions
|
||||
{
|
||||
public string PolicyDirectory { get; set; } = "policies";
|
||||
|
||||
public string ReferenceImageDirectory { get; set; } = "builds";
|
||||
|
||||
/// <summary>Where the machine link table and hardware ban list are kept.</summary>
|
||||
public string BanDirectory { get; set; } = "bans";
|
||||
|
||||
/// <summary>
|
||||
/// Whether a client that cannot identify its machine may open a session. Left on by
|
||||
/// default: locked-down and unusual machines legitimately fail to produce an
|
||||
/// identity, and refusing them would turn a hardware ban feature into a hardware
|
||||
/// allow-list nobody asked for. The refusal is still recorded either way.
|
||||
/// </summary>
|
||||
public bool AllowUnidentifiedMachines { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether a session may open without a TPM-backed device key. Left on by default:
|
||||
/// plenty of machines have no TPM, have it switched off in firmware, or run a
|
||||
/// provider that refuses to create keys, and none of that is cheating. Turning this
|
||||
/// off is a hardware requirement for playing, not an anti-cheat tweak.
|
||||
/// </summary>
|
||||
public bool RequireDeviceAttestation { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Shared secret for the internal endpoints the game server calls. These endpoints
|
||||
/// expose per-player risk data, so they must never be reachable by a game client.
|
||||
/// </summary>
|
||||
public string InternalApiKey { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Shared HMAC secret for the game's JWTs. The AC service validates the player's
|
||||
/// game token at session open with the SAME secret and issuer the game server uses,
|
||||
/// so it authenticates without a callback. Must match the game server exactly.
|
||||
/// </summary>
|
||||
public string JwtSecret { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Issuer the game server stamps on its JWTs. Must match exactly.</summary>
|
||||
public string JwtIssuer { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Allow a session to open with no valid player token, tied to the machine instead of
|
||||
/// an account. This exists because the guard mod is not yet wired to hand over the
|
||||
/// player's game token; with it on, the guard still connects, reports, and is subject
|
||||
/// to hardware bans - it just cannot be attributed to a specific account until the
|
||||
/// token is supplied. Turn it OFF once the client sends a token, so every session is
|
||||
/// player-attributed and no one can report anonymously.
|
||||
/// </summary>
|
||||
public bool AllowAnonymousSessions { get; set; } = false;
|
||||
|
||||
/// <summary>Silence beyond this is treated as the guard having gone away.</summary>
|
||||
public int SessionIdleTimeoutSeconds { get; set; } = 90;
|
||||
|
||||
public int MissedHeartbeatsBeforeSilent { get; set; } = 3;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using VanadiumGuard.Protocol;
|
||||
using VanadiumGuard.Server.Challenges;
|
||||
using VanadiumGuard.Server.Policy;
|
||||
using VanadiumGuard.Server.Sessions;
|
||||
|
||||
namespace VanadiumGuard.Server.Hosting
|
||||
{
|
||||
/// <summary>
|
||||
/// Notices guards that have stopped talking, and challenges that were never answered.
|
||||
/// <para>
|
||||
/// This closes the hole that made the old design pointless. Every check the old build
|
||||
/// ran was inside the game process, so the cheapest possible defeat was to stop the
|
||||
/// guard from running - and a guard that is not running reports nothing, which looked
|
||||
/// exactly like a clean player. Here, silence is the signal: the service knows a match
|
||||
/// is live because the game server told it, so a guard that stops checking in is a
|
||||
/// detection in its own right.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class LivenessSweeper : BackgroundService
|
||||
{
|
||||
private static readonly TimeSpan SweepInterval = TimeSpan.FromSeconds(15);
|
||||
|
||||
private readonly ILogger<LivenessSweeper> _logger;
|
||||
private readonly ISessionStore _sessions;
|
||||
private readonly PolicyEngine _policy;
|
||||
private readonly GuardServerOptions _options;
|
||||
|
||||
public LivenessSweeper(
|
||||
ILogger<LivenessSweeper> logger,
|
||||
ISessionStore sessions,
|
||||
PolicyEngine policy,
|
||||
GuardServerOptions options)
|
||||
{
|
||||
_logger = logger;
|
||||
_sessions = sessions;
|
||||
_policy = policy;
|
||||
_options = options;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("Liveness sweeper started");
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
Sweep();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Liveness sweep failed");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(SweepInterval, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void Sweep()
|
||||
{
|
||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||
var idleTimeout = TimeSpan.FromSeconds(_options.SessionIdleTimeoutSeconds);
|
||||
|
||||
foreach (GuardSession session in _sessions.All())
|
||||
{
|
||||
// Challenges that aged out. A guard that answers some challenges but not
|
||||
// others is more interesting than one that answers none.
|
||||
IReadOnlyList<MemoryChallenge> expired = session.ExpiredChallenges(ChallengeService.AnswerWindow);
|
||||
foreach (MemoryChallenge challenge in expired)
|
||||
{
|
||||
_policy.RecordServerSide(
|
||||
session, DetectionCode.ChallengeUnreadable, challenge.Module, 50,
|
||||
new Dictionary<string, string> { ["reason"] = "no answer within the window" });
|
||||
}
|
||||
|
||||
TimeSpan silence = now - session.LastSeenUtc;
|
||||
|
||||
if (silence < idleTimeout)
|
||||
continue;
|
||||
|
||||
// Only meaningful while the match is still running. Without a game session
|
||||
// id we cannot tell a killed guard from a player who closed the game, and
|
||||
// accusing the second group would be a false positive machine.
|
||||
if (string.IsNullOrEmpty(session.GameSessionId))
|
||||
{
|
||||
_sessions.Remove(session.SessionId);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!session.Terminated)
|
||||
{
|
||||
_policy.RecordServerSide(
|
||||
session, DetectionCode.GuardSilent, session.PlayerId, 80,
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["silentSeconds"] = ((int)silence.TotalSeconds).ToString(),
|
||||
["lastSeen"] = session.LastSeenUtc.ToString("O"),
|
||||
});
|
||||
|
||||
_logger.LogWarning(
|
||||
"Guard silent for {Seconds}s player={Player} gameSession={GameSession}",
|
||||
(int)silence.TotalSeconds, session.PlayerId, session.GameSessionId);
|
||||
}
|
||||
|
||||
// Keep the session around a while longer so the game server can still query
|
||||
// its status; drop it once it is thoroughly stale.
|
||||
if (silence > idleTimeout + TimeSpan.FromMinutes(10))
|
||||
_sessions.Remove(session.SessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
using System.Collections.Generic;
|
||||
using VanadiumGuard.Protocol;
|
||||
|
||||
namespace VanadiumGuard.Server.Policy
|
||||
{
|
||||
/// <summary>
|
||||
/// What each detection is worth, and whether it may ever act on its own.
|
||||
/// <para>
|
||||
/// This table is the whole reason the redesign exists. In the old build every signal was
|
||||
/// worth the same thing - immediate death - so a Cheat Engine window and someone having
|
||||
/// a file named injector.exe in Downloads had identical consequences. Here a signal has
|
||||
/// a weight, and only a handful of signals are trusted enough to end a session by
|
||||
/// themselves.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class DetectionCatalog
|
||||
{
|
||||
public sealed class Entry
|
||||
{
|
||||
/// <summary>Score contribution at 100 confidence. Scores accumulate toward 100.</summary>
|
||||
public double Weight { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this code alone, at high confidence, justifies removing the player.
|
||||
/// Reserved for direct evidence of code modification inside the game process.
|
||||
/// </summary>
|
||||
public bool Decisive { get; init; }
|
||||
|
||||
/// <summary>Human-readable, used in review tooling and audit logs.</summary>
|
||||
public string Description { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
private static readonly Dictionary<DetectionCode, Entry> Entries = new()
|
||||
{
|
||||
// Modules. Loading a known cheat framework into the process is strong, but the
|
||||
// name match is still a name match, so it is not decisive on its own.
|
||||
[DetectionCode.BlacklistedModuleLoaded] = new Entry
|
||||
{
|
||||
Weight = 55, Decisive = false, Description = "Known cheat or hooking library mapped into the game",
|
||||
},
|
||||
[DetectionCode.ModuleUnbackedByDisk] = new Entry
|
||||
{
|
||||
Weight = 45, Decisive = false, Description = "Module present without a loader notification (manual map)",
|
||||
},
|
||||
[DetectionCode.ModuleFromVolatilePath] = new Entry
|
||||
{
|
||||
Weight = 8, Decisive = false, Description = "Module loaded from a temp or downloads directory",
|
||||
},
|
||||
[DetectionCode.ModuleFromUntrustedPath] = new Entry
|
||||
{
|
||||
Weight = 5, Decisive = false, Description = "Module loaded from outside the trusted roots",
|
||||
},
|
||||
|
||||
// External environment. Weak by construction: what is open on someone's second
|
||||
// monitor is not evidence of what is happening inside the game.
|
||||
[DetectionCode.BlacklistedProcess] = new Entry
|
||||
{
|
||||
Weight = 12, Decisive = false, Description = "A listed tool is running on the machine",
|
||||
},
|
||||
[DetectionCode.BlacklistedWindow] = new Entry
|
||||
{
|
||||
Weight = 10, Decisive = false, Description = "A listed tool has a visible window",
|
||||
},
|
||||
[DetectionCode.DebuggerAttached] = new Entry
|
||||
{
|
||||
Weight = 60, Decisive = false, Description = "A debugger is attached to the game process",
|
||||
},
|
||||
|
||||
// Code integrity. This is the evidence worth acting on: something modified
|
||||
// executable memory inside the process, which nothing legitimate does here.
|
||||
[DetectionCode.NtExportPrologueModified] = new Entry
|
||||
{
|
||||
Weight = 70, Decisive = false, Description = "Syscall stub prologue rewritten as a detour",
|
||||
},
|
||||
[DetectionCode.GameCodeModified] = new Entry
|
||||
{
|
||||
Weight = 85, Decisive = true, Description = "Game code section modified in memory",
|
||||
},
|
||||
[DetectionCode.Il2CppMethodHooked] = new Entry
|
||||
{
|
||||
Weight = 90, Decisive = true, Description = "Inline hook on a watched gameplay method",
|
||||
},
|
||||
|
||||
// Managed integrity.
|
||||
[DetectionCode.ForeignHarmonyPatch] = new Entry
|
||||
{
|
||||
Weight = 6, Decisive = false, Description = "Harmony patch from an unrecognised mod",
|
||||
},
|
||||
[DetectionCode.EnforcementMethodPatched] = new Entry
|
||||
{
|
||||
Weight = 95, Decisive = true, Description = "The guard or process exit path was patched",
|
||||
},
|
||||
|
||||
// Guard integrity, raised server-side. Tampering with the guard is treated as
|
||||
// seriously as tampering with the game, because it is done for the same reason.
|
||||
[DetectionCode.ChallengeFailed] = new Entry
|
||||
{
|
||||
Weight = 90, Decisive = true, Description = "Memory challenge answered with the wrong hash",
|
||||
},
|
||||
[DetectionCode.ChallengeUnreadable] = new Entry
|
||||
{
|
||||
Weight = 20, Decisive = false, Description = "Client could not read a challenged region",
|
||||
},
|
||||
[DetectionCode.GuardSilent] = new Entry
|
||||
{
|
||||
Weight = 50, Decisive = false, Description = "Guard stopped heartbeating while the match was live",
|
||||
},
|
||||
[DetectionCode.SignatureInvalid] = new Entry
|
||||
{
|
||||
Weight = 70, Decisive = false, Description = "Request signature did not verify",
|
||||
},
|
||||
[DetectionCode.SequenceReplay] = new Entry
|
||||
{
|
||||
Weight = 70, Decisive = false, Description = "Replayed or reordered request sequence",
|
||||
},
|
||||
[DetectionCode.PolicyRefused] = new Entry
|
||||
{
|
||||
Weight = 15, Decisive = false, Description = "Client refused to apply the issued policy",
|
||||
},
|
||||
|
||||
// Hardware bans. These are recorded for the audit trail, not to move a score:
|
||||
// a machine ban is a decision that was already made by a person, and running it
|
||||
// back through the scoring path would let a threshold second-guess them.
|
||||
[DetectionCode.MachineBanned] = new Entry
|
||||
{
|
||||
Weight = 0, Decisive = false, Description = "Session opened from a banned machine",
|
||||
},
|
||||
[DetectionCode.MachineLinkedToBan] = new Entry
|
||||
{
|
||||
Weight = 0, Decisive = false, Description = "Machine shares hardware with a banned machine",
|
||||
},
|
||||
[DetectionCode.MachineUnidentifiable] = new Entry
|
||||
{
|
||||
Weight = 0, Decisive = false, Description = "Client could not produce a machine identity",
|
||||
},
|
||||
|
||||
// Device attestation. A session that proved it held a device key and then stopped
|
||||
// signing with it is claiming to be somewhere it is not, which is the one thing
|
||||
// the HMAC alone can never catch.
|
||||
[DetectionCode.AttestationInvalid] = new Entry
|
||||
{
|
||||
Weight = 70, Decisive = false, Description = "Request not signed by the bound device key",
|
||||
},
|
||||
[DetectionCode.AttestationMissing] = new Entry
|
||||
{
|
||||
Weight = 0, Decisive = false, Description = "Session opened without a hardware-backed key",
|
||||
},
|
||||
};
|
||||
|
||||
public static Entry For(DetectionCode code)
|
||||
{
|
||||
return Entries.TryGetValue(code, out Entry? entry)
|
||||
? entry
|
||||
: new Entry { Weight = 0, Decisive = false, Description = "Unknown detection code" };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using VanadiumGuard.Protocol;
|
||||
using VanadiumGuard.Server.Sessions;
|
||||
|
||||
namespace VanadiumGuard.Server.Policy
|
||||
{
|
||||
/// <summary>
|
||||
/// Turns detections into a verdict. This is the only place in the system where a
|
||||
/// decision about a player is made, and it runs on the server where the player cannot
|
||||
/// reach it.
|
||||
/// </summary>
|
||||
public sealed class PolicyEngine
|
||||
{
|
||||
/// <summary>Score at which the session is ended.</summary>
|
||||
public const int DisconnectThreshold = 80;
|
||||
|
||||
/// <summary>Score at which the player is told something is wrong but keeps playing.</summary>
|
||||
public const int WarnThreshold = 40;
|
||||
|
||||
private readonly ILogger<PolicyEngine> _logger;
|
||||
|
||||
public PolicyEngine(ILogger<PolicyEngine> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a batch against the session and returns what the client should do.
|
||||
/// </summary>
|
||||
public Verdict Evaluate(GuardSession session, IReadOnlyList<Detection> detections)
|
||||
{
|
||||
bool decisive = false;
|
||||
|
||||
foreach (Detection detection in detections)
|
||||
{
|
||||
DetectionCatalog.Entry entry = DetectionCatalog.For(detection.Code);
|
||||
|
||||
if (entry.Weight <= 0)
|
||||
{
|
||||
_logger.LogDebug("Ignoring unweighted code {Code} from {Session}", detection.Code, session.SessionId);
|
||||
continue;
|
||||
}
|
||||
|
||||
int confidence = Math.Clamp(detection.Confidence, 0, 100);
|
||||
|
||||
// Confidence scales the weight. A detector rolled out at MaxConfidence 0
|
||||
// contributes exactly nothing, which is what makes a safe rollout possible.
|
||||
double score = entry.Weight * (confidence / 100.0);
|
||||
session.Record(detection, score);
|
||||
|
||||
// Decisive codes still have to arrive at high confidence. A weak reading of
|
||||
// a serious signal is evidence, not a conviction.
|
||||
if (entry.Decisive && confidence >= 85)
|
||||
{
|
||||
decisive = true;
|
||||
_logger.LogWarning(
|
||||
"Decisive detection {Code} subject={Subject} conf={Confidence} player={Player} session={Session}",
|
||||
detection.Code, detection.Subject, confidence, session.PlayerId, session.SessionId);
|
||||
}
|
||||
}
|
||||
|
||||
return VerdictFor(session, decisive);
|
||||
}
|
||||
|
||||
/// <summary>Re-derives a verdict without adding anything, for heartbeats and status.</summary>
|
||||
public Verdict Current(GuardSession session)
|
||||
{
|
||||
return VerdictFor(session, decisive: false);
|
||||
}
|
||||
|
||||
private Verdict VerdictFor(GuardSession session, bool decisive)
|
||||
{
|
||||
// A forced verdict outranks the score. A hardware ban is not a risk judgement
|
||||
// and must not be re-derived from one.
|
||||
Verdict? forced = session.ForcedVerdict;
|
||||
if (forced != null)
|
||||
return forced;
|
||||
|
||||
if (session.Terminated)
|
||||
{
|
||||
return new Verdict
|
||||
{
|
||||
Action = VerdictAction.Disconnect,
|
||||
ReasonCode = "VG-TERM",
|
||||
PlayerMessage = "This session was ended by an anti-cheat check.",
|
||||
};
|
||||
}
|
||||
|
||||
int score = session.RiskScore();
|
||||
|
||||
if (decisive || score >= DisconnectThreshold)
|
||||
{
|
||||
session.MarkTerminated();
|
||||
|
||||
_logger.LogWarning(
|
||||
"Disconnecting player={Player} session={Session} score={Score} decisive={Decisive}",
|
||||
session.PlayerId, session.SessionId, score, decisive);
|
||||
|
||||
return new Verdict
|
||||
{
|
||||
Action = VerdictAction.Disconnect,
|
||||
ReasonCode = "VG-" + score.ToString("D3"),
|
||||
PlayerMessage =
|
||||
"You have been removed from this session by an anti-cheat check. " +
|
||||
"If you believe this is wrong, contact support and quote this code.",
|
||||
|
||||
// A short grace period so the client can flush the batch that triggered
|
||||
// this. Without it the most important evidence is the evidence lost.
|
||||
DelayMs = 2000,
|
||||
};
|
||||
}
|
||||
|
||||
if (score >= WarnThreshold)
|
||||
{
|
||||
return new Verdict
|
||||
{
|
||||
Action = VerdictAction.Warn,
|
||||
ReasonCode = "VG-W" + score.ToString("D3"),
|
||||
PlayerMessage =
|
||||
"Close any debugging or memory tools before continuing. " +
|
||||
"Leaving them open may end your session.",
|
||||
};
|
||||
}
|
||||
|
||||
return new Verdict { Action = VerdictAction.Continue };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raises a detection the server itself observed, e.g. a bad signature or a guard
|
||||
/// that went quiet. These never come from the client, which is exactly why they are
|
||||
/// worth something.
|
||||
/// </summary>
|
||||
public void RecordServerSide(GuardSession session, DetectionCode code, string subject, int confidence,
|
||||
IDictionary<string, string>? evidence = null)
|
||||
{
|
||||
var detection = new Detection
|
||||
{
|
||||
Code = code,
|
||||
Severity = Severity.High,
|
||||
Confidence = confidence,
|
||||
Subject = subject,
|
||||
Detector = "server",
|
||||
FirstObservedUtc = DateTimeOffset.UtcNow,
|
||||
LastObservedUtc = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
if (evidence != null)
|
||||
{
|
||||
foreach (KeyValuePair<string, string> pair in evidence)
|
||||
detection.Evidence[pair.Key] = pair.Value;
|
||||
}
|
||||
|
||||
DetectionCatalog.Entry entry = DetectionCatalog.For(code);
|
||||
session.Record(detection, entry.Weight * (confidence / 100.0));
|
||||
|
||||
_logger.LogWarning(
|
||||
"Server-side detection {Code} subject={Subject} player={Player}",
|
||||
code, subject, session.PlayerId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using VanadiumGuard.Protocol;
|
||||
|
||||
namespace VanadiumGuard.Server.Policy
|
||||
{
|
||||
/// <summary>
|
||||
/// Serves the policy handed to clients at session open, reloading it from disk when the
|
||||
/// file changes so rules can be adjusted without a deploy.
|
||||
/// </summary>
|
||||
public sealed class PolicyProvider
|
||||
{
|
||||
private readonly ILogger<PolicyProvider> _logger;
|
||||
private readonly string _path;
|
||||
private GuardPolicy _policy;
|
||||
private DateTime _loadedStamp;
|
||||
|
||||
public PolicyProvider(ILogger<PolicyProvider> logger, string policyDirectory)
|
||||
{
|
||||
_logger = logger;
|
||||
_path = Path.Combine(policyDirectory, "policy.json");
|
||||
_policy = Load();
|
||||
}
|
||||
|
||||
public GuardPolicy Current
|
||||
{
|
||||
get
|
||||
{
|
||||
ReloadIfChanged();
|
||||
return _policy;
|
||||
}
|
||||
}
|
||||
|
||||
private void ReloadIfChanged()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(_path))
|
||||
return;
|
||||
|
||||
DateTime stamp = File.GetLastWriteTimeUtc(_path);
|
||||
if (stamp <= _loadedStamp)
|
||||
return;
|
||||
|
||||
_policy = Load();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Keep serving the last good policy. A malformed edit must not take the
|
||||
// whole fleet offline.
|
||||
_logger.LogError(ex, "Policy reload failed; continuing with the previous policy");
|
||||
}
|
||||
}
|
||||
|
||||
private GuardPolicy Load()
|
||||
{
|
||||
if (!File.Exists(_path))
|
||||
{
|
||||
_logger.LogWarning("No policy at {Path}; writing the built-in default", _path);
|
||||
|
||||
GuardPolicy fallback = Default();
|
||||
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(_path)!);
|
||||
File.WriteAllText(_path, JsonSerializer.Serialize(
|
||||
fallback, new JsonSerializerOptions { WriteIndented = true }));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Could not write the default policy to {Path}", _path);
|
||||
}
|
||||
|
||||
_loadedStamp = DateTime.UtcNow;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
GuardPolicy? loaded = JsonSerializer.Deserialize<GuardPolicy>(
|
||||
File.ReadAllText(_path),
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
|
||||
if (loaded == null)
|
||||
throw new InvalidOperationException("policy.json deserialised to null");
|
||||
|
||||
_loadedStamp = File.GetLastWriteTimeUtc(_path);
|
||||
_logger.LogInformation("Loaded policy version {Version}", loaded.PolicyVersion);
|
||||
return loaded;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Policy at {Path} is invalid; using the built-in default", _path);
|
||||
_loadedStamp = DateTime.UtcNow;
|
||||
return Default();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The shipped starting point.
|
||||
/// <para>
|
||||
/// Note what is missing relative to the old hard-coded lists. There is no filesystem
|
||||
/// scanner, so nothing looks at what a player keeps in Documents or Downloads; that
|
||||
/// was a large privacy cost for a signal that only ever proved someone had once
|
||||
/// downloaded a tool. Generic process names are gone. Reverse-engineering tools that
|
||||
/// developers use every day are present but weighted low, because owning IDA is not
|
||||
/// evidence of cheating in a match.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static GuardPolicy Default()
|
||||
{
|
||||
return new GuardPolicy
|
||||
{
|
||||
PolicyVersion = "2024.09.05-1",
|
||||
ReportIntervalMs = 5000,
|
||||
HeartbeatIntervalMs = 15000,
|
||||
|
||||
Detectors = new[]
|
||||
{
|
||||
new DetectorSettings { Name = "module", Enabled = true, MaxConfidence = 100 },
|
||||
new DetectorSettings { Name = "process", Enabled = true, MaxConfidence = 60 },
|
||||
new DetectorSettings { Name = "window", Enabled = true, MaxConfidence = 60 },
|
||||
new DetectorSettings { Name = "debugger", Enabled = true, MaxConfidence = 100 },
|
||||
new DetectorSettings { Name = "nativehook", Enabled = true, MaxConfidence = 100 },
|
||||
|
||||
// Starts in observe-only mode. Every player with a second mod installed
|
||||
// trips this, so it reports for a while and earns its confidence later.
|
||||
new DetectorSettings { Name = "managedpatch", Enabled = true, MaxConfidence = 0 },
|
||||
|
||||
new DetectorSettings { Name = "il2cpp", Enabled = true, MaxConfidence = 100 },
|
||||
},
|
||||
|
||||
ModuleAllowPrefixes = new[]
|
||||
{
|
||||
"vanadiumguard", "melonloader", "0harmony", "il2cpp", "unityplayer", "unityengine",
|
||||
"gameassembly", "baselib", "unitycrashhandler", "recroom", "easyanticheat",
|
||||
"system.", "microsoft.", "mono", "netstandard", "mscor", "clr", "coreclr",
|
||||
"hostfxr", "hostpolicy", "ucrtbase", "vcruntime", "msvcp", "msvcr",
|
||||
"ntdll", "kernel32", "kernelbase", "user32", "gdi32", "win32u", "advapi32",
|
||||
"sechost", "rpcrt4", "combase", "ole32", "oleaut32", "shell32", "shlwapi",
|
||||
"psapi", "winhttp", "wininet", "crypt32", "bcrypt", "ncrypt", "schannel",
|
||||
"iphlpapi", "ws2_32", "mswsock", "dnsapi", "setupapi", "cfgmgr32", "devobj",
|
||||
"powrprof", "secur32", "sspicli", "userenv", "profapi", "winmm", "dsound",
|
||||
"xaudio", "x3daudio", "xinput", "dinput", "openal", "physx", "steam",
|
||||
"nvapi", "nvcuda", "nvoglv", "cudart", "amdxc", "atiadl", "igd", "dxgi",
|
||||
"d3d", "d3dcompiler", "dcomp", "dwmapi", "uxtheme", "propsys", "version",
|
||||
"dbghelp", "dbgcore", "symsrv", "textinputframework", "coreuicomponents",
|
||||
"coremessaging", "windows.storage", "wintypes", "apphelp", "avrt", "wtsapi32",
|
||||
},
|
||||
|
||||
TrustedPathPrefixes = new[]
|
||||
{
|
||||
"\\windows\\system32\\",
|
||||
"\\windows\\winsxs\\",
|
||||
"\\program files\\",
|
||||
"\\program files (x86)\\",
|
||||
"\\steam\\steamapps\\",
|
||||
"\\dotnet\\",
|
||||
},
|
||||
|
||||
ModuleDenyList = new[]
|
||||
{
|
||||
"minhook", "easyhook", "blackbone", "detours", "polyhook",
|
||||
"scyllahide", "scyllahook",
|
||||
"frida-agent", "frida-gadget",
|
||||
"cheatengine", "ceserver", "vehdebug", "speedhack",
|
||||
"unityexplorer", "monoinjector", "melonsampler",
|
||||
"vmthook", "internalhack", "externalhack",
|
||||
},
|
||||
|
||||
// Exact image names only. Anything that is also a normal program name has
|
||||
// been left off entirely rather than matched loosely.
|
||||
ProcessDenyList = new[]
|
||||
{
|
||||
"cheatengine-x86_64.exe", "cheatengine-i386.exe",
|
||||
"cheatengine-x86_64-SSE4-AVX2.exe", "ceserver.exe",
|
||||
"x64dbg.exe", "x32dbg.exe", "ollydbg.exe", "immunitydebugger.exe",
|
||||
"windbg.exe", "windbgx.exe",
|
||||
"ida.exe", "ida64.exe", "idaq.exe", "idaq64.exe",
|
||||
"ghidraRun.exe",
|
||||
"dnSpy.exe", "dnSpy-x86.exe", "dnSpyEx.exe", "ILSpy.exe", "dotPeek64.exe",
|
||||
"scylla.exe", "scylla_x64.exe", "ScyllaHide.exe",
|
||||
"frida.exe", "frida-server.exe", "frida-trace.exe",
|
||||
"artmoney.exe", "squalr.exe",
|
||||
"HTTPDebuggerUI.exe", "HTTPDebuggerSvc.exe",
|
||||
"extremeinjector.exe", "processhacker.exe", "SystemInformer.exe",
|
||||
},
|
||||
|
||||
// A class alone never counts; the detector requires a title match to treat
|
||||
// one of these as more than informational.
|
||||
WindowClassDenyList = new[]
|
||||
{
|
||||
"TFormCheatEngine", "TMemoryBrowser", "Qt5152QWindowIcon", "OLLYDBG",
|
||||
"ID Window", "WinDbgFrameClass",
|
||||
},
|
||||
|
||||
WindowTitleDenyList = new[]
|
||||
{
|
||||
"Cheat Engine", "Memory Viewer", "Add Address Manually",
|
||||
"x64dbg", "x32dbg", "OllyDbg", "Immunity Debugger",
|
||||
"IDA -", "Ghidra:", "dnSpy", "ILSpy",
|
||||
"HTTP Debugger", "Extreme Injector",
|
||||
},
|
||||
|
||||
WatchedExports = new[]
|
||||
{
|
||||
new WatchedExport { Module = "ntdll.dll", Export = "NtProtectVirtualMemory" },
|
||||
new WatchedExport { Module = "ntdll.dll", Export = "NtWriteVirtualMemory" },
|
||||
new WatchedExport { Module = "ntdll.dll", Export = "NtReadVirtualMemory" },
|
||||
new WatchedExport { Module = "ntdll.dll", Export = "NtCreateThreadEx" },
|
||||
new WatchedExport { Module = "ntdll.dll", Export = "NtOpenProcess" },
|
||||
new WatchedExport { Module = "ntdll.dll", Export = "LdrLoadDll" },
|
||||
},
|
||||
|
||||
// Replace these with the real signatures for your build. They are examples,
|
||||
// and a target that does not resolve is skipped rather than treated as a fault.
|
||||
Il2CppTargets = new[]
|
||||
{
|
||||
new Il2CppTarget { TypeName = "UnityEngine.Camera", MethodName = "set_fieldOfView", ParameterCount = 1 },
|
||||
},
|
||||
|
||||
AllowedPatchOwners = new[]
|
||||
{
|
||||
"vanadiumguard", "vanadium.patch", "melonloader",
|
||||
},
|
||||
|
||||
ChallengeableModules = new[]
|
||||
{
|
||||
"GameAssembly.dll", "UnityPlayer.dll", "VanadiumGuard.dll",
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
using System;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using VanadiumGuard.Protocol;
|
||||
|
||||
namespace VanadiumGuard.Server.Security
|
||||
{
|
||||
/// <summary>Why an attestation was rejected, for logs and detections.</summary>
|
||||
public enum AttestationResult
|
||||
{
|
||||
/// <summary>Proof verified. The key is bound to the session.</summary>
|
||||
Ok,
|
||||
|
||||
/// <summary>No attestation offered. Falls back to HMAC-only; not an error.</summary>
|
||||
Absent,
|
||||
|
||||
/// <summary>Key material was unusable, or the algorithm was not one we verify.</summary>
|
||||
Malformed,
|
||||
|
||||
/// <summary>The proof did not verify under the offered key.</summary>
|
||||
BadProof,
|
||||
|
||||
/// <summary>The proof was too old to accept.</summary>
|
||||
Stale,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies device attestations at session open, and the per-request signatures that
|
||||
/// follow.
|
||||
/// <para>
|
||||
/// Worth being precise about what this buys, because it is easy to oversell. It does not
|
||||
/// make the client honest: an attacker sitting on the machine can ask the TPM to sign
|
||||
/// whatever the guard would have signed. What it removes is the ability to extract a key
|
||||
/// and drive a session from somewhere else, which is what the HMAC scheme cannot stop,
|
||||
/// and it pins every request to one piece of hardware that can then be banned.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class AttestationVerifier
|
||||
{
|
||||
/// <summary>
|
||||
/// How old a proof of possession may be. Tighter than the request clock skew because
|
||||
/// this is presented once, at the start, and has no sequence number behind it.
|
||||
/// </summary>
|
||||
public static readonly TimeSpan MaxProofAge = TimeSpan.FromMinutes(5);
|
||||
|
||||
/// <summary>
|
||||
/// Checks the proof of possession presented at session open. Returns Absent, not an
|
||||
/// error, when no attestation was offered.
|
||||
/// </summary>
|
||||
public static AttestationResult VerifyProof(
|
||||
DeviceAttestation? attestation, string playerId, string gameBuild)
|
||||
{
|
||||
if (attestation == null || !attestation.IsPresent)
|
||||
return AttestationResult.Absent;
|
||||
|
||||
if (!string.Equals(attestation.Algorithm, DeviceAttestation.AlgorithmEs256, StringComparison.Ordinal))
|
||||
return AttestationResult.Malformed;
|
||||
|
||||
double age = Math.Abs(
|
||||
(DateTimeOffset.UtcNow - DateTimeOffset.FromUnixTimeMilliseconds(attestation.TimestampUnixMs))
|
||||
.TotalSeconds);
|
||||
|
||||
if (age > MaxProofAge.TotalSeconds)
|
||||
return AttestationResult.Stale;
|
||||
|
||||
string proof = DeviceAttestation.BuildProofString(
|
||||
playerId, gameBuild, attestation.TimestampUnixMs, attestation.Nonce, attestation.PublicKey);
|
||||
|
||||
return Verify(attestation.PublicKey, proof, attestation.ProofSignature)
|
||||
? AttestationResult.Ok
|
||||
: AttestationResult.BadProof;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies one signature made by a bound device key over the canonical request
|
||||
/// string. Returns false for anything it cannot check, including unusable key
|
||||
/// material - a key that stops parsing mid-session is not something to wave through.
|
||||
/// </summary>
|
||||
public static bool Verify(string publicKeyBase64, string payload, string signatureBase64)
|
||||
{
|
||||
if (string.IsNullOrEmpty(publicKeyBase64) || string.IsNullOrEmpty(signatureBase64))
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
byte[] spki = Convert.FromBase64String(publicKeyBase64);
|
||||
byte[] signature = Convert.FromBase64String(signatureBase64);
|
||||
|
||||
using ECDsa key = ECDsa.Create();
|
||||
key.ImportSubjectPublicKeyInfo(spki, out _);
|
||||
|
||||
// Default signature format on both ends is IEEE P1363 fixed-field, which is
|
||||
// what ECDsa.SignData produces on the client. Do not switch one side to DER
|
||||
// without switching the other.
|
||||
return key.VerifyData(
|
||||
Encoding.UTF8.GetBytes(payload), signature, HashAlgorithmName.SHA256);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Bad base64, a key that is not P-256, a truncated SPKI. All the same answer.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace VanadiumGuard.Server.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies the bearer token a client presents at session open and returns the player
|
||||
/// id it belongs to. Implement this against your existing auth service.
|
||||
/// </summary>
|
||||
public interface IPlayerAuthenticator
|
||||
{
|
||||
/// <summary>Returns the authenticated player id, or null when the token is not valid.</summary>
|
||||
Task<string?> AuthenticateAsync(string? bearerToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Development stand-in that trusts whatever the client claims.
|
||||
/// <para>
|
||||
/// It refuses to run outside Development precisely because trusting the client about
|
||||
/// who it is would let anyone file detections against anyone else.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class TrustClaimedIdentityAuthenticator : IPlayerAuthenticator
|
||||
{
|
||||
public Task<string?> AuthenticateAsync(string? bearerToken)
|
||||
{
|
||||
return Task.FromResult<string?>(string.IsNullOrEmpty(bearerToken) ? null : bearerToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using System;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using VanadiumGuard.Server.Hosting;
|
||||
|
||||
namespace VanadiumGuard.Server.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// Authenticates the bearer token the guard presents at session open by validating it as
|
||||
/// the game server's own JWT - same symmetric secret, same issuer.
|
||||
/// <para>
|
||||
/// It deliberately does no database lookup. Only the game server can mint a token that
|
||||
/// verifies against the shared secret, so a valid signature already proves the identity;
|
||||
/// requiring the AC service to also reach the game's player store would couple two
|
||||
/// services that are meant to be able to run apart, for no extra assurance. The player id
|
||||
/// returned is the <c>sub</c>/<c>pid</c> claim, which the game server sets equal.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class JwtPlayerAuthenticator : IPlayerAuthenticator
|
||||
{
|
||||
private readonly TokenValidationParameters _parameters;
|
||||
private readonly JwtSecurityTokenHandler _handler = new JwtSecurityTokenHandler();
|
||||
private readonly ILogger<JwtPlayerAuthenticator> _logger;
|
||||
|
||||
public JwtPlayerAuthenticator(GuardServerOptions options, ILogger<JwtPlayerAuthenticator> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
|
||||
_parameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(options.JwtSecret)),
|
||||
ValidateIssuer = true,
|
||||
ValidIssuer = options.JwtIssuer,
|
||||
ValidateAudience = false,
|
||||
ValidateLifetime = true,
|
||||
ClockSkew = TimeSpan.Zero,
|
||||
};
|
||||
}
|
||||
|
||||
public Task<string?> AuthenticateAsync(string? bearerToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(bearerToken))
|
||||
return Task.FromResult<string?>(null);
|
||||
|
||||
try
|
||||
{
|
||||
_handler.ValidateToken(bearerToken, _parameters, out SecurityToken validated);
|
||||
var jwt = (JwtSecurityToken)validated;
|
||||
|
||||
string? sub = jwt.Claims.FirstOrDefault(c => c.Type == "sub")?.Value;
|
||||
string? pid = jwt.Claims.FirstOrDefault(c => c.Type == "pid")?.Value;
|
||||
|
||||
// sub and pid are set equal by the game server; a token where they disagree
|
||||
// is not one it minted, so it is refused the same as a bad signature.
|
||||
if (string.IsNullOrEmpty(sub) || sub != pid)
|
||||
return Task.FromResult<string?>(null);
|
||||
|
||||
return Task.FromResult<string?>(sub);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// An invalid, expired, or forged token is an ordinary "no" here, not an error
|
||||
// worth a stack trace - it is exactly what this method exists to catch.
|
||||
_logger.LogDebug("token rejected: {Message}", ex.Message);
|
||||
return Task.FromResult<string?>(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using VanadiumGuard.Protocol;
|
||||
using VanadiumGuard.Server.Sessions;
|
||||
|
||||
namespace VanadiumGuard.Server.Security
|
||||
{
|
||||
/// <summary>Outcome of validating a signed request.</summary>
|
||||
public enum SignedRequestStatus
|
||||
{
|
||||
Ok,
|
||||
MissingHeaders,
|
||||
UnknownSession,
|
||||
ClockSkew,
|
||||
BadSignature,
|
||||
BadAttestation,
|
||||
Replay,
|
||||
BadBody,
|
||||
}
|
||||
|
||||
public sealed class SignedRequest<T>
|
||||
where T : class
|
||||
{
|
||||
public SignedRequestStatus Status { get; init; }
|
||||
public GuardSession? Session { get; init; }
|
||||
public T? Payload { get; init; }
|
||||
|
||||
public bool IsValid => Status == SignedRequestStatus.Ok && Session != null && Payload != null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads and validates a signed request body.
|
||||
/// <para>
|
||||
/// The order of checks matters. The session is resolved first so a failure can be
|
||||
/// attributed to a player, then the signature, then replay. A request that fails
|
||||
/// signature or replay is not merely rejected: it is recorded against the session as
|
||||
/// a detection, because a client sending malformed signatures is a client someone has
|
||||
/// been editing.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class SignedRequestReader
|
||||
{
|
||||
private static readonly JsonSerializerOptions Json = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
public static async Task<SignedRequest<T>> ReadAsync<T>(
|
||||
HttpContext context, ISessionStore sessions, string path)
|
||||
where T : class
|
||||
{
|
||||
string? sessionId = context.Request.Headers[ProtocolConstants.HeaderSession];
|
||||
string? timestampRaw = context.Request.Headers[ProtocolConstants.HeaderTimestamp];
|
||||
string? nonce = context.Request.Headers[ProtocolConstants.HeaderNonce];
|
||||
string? signature = context.Request.Headers[ProtocolConstants.HeaderSignature];
|
||||
string? sequenceRaw = context.Request.Headers[ProtocolConstants.HeaderSequence];
|
||||
|
||||
if (string.IsNullOrEmpty(sessionId) || string.IsNullOrEmpty(timestampRaw)
|
||||
|| string.IsNullOrEmpty(nonce) || string.IsNullOrEmpty(signature)
|
||||
|| string.IsNullOrEmpty(sequenceRaw))
|
||||
{
|
||||
return new SignedRequest<T> { Status = SignedRequestStatus.MissingHeaders };
|
||||
}
|
||||
|
||||
GuardSession? session = sessions.Get(sessionId);
|
||||
if (session == null)
|
||||
return new SignedRequest<T> { Status = SignedRequestStatus.UnknownSession };
|
||||
|
||||
if (!long.TryParse(timestampRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out long timestamp)
|
||||
|| !long.TryParse(sequenceRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out long sequence))
|
||||
{
|
||||
return new SignedRequest<T> { Status = SignedRequestStatus.MissingHeaders, Session = session };
|
||||
}
|
||||
|
||||
double skew = Math.Abs((DateTimeOffset.UtcNow - DateTimeOffset.FromUnixTimeMilliseconds(timestamp)).TotalSeconds);
|
||||
if (skew > ProtocolConstants.MaxClockSkewSeconds)
|
||||
return new SignedRequest<T> { Status = SignedRequestStatus.ClockSkew, Session = session };
|
||||
|
||||
byte[] body;
|
||||
using (var buffer = new MemoryStream())
|
||||
{
|
||||
await context.Request.Body.CopyToAsync(buffer);
|
||||
body = buffer.ToArray();
|
||||
}
|
||||
|
||||
string canonical = RequestSigner.BuildCanonicalString("POST", path, timestamp, nonce, sequence, body);
|
||||
|
||||
if (!RequestSigner.Verify(session.SessionKey, canonical, signature))
|
||||
return new SignedRequest<T> { Status = SignedRequestStatus.BadSignature, Session = session };
|
||||
|
||||
// The device key signs the same canonical string. Checked only when the session
|
||||
// actually bound a key, so a client that never had one is not suddenly failing.
|
||||
if (session.AttestationKey.Length > 0)
|
||||
{
|
||||
string? attested = context.Request.Headers[ProtocolConstants.HeaderAttestation];
|
||||
|
||||
// A session that presented a key and then stops signing with it is the
|
||||
// interesting case: that is exactly what lifting the HMAC key and driving the
|
||||
// session from another machine looks like from here.
|
||||
if (string.IsNullOrEmpty(attested)
|
||||
|| !AttestationVerifier.Verify(session.AttestationKey, canonical, attested))
|
||||
{
|
||||
return new SignedRequest<T> { Status = SignedRequestStatus.BadAttestation, Session = session };
|
||||
}
|
||||
}
|
||||
|
||||
// Only after the signature verifies, so an unauthenticated caller cannot burn
|
||||
// sequence numbers and lock a legitimate client out of its own session.
|
||||
if (!session.AcceptRequest(sequence, nonce))
|
||||
return new SignedRequest<T> { Status = SignedRequestStatus.Replay, Session = session };
|
||||
|
||||
try
|
||||
{
|
||||
T? payload = JsonSerializer.Deserialize<T>(body, Json);
|
||||
if (payload == null)
|
||||
return new SignedRequest<T> { Status = SignedRequestStatus.BadBody, Session = session };
|
||||
|
||||
return new SignedRequest<T>
|
||||
{
|
||||
Status = SignedRequestStatus.Ok,
|
||||
Session = session,
|
||||
Payload = payload,
|
||||
};
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return new SignedRequest<T> { Status = SignedRequestStatus.BadBody, Session = session };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using VanadiumGuard.Protocol;
|
||||
|
||||
namespace VanadiumGuard.Server.Sessions
|
||||
{
|
||||
/// <summary>
|
||||
/// Everything the service knows about one player's guard for one play session.
|
||||
/// <para>
|
||||
/// Instances are mutated from concurrent requests, so all access goes through the
|
||||
/// instance lock. Sessions are short-lived and low in number relative to request rate,
|
||||
/// so a lock per session is cheaper and far easier to reason about than making every
|
||||
/// field individually atomic.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class GuardSession
|
||||
{
|
||||
private readonly object _gate = new object();
|
||||
private readonly Dictionary<string, RecordedDetection> _detections = new Dictionary<string, RecordedDetection>();
|
||||
private readonly HashSet<string> _seenNonces = new HashSet<string>(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, MemoryChallenge> _openChallenges = new Dictionary<string, MemoryChallenge>();
|
||||
|
||||
public GuardSession(string sessionId, byte[] sessionKey, string playerId, string gameSessionId, string gameBuild)
|
||||
{
|
||||
SessionId = sessionId;
|
||||
SessionKey = sessionKey;
|
||||
PlayerId = playerId;
|
||||
GameSessionId = gameSessionId;
|
||||
GameBuild = gameBuild;
|
||||
OpenedUtc = DateTimeOffset.UtcNow;
|
||||
_lastSeenUtc = OpenedUtc;
|
||||
}
|
||||
|
||||
public string SessionId { get; }
|
||||
|
||||
public byte[] SessionKey { get; }
|
||||
|
||||
public string PlayerId { get; }
|
||||
|
||||
public string GameSessionId { get; }
|
||||
|
||||
public string GameBuild { get; }
|
||||
|
||||
public DateTimeOffset OpenedUtc { get; }
|
||||
|
||||
// DateTimeOffset is wider than a machine word, so these are read under the lock
|
||||
// rather than exposed as plain auto-properties. The sweeper reads them from a
|
||||
// different thread than the request handlers that write them.
|
||||
private DateTimeOffset _lastSeenUtc;
|
||||
private DateTimeOffset? _lastHeartbeatUtc;
|
||||
|
||||
public DateTimeOffset LastSeenUtc
|
||||
{
|
||||
get { lock (_gate) { return _lastSeenUtc; } }
|
||||
}
|
||||
|
||||
public DateTimeOffset? LastHeartbeatUtc
|
||||
{
|
||||
get { lock (_gate) { return _lastHeartbeatUtc; } }
|
||||
}
|
||||
|
||||
public string PolicyVersion { get; set; } = "0";
|
||||
|
||||
/// <summary>
|
||||
/// Composite machine id this session opened from, empty when the client could not
|
||||
/// identify the machine. Held so a hardware ban issued mid-match can find the live
|
||||
/// sessions it applies to.
|
||||
/// </summary>
|
||||
public string MachineComposite { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Base64 public key bound at session open, empty when the client had none. When set,
|
||||
/// every signed request must also carry a signature from its private half.
|
||||
/// </summary>
|
||||
public string AttestationKey { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>What is holding that key. Only Tpm counts as attested.</summary>
|
||||
public AttestationBacking KeyBacking { get; set; } = AttestationBacking.None;
|
||||
|
||||
/// <summary>
|
||||
/// True when requests on this session are proven to come from the machine that opened
|
||||
/// it. A software key is deliberately not enough: the guard can read it, so anyone
|
||||
/// with the same access can too.
|
||||
/// </summary>
|
||||
public bool IsAttested => KeyBacking == AttestationBacking.Tpm && AttestationKey.Length > 0;
|
||||
|
||||
/// <summary>Set once a disconnect has been issued, so it is not issued repeatedly.</summary>
|
||||
public bool Terminated { get; private set; }
|
||||
|
||||
/// <summary>Highest sequence number accepted. Replays and reorders are rejected.</summary>
|
||||
public long LastSequence { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Set when something outside the scoring path has already decided this session is
|
||||
/// over - today, a hardware ban issued while the player was mid-match. Held so the
|
||||
/// player gets the real reason instead of a generic termination code.
|
||||
/// </summary>
|
||||
private Verdict? _forced;
|
||||
|
||||
public Verdict? ForcedVerdict
|
||||
{
|
||||
get { lock (_gate) { return _forced; } }
|
||||
}
|
||||
|
||||
public void Force(Verdict verdict)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_forced = verdict;
|
||||
Terminated = true;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class RecordedDetection
|
||||
{
|
||||
public Detection Detection { get; init; } = null!;
|
||||
public double Score { get; set; }
|
||||
public DateTimeOffset FirstUtc { get; init; }
|
||||
public DateTimeOffset LastUtc { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks a request's sequence and nonce. Returns false for a replay.
|
||||
/// </summary>
|
||||
public bool AcceptRequest(long sequence, string nonce)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (sequence <= LastSequence)
|
||||
return false;
|
||||
|
||||
if (!_seenNonces.Add(nonce))
|
||||
return false;
|
||||
|
||||
// The nonce set is bounded by the sequence check anyway; trim it so a long
|
||||
// session cannot grow it without limit.
|
||||
if (_seenNonces.Count > 4096)
|
||||
_seenNonces.Clear();
|
||||
|
||||
LastSequence = sequence;
|
||||
_lastSeenUtc = DateTimeOffset.UtcNow;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public void MarkHeartbeat()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||
_lastHeartbeatUtc = now;
|
||||
_lastSeenUtc = now;
|
||||
}
|
||||
}
|
||||
|
||||
public void MarkTerminated()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
Terminated = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a detection and its score contribution. Repeats of the same subject add
|
||||
/// a heavily damped amount, so a detector firing in a loop cannot inflate a score
|
||||
/// past the threshold on its own.
|
||||
/// </summary>
|
||||
public void Record(Detection detection, double score)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
string key = detection.DedupeKey();
|
||||
|
||||
if (_detections.TryGetValue(key, out RecordedDetection? existing))
|
||||
{
|
||||
existing.LastUtc = DateTimeOffset.UtcNow;
|
||||
existing.Score += score * 0.1;
|
||||
existing.Detection.OccurrenceCount += detection.OccurrenceCount;
|
||||
return;
|
||||
}
|
||||
|
||||
_detections[key] = new RecordedDetection
|
||||
{
|
||||
Detection = detection,
|
||||
Score = score,
|
||||
FirstUtc = DateTimeOffset.UtcNow,
|
||||
LastUtc = DateTimeOffset.UtcNow,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Total risk score, capped at 100.</summary>
|
||||
public int RiskScore()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
double total = _detections.Values.Sum(d => d.Score);
|
||||
return (int)Math.Clamp(total, 0, 100);
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<RecordedDetection> Detections()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return _detections.Values
|
||||
.OrderByDescending(d => d.Score)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
public void AddChallenge(MemoryChallenge challenge)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_openChallenges[challenge.ChallengeId] = challenge;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Takes a challenge out of the open set. Null means unknown or already answered.</summary>
|
||||
public MemoryChallenge? ClaimChallenge(string challengeId)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_openChallenges.TryGetValue(challengeId, out MemoryChallenge? challenge))
|
||||
return null;
|
||||
|
||||
_openChallenges.Remove(challengeId);
|
||||
return challenge;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Challenges issued long enough ago that an answer should have arrived.</summary>
|
||||
public IReadOnlyList<MemoryChallenge> ExpiredChallenges(TimeSpan age)
|
||||
{
|
||||
DateTimeOffset cutoff = DateTimeOffset.UtcNow - age;
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
var expired = _openChallenges.Values.Where(c => c.IssuedUtc < cutoff).ToList();
|
||||
foreach (MemoryChallenge challenge in expired)
|
||||
_openChallenges.Remove(challenge.ChallengeId);
|
||||
return expired;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace VanadiumGuard.Server.Sessions
|
||||
{
|
||||
/// <summary>
|
||||
/// Session persistence. The in-memory implementation is fine for a single instance;
|
||||
/// swap in Redis or a database behind this interface to run more than one.
|
||||
/// </summary>
|
||||
public interface ISessionStore
|
||||
{
|
||||
void Add(GuardSession session);
|
||||
|
||||
GuardSession? Get(string sessionId);
|
||||
|
||||
/// <summary>Looks a session up by the match id the game server knows it as.</summary>
|
||||
GuardSession? GetByGameSession(string gameSessionId);
|
||||
|
||||
void Remove(string sessionId);
|
||||
|
||||
IReadOnlyList<GuardSession> All();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace VanadiumGuard.Server.Sessions
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public sealed class InMemorySessionStore : ISessionStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, GuardSession> _bySessionId =
|
||||
new ConcurrentDictionary<string, GuardSession>(StringComparer.Ordinal);
|
||||
|
||||
private readonly ConcurrentDictionary<string, string> _gameSessionToSessionId =
|
||||
new ConcurrentDictionary<string, string>(StringComparer.Ordinal);
|
||||
|
||||
public void Add(GuardSession session)
|
||||
{
|
||||
_bySessionId[session.SessionId] = session;
|
||||
|
||||
if (!string.IsNullOrEmpty(session.GameSessionId))
|
||||
_gameSessionToSessionId[session.GameSessionId] = session.SessionId;
|
||||
}
|
||||
|
||||
public GuardSession? Get(string sessionId)
|
||||
{
|
||||
return _bySessionId.TryGetValue(sessionId, out GuardSession? session) ? session : null;
|
||||
}
|
||||
|
||||
public GuardSession? GetByGameSession(string gameSessionId)
|
||||
{
|
||||
if (!_gameSessionToSessionId.TryGetValue(gameSessionId, out string? sessionId))
|
||||
return null;
|
||||
|
||||
return Get(sessionId);
|
||||
}
|
||||
|
||||
public void Remove(string sessionId)
|
||||
{
|
||||
if (!_bySessionId.TryRemove(sessionId, out GuardSession? session))
|
||||
return;
|
||||
|
||||
if (!string.IsNullOrEmpty(session.GameSessionId))
|
||||
_gameSessionToSessionId.TryRemove(session.GameSessionId, out _);
|
||||
}
|
||||
|
||||
public IReadOnlyList<GuardSession> All()
|
||||
{
|
||||
return _bySessionId.Values.ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user