This commit is contained in:
skidfire
2026-09-06 19:59:27 -04:00
parent 55d8e11a5d
commit fe3468c65d
247 changed files with 338183 additions and 0 deletions
+84
View File
@@ -0,0 +1,84 @@
using System;
namespace VanadiumGuard.Protocol
{
/// <summary>
/// Body of a 403 from /v1/session/open. The client shows this and stops; it is the one
/// case where the guard refuses to run rather than running and reporting.
/// </summary>
public sealed class SessionRejection
{
/// <summary>Stable code the player can quote to support, e.g. VG-7001.</summary>
public string ReasonCode { get; set; } = string.Empty;
/// <summary>Player-facing text. Says a machine is blocked, not how it was recognised.</summary>
public string PlayerMessage { get; set; } = string.Empty;
/// <summary>Null for a permanent ban.</summary>
public DateTimeOffset? ExpiresUtc { get; set; }
}
/// <summary>
/// POST /v1/internal/machine/ban. Internal, called by moderation tooling with the
/// internal API key - never by a game client.
/// </summary>
public sealed class MachineBanRequest
{
/// <summary>Composite id of the machine to ban. Takes precedence over PlayerId.</summary>
public string? Composite { get; set; }
/// <summary>
/// Ban whichever machines this player has been seen on. Convenient for moderators,
/// who know an account and not a hash.
/// </summary>
public string? PlayerId { get; set; }
public string Reason { get; set; } = string.Empty;
/// <summary>Who issued it. Recorded verbatim so bans can be traced to a person.</summary>
public string IssuedBy { get; set; } = string.Empty;
/// <summary>Null or absent bans permanently.</summary>
public DateTimeOffset? ExpiresUtc { get; set; }
}
public sealed class MachineUnbanRequest
{
public string Composite { get; set; } = string.Empty;
public string LiftedBy { get; set; } = string.Empty;
public string Reason { get; set; } = string.Empty;
}
/// <summary>One machine as the service knows it. Returned by the lookup endpoints.</summary>
public sealed class MachineRecordView
{
public string Composite { get; set; } = string.Empty;
public string[] PlayerIds { get; set; } = Array.Empty<string>();
public DateTimeOffset FirstSeenUtc { get; set; }
public DateTimeOffset LastSeenUtc { get; set; }
public bool Banned { get; set; }
public string? BanReason { get; set; }
public string? BannedBy { get; set; }
public DateTimeOffset? BannedUtc { get; set; }
public DateTimeOffset? ExpiresUtc { get; set; }
}
public sealed class MachineBanResult
{
/// <summary>Composites actually banned by this call.</summary>
public string[] Banned { get; set; } = Array.Empty<string>();
/// <summary>Live guard sessions disconnected as a result.</summary>
public int SessionsEnded { get; set; }
}
}
+45
View File
@@ -0,0 +1,45 @@
using System;
using System.Collections.Generic;
namespace VanadiumGuard.Protocol
{
/// <summary>
/// One observation. The client fills these in and ships them; it draws no conclusions.
/// </summary>
public sealed class Detection
{
public DetectionCode Code { get; set; }
public Severity Severity { get; set; }
/// <summary>
/// 0-100. How sure the client is that the observation is what it looks like, rather
/// than a legitimate program that happens to match. The policy engine multiplies
/// weight by confidence, so a noisy heuristic can stay enabled at low confidence
/// without ever reaching a disconnect on its own.
/// </summary>
public int Confidence { get; set; }
/// <summary>Normalised subject: module base name, process image name, method signature.</summary>
public string Subject { get; set; } = string.Empty;
/// <summary>Which detector produced this, for triage.</summary>
public string Detector { get; set; } = string.Empty;
/// <summary>Supporting detail. Keep it small; it is stored verbatim.</summary>
public Dictionary<string, string> Evidence { get; set; } = new Dictionary<string, string>();
public DateTimeOffset FirstObservedUtc { get; set; }
public DateTimeOffset LastObservedUtc { get; set; }
/// <summary>Times this exact Code plus Subject pair was seen before the batch flushed.</summary>
public int OccurrenceCount { get; set; } = 1;
/// <summary>Dedupe key used by both the client sink and the server store.</summary>
public string DedupeKey()
{
return ((int)Code).ToString() + "|" + Subject;
}
}
}
+51
View File
@@ -0,0 +1,51 @@
namespace VanadiumGuard.Protocol
{
/// <summary>
/// Stable identifiers for everything the guard can observe. These values are persisted
/// and appear in review tooling, so they are never reused or renumbered.
/// </summary>
public enum DetectionCode
{
Unknown = 0,
// 1xxx - modules loaded into the game process
BlacklistedModuleLoaded = 1001,
ModuleFromUntrustedPath = 1002,
ModuleFromVolatilePath = 1003,
ModuleUnbackedByDisk = 1004,
// 2xxx - the rest of the machine
BlacklistedProcess = 2001,
BlacklistedWindow = 2002,
DebuggerAttached = 2003,
HttpInterceptionTool = 2004,
// 3xxx - native code integrity
NtExportPrologueModified = 3001,
GameCodeModified = 3002,
// 4xxx - managed (CLR) integrity
ForeignHarmonyPatch = 4001,
EnforcementMethodPatched = 4002,
// 5xxx - IL2CPP method integrity
Il2CppMethodHooked = 5001,
// 6xxx - guard integrity. Raised by the SERVER, never by the client.
ChallengeFailed = 6001,
ChallengeUnreadable = 6002,
GuardSilent = 6003,
SignatureInvalid = 6004,
SequenceReplay = 6005,
PolicyRefused = 6006,
// 7xxx - hardware bans. Raised by the SERVER; the client only carries the identity.
MachineBanned = 7001,
MachineLinkedToBan = 7002,
MachineUnidentifiable = 7003,
// 8xxx - device attestation. Raised by the SERVER.
AttestationInvalid = 8001,
AttestationMissing = 8002,
}
}
+106
View File
@@ -0,0 +1,106 @@
using System;
using System.Globalization;
using System.Text;
namespace VanadiumGuard.Protocol
{
/// <summary>What is holding the private half of the attestation key.</summary>
public enum AttestationBacking
{
/// <summary>No key at all. The session falls back to HMAC only.</summary>
None = 0,
/// <summary>
/// A TPM, through the Windows Platform Crypto Provider. The private key physically
/// cannot leave the chip, so a signature proves the request was made on this machine.
/// </summary>
Tpm = 1,
/// <summary>
/// A software key held by the OS key store. Better than nothing for continuity, but
/// it is extractable by anyone with the same access the guard has, so the server
/// treats it as unattested.
/// </summary>
Software = 2,
}
/// <summary>
/// A device key and the proof that this client holds its private half, presented once at
/// session open.
/// <para>
/// This exists because of the limit stated in the README: the HMAC session key lives in
/// the client process, so anyone with a debugger can lift it and sign whatever they like.
/// Replay, sequence and nonce checks do not help there - the attacker is not replaying
/// anything, they are minting fresh, correctly-ordered requests. A TPM key is the one
/// thing on a consumer machine that a debugger cannot walk off with: it signs inside the
/// chip and the private half never exists in process memory.
/// </para>
/// <para>
/// It does not make the client trustworthy. An attacker on the machine can still ask the
/// TPM to sign things, exactly as the guard does. What it removes is the ability to lift
/// a key and forge that machine traffic from somewhere else, and it binds every request
/// to a piece of hardware that can be banned.
/// </para>
/// </summary>
public sealed class DeviceAttestation
{
public AttestationBacking Backing { get; set; } = AttestationBacking.None;
/// <summary>Base64 SubjectPublicKeyInfo for the ECDSA P-256 public key.</summary>
public string PublicKey { get; set; } = string.Empty;
/// <summary>Named so a future key type does not silently verify under the wrong rules.</summary>
public string Algorithm { get; set; } = AlgorithmEs256;
public long TimestampUnixMs { get; set; }
public string Nonce { get; set; } = string.Empty;
/// <summary>Base64 signature over <see cref="BuildProofString"/>, IEEE P1363 form.</summary>
public string ProofSignature { get; set; } = string.Empty;
public const string AlgorithmEs256 = "ES256";
public bool IsPresent => Backing != AttestationBacking.None && PublicKey.Length > 0;
/// <summary>
/// The string the proof signature covers. Lives here, next to the canonical string
/// for signed requests, so the two ends cannot drift apart.
/// <para>
/// The player id and the build are bound in so a proof captured from one session
/// cannot be presented for a different account. The nonce is chosen by the client
/// rather than issued by the server, which would normally be a weakness - but a
/// stolen proof buys nothing on its own here, because every subsequent request has to
/// be signed by the same private key, and that key is in the chip.
/// </para>
/// </summary>
public static string BuildProofString(
string playerId, string gameBuild, long timestampUnixMs, string nonce, string publicKeyBase64)
{
var sb = new StringBuilder();
sb.Append("VG-ATTEST-V1").Append('\n');
sb.Append(playerId ?? string.Empty).Append('\n');
sb.Append(gameBuild ?? string.Empty).Append('\n');
sb.Append(timestampUnixMs.ToString(CultureInfo.InvariantCulture)).Append('\n');
sb.Append(nonce ?? string.Empty).Append('\n');
sb.Append(publicKeyBase64 ?? string.Empty);
return sb.ToString();
}
/// <summary>
/// Short, stable identifier for the key, for logs and ban records. A hash rather than
/// the key itself so a log line does not carry an eighty character blob.
/// </summary>
public static string Thumbprint(string publicKeyBase64)
{
if (string.IsNullOrEmpty(publicKeyBase64))
return string.Empty;
using (var sha = System.Security.Cryptography.SHA256.Create())
{
byte[] hash = sha.ComputeHash(Encoding.UTF8.GetBytes(publicKeyBase64));
return Convert.ToHexString(hash).ToLowerInvariant().Substring(0, 32);
}
}
}
}
+90
View File
@@ -0,0 +1,90 @@
using System;
namespace VanadiumGuard.Protocol
{
/// <summary>
/// Everything the client needs in order to know what to look for, delivered by the
/// server at session open.
/// <para>
/// Shipping this from the server rather than compiling it in is the single most useful
/// operational property of the whole design: a detector that starts producing false
/// positives in production can be turned off, or dropped to observe-only, without
/// shipping a new DLL to every player.
/// </para>
/// </summary>
public sealed class GuardPolicy
{
/// <summary>Identifies this policy revision. Echoed back on every report.</summary>
public string PolicyVersion { get; set; } = "0";
public int ReportIntervalMs { get; set; } = 5000;
public int HeartbeatIntervalMs { get; set; } = 15000;
/// <summary>Per-detector switches, keyed by detector name. Missing means disabled.</summary>
public DetectorSettings[] Detectors { get; set; } = Array.Empty<DetectorSettings>();
/// <summary>Substrings matched against loaded module base names.</summary>
public string[] ModuleDenyList { get; set; } = Array.Empty<string>();
/// <summary>Module name prefixes that are always fine, checked before the deny list.</summary>
public string[] ModuleAllowPrefixes { get; set; } = Array.Empty<string>();
/// <summary>Directory prefixes whose modules are trusted, in lowercase.</summary>
public string[] TrustedPathPrefixes { get; set; } = Array.Empty<string>();
/// <summary>
/// Exact process image names, lowercase with extension. Exact match only - substring
/// matching is what made the old build kill players for running Regedit.
/// </summary>
public string[] ProcessDenyList { get; set; } = Array.Empty<string>();
/// <summary>Window class names requiring an accompanying title match to count.</summary>
public string[] WindowClassDenyList { get; set; } = Array.Empty<string>();
/// <summary>Window title substrings. Matched case-insensitively.</summary>
public string[] WindowTitleDenyList { get; set; } = Array.Empty<string>();
/// <summary>Native exports whose prologues are snapshotted and re-verified.</summary>
public WatchedExport[] WatchedExports { get; set; } = Array.Empty<WatchedExport>();
/// <summary>IL2CPP methods to watch for inline hooks.</summary>
public Il2CppTarget[] Il2CppTargets { get; set; } = Array.Empty<Il2CppTarget>();
/// <summary>Harmony patch owner ids that are expected and allowed.</summary>
public string[] AllowedPatchOwners { get; set; } = Array.Empty<string>();
/// <summary>Modules the server is permitted to issue memory challenges against.</summary>
public string[] ChallengeableModules { get; set; } = Array.Empty<string>();
}
public sealed class DetectorSettings
{
public string Name { get; set; } = string.Empty;
public bool Enabled { get; set; } = true;
/// <summary>Overrides the detector default. Zero means use the default.</summary>
public int IntervalMs { get; set; }
/// <summary>
/// Caps the confidence of everything this detector emits. Set to 0 to keep a
/// detector running and reporting while contributing nothing to any verdict,
/// which is how a new heuristic should always be rolled out.
/// </summary>
public int MaxConfidence { get; set; } = 100;
}
public sealed class WatchedExport
{
public string Module { get; set; } = string.Empty;
public string Export { get; set; } = string.Empty;
}
public sealed class Il2CppTarget
{
public string TypeName { get; set; } = string.Empty;
public string MethodName { get; set; } = string.Empty;
public int ParameterCount { get; set; }
}
}
+118
View File
@@ -0,0 +1,118 @@
using System;
using System.Collections.Generic;
namespace VanadiumGuard.Protocol
{
/// <summary>
/// A machine identity, as reported by the client at session open.
/// <para>
/// Every value in here is already a salted hash when it leaves the player's machine.
/// The service never sees a disk serial or a motherboard serial, only opaque strings it
/// can compare for equality. That is deliberate: a ban list is a long-lived store of
/// data about people who are not around to consent to it, so it holds the least it can
/// while still doing its job.
/// </para>
/// <para>
/// The composite is the primary identity. The individual components exist so a ban can
/// survive one part being swapped: someone who replaces a disk keeps the same
/// motherboard and the same Windows install id, and matching on any single strong
/// component still recognises the machine. That is also why a component must be strong
/// to count - matching on a CPU model would ban everyone who bought the same processor.
/// </para>
/// </summary>
public sealed class MachineFingerprint
{
/// <summary>Hex SHA-256 over every component. Empty when collection failed.</summary>
public string Composite { get; set; } = string.Empty;
/// <summary>Per-source hashes, keyed by the names in <see cref="MachineComponents"/>.</summary>
public Dictionary<string, string> Components { get; set; } = new Dictionary<string, string>(StringComparer.Ordinal);
public DateTimeOffset CollectedUtc { get; set; }
public bool IsUsable => !string.IsNullOrEmpty(Composite);
/// <summary>
/// True when both fingerprints name the same machine: the same composite, or any
/// shared strong component. Weak components are carried for triage and are never
/// enough on their own.
/// </summary>
public bool Matches(MachineFingerprint? other)
{
if (other == null || !IsUsable || !other.IsUsable)
return false;
if (string.Equals(Composite, other.Composite, StringComparison.Ordinal))
return true;
foreach (string name in MachineComponents.Strong)
{
if (Components.TryGetValue(name, out string? mine)
&& other.Components.TryGetValue(name, out string? theirs)
&& !string.IsNullOrEmpty(mine)
&& string.Equals(mine, theirs, StringComparison.Ordinal))
{
return true;
}
}
return false;
}
/// <summary>Which strong components two fingerprints agree on. For audit logs.</summary>
public IReadOnlyList<string> SharedStrongComponents(MachineFingerprint? other)
{
var shared = new List<string>();
if (other == null)
return shared;
foreach (string name in MachineComponents.Strong)
{
if (Components.TryGetValue(name, out string? mine)
&& other.Components.TryGetValue(name, out string? theirs)
&& !string.IsNullOrEmpty(mine)
&& string.Equals(mine, theirs, StringComparison.Ordinal))
{
shared.Add(name);
}
}
return shared;
}
}
/// <summary>
/// Component names, and which of them identify one machine rather than one model of
/// machine. Names are persisted in ban records, so they are never renamed.
/// </summary>
public static class MachineComponents
{
/// <summary>Windows installation id. Survives hardware changes, dies on reinstall.</summary>
public const string MachineGuid = "machineGuid";
/// <summary>Volume serial of the system drive. Dies on reformat.</summary>
public const string SystemVolume = "systemVolume";
/// <summary>Motherboard identity, serial included. The most durable component there is.</summary>
public const string Baseboard = "baseboard";
/// <summary>BIOS vendor, version and date. Weak: it names a model, not a machine.</summary>
public const string Bios = "bios";
/// <summary>Processor identifier string. A model, not a machine - weak by design.</summary>
public const string Processor = "processor";
/// <summary>OS build. Triage only; changes with every update.</summary>
public const string OsBuild = "osBuild";
/// <summary>
/// Components specific enough to identify one machine. Only these are used to
/// recognise a banned machine whose composite has changed.
/// </summary>
public static readonly string[] Strong = { MachineGuid, SystemVolume, Baseboard };
/// <summary>Carried for review tooling, never used for matching.</summary>
public static readonly string[] Weak = { Bios, Processor, OsBuild };
}
}
+46
View File
@@ -0,0 +1,46 @@
using System;
namespace VanadiumGuard.Protocol
{
/// <summary>
/// A server-issued request for the client to hash a region of its own memory.
/// <para>
/// This is the anti-tamper primitive a stubbed-out guard cannot fake. The server picks
/// the module, offset and salt at random every time, so a valid answer requires actually
/// reading the live image, and a captured answer fails against the next salt.
/// </para>
/// </summary>
public sealed class MemoryChallenge
{
public string ChallengeId { get; set; } = string.Empty;
/// <summary>Module base name, e.g. GameAssembly.dll. Must be on the policy allowlist.</summary>
public string Module { get; set; } = string.Empty;
/// <summary>Offset from the module base address.</summary>
public uint Rva { get; set; }
/// <summary>Bytes to hash. Clamped to ProtocolConstants.MaxChallengeReadLength.</summary>
public int Length { get; set; }
/// <summary>Hex salt prefixed to the bytes before hashing. Never reused.</summary>
public string Salt { get; set; } = string.Empty;
public DateTimeOffset IssuedUtc { get; set; }
}
/// <summary>The client reply to a <see cref="MemoryChallenge"/>.</summary>
public sealed class ChallengeAnswer
{
public string ChallengeId { get; set; } = string.Empty;
/// <summary>Lowercase hex SHA-256 over salt bytes followed by the memory bytes.</summary>
public string Hash { get; set; } = string.Empty;
/// <summary>False when the client could not resolve the module or read the region.</summary>
public bool Readable { get; set; }
/// <summary>Populated when Readable is false.</summary>
public string? Error { get; set; }
}
}
+128
View File
@@ -0,0 +1,128 @@
using System;
namespace VanadiumGuard.Protocol
{
/// <summary>POST /v1/session/open. Authenticated with the player's normal game token.</summary>
public sealed class SessionOpenRequest
{
public int ProtocolVersion { get; set; } = ProtocolConstants.Version;
/// <summary>Opaque player id from the game's own auth system.</summary>
public string PlayerId { get; set; } = string.Empty;
/// <summary>Game build identifier. Selects the reference images used for challenges.</summary>
public string GameBuild { get; set; } = string.Empty;
/// <summary>Assembly version of the guard client, so old builds can be refused.</summary>
public string GuardVersion { get; set; } = string.Empty;
/// <summary>Windows build string, for triaging platform-specific false positives.</summary>
public string Platform { get; set; } = string.Empty;
/// <summary>
/// Correlates this guard session with the match/room session the game server knows
/// about, so the server can tell "guard went quiet" from "player quit".
/// </summary>
public string GameSessionId { get; set; } = string.Empty;
/// <summary>
/// Hashed machine identity, used for hardware bans. Absent when the client could
/// not collect one; the server decides what an unidentifiable machine is worth
/// rather than the client deciding for it.
/// </summary>
public MachineFingerprint? Machine { get; set; }
/// <summary>
/// Device key and proof of possession. Null when the machine has no usable key, which
/// is a supported state: the session then relies on the HMAC alone, exactly as before.
/// </summary>
public DeviceAttestation? Attestation { get; set; }
}
public sealed class SessionOpenResponse
{
public string SessionId { get; set; } = string.Empty;
/// <summary>Base64 HMAC key for signing subsequent requests. Session-scoped.</summary>
public string SessionKey { get; set; } = string.Empty;
public GuardPolicy Policy { get; set; } = new GuardPolicy();
/// <summary>Server time, so a client with a skewed clock can still sign correctly.</summary>
public DateTimeOffset ServerTimeUtc { get; set; }
}
/// <summary>POST /v1/session/report. Signed. Carries a batch of detections.</summary>
public sealed class ReportRequest
{
public Detection[] Detections { get; set; } = Array.Empty<Detection>();
/// <summary>Policy revision the client is running, so drift can be spotted.</summary>
public string PolicyVersion { get; set; } = "0";
/// <summary>Detections dropped because the client queue was full.</summary>
public int DroppedCount { get; set; }
}
public sealed class ReportResponse
{
public Verdict Verdict { get; set; } = new Verdict();
/// <summary>Number of detections the server accepted, for client-side log parity.</summary>
public int Accepted { get; set; }
}
/// <summary>POST /v1/session/heartbeat. Signed. Liveness plus challenge answers.</summary>
public sealed class HeartbeatRequest
{
/// <summary>Answers to challenges issued in previous responses.</summary>
public ChallengeAnswer[] Answers { get; set; } = Array.Empty<ChallengeAnswer>();
/// <summary>Milliseconds since the guard started. Monotonic; a reset means a reload.</summary>
public long UptimeMs { get; set; }
/// <summary>Detections currently queued but not yet reported.</summary>
public int QueueDepth { get; set; }
}
public sealed class HeartbeatResponse
{
public Verdict Verdict { get; set; } = new Verdict();
/// <summary>Challenges to answer on the next heartbeat.</summary>
public MemoryChallenge[] Challenges { get; set; } = Array.Empty<MemoryChallenge>();
/// <summary>Set when the server has a newer policy; the client re-opens its session.</summary>
public string? NewPolicyVersion { get; set; }
}
/// <summary>
/// GET /v1/internal/session/{gameSessionId}/status, called by the game server rather
/// than the client. This is the join point that makes the whole thing worth running:
/// gameplay authority asks the anti-cheat service what it thinks, instead of the
/// client deciding its own fate.
/// </summary>
public sealed class GuardStatusResponse
{
public bool GuardPresent { get; set; }
public bool GuardHealthy { get; set; }
/// <summary>
/// True when every request this session has made was co-signed by a TPM-backed key.
/// Lets the gameplay authority treat an unattested session differently without this
/// service deciding for it.
/// </summary>
public bool Attested { get; set; }
/// <summary>Accumulated risk score, 0-100.</summary>
public int RiskScore { get; set; }
public DateTimeOffset? LastHeartbeatUtc { get; set; }
/// <summary>Detection codes seen this session, most severe first.</summary>
public int[] Codes { get; set; } = Array.Empty<int>();
public VerdictAction RecommendedAction { get; set; }
}
}
+32
View File
@@ -0,0 +1,32 @@
namespace VanadiumGuard.Protocol
{
/// <summary>Wire-level constants shared by the guard client and the anti-cheat service.</summary>
public static class ProtocolConstants
{
/// <summary>Bumped whenever request/response shapes change incompatibly.</summary>
public const int Version = 1;
public const string HeaderSession = "X-VG-Session";
public const string HeaderTimestamp = "X-VG-Timestamp";
public const string HeaderNonce = "X-VG-Nonce";
public const string HeaderSignature = "X-VG-Signature";
public const string HeaderSequence = "X-VG-Seq";
public const string HeaderProtocol = "X-VG-Protocol";
/// <summary>
/// Per-request signature from the device attestation key, alongside the HMAC. Present
/// only when the session bound a key at open; absent means HMAC-only, which is a
/// state the server records rather than refuses.
/// </summary>
public const string HeaderAttestation = "X-VG-Attest";
/// <summary>How far a client clock may drift before signed requests are rejected.</summary>
public const int MaxClockSkewSeconds = 120;
/// <summary>Upper bound on a single memory challenge read, enforced on both ends.</summary>
public const int MaxChallengeReadLength = 4096;
/// <summary>Detections are dropped rather than queued once the backlog hits this size.</summary>
public const int MaxQueuedDetections = 256;
}
}
+113
View File
@@ -0,0 +1,113 @@
using System;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
namespace VanadiumGuard.Protocol
{
/// <summary>
/// HMAC-SHA256 request signing, shared verbatim by client and server so the canonical
/// string can never drift between the two.
/// <para>
/// Threat model, stated plainly: the session key lives in the client process, so an
/// attacker with a debugger can extract it and sign whatever they like. Signing is not
/// what stops cheating. It stops the cheap attacks - replaying someone else's clean
/// reports, forging reports for another player, and tampering with a report in transit -
/// and it forces the expensive attack to keep the guard alive and answering memory
/// challenges, which is what the server actually watches for.
/// </para>
/// </summary>
public static class RequestSigner
{
/// <summary>
/// Builds the string that gets signed. Every field that could be swapped by an
/// attacker is bound in: method, path, time, nonce, sequence and a body digest.
/// </summary>
public static string BuildCanonicalString(
string method,
string path,
long timestampUnixMs,
string nonce,
long sequence,
byte[] body)
{
string bodyHash;
using (var sha = SHA256.Create())
{
bodyHash = Convert.ToBase64String(sha.ComputeHash(body ?? Array.Empty<byte>()));
}
var sb = new StringBuilder();
sb.Append(method.ToUpperInvariant()).Append('\n');
sb.Append(path).Append('\n');
sb.Append(timestampUnixMs.ToString(CultureInfo.InvariantCulture)).Append('\n');
sb.Append(nonce).Append('\n');
sb.Append(sequence.ToString(CultureInfo.InvariantCulture)).Append('\n');
sb.Append(bodyHash);
return sb.ToString();
}
public static string Sign(byte[] key, string canonicalString)
{
using (var hmac = new HMACSHA256(key))
{
return Convert.ToBase64String(hmac.ComputeHash(Encoding.UTF8.GetBytes(canonicalString)));
}
}
/// <summary>Constant-time comparison. Do not replace with string equality.</summary>
public static bool Verify(byte[] key, string canonicalString, string providedSignature)
{
if (string.IsNullOrEmpty(providedSignature))
return false;
string expected = Sign(key, canonicalString);
return FixedTimeEquals(
Encoding.UTF8.GetBytes(expected),
Encoding.UTF8.GetBytes(providedSignature));
}
private static bool FixedTimeEquals(byte[] a, byte[] b)
{
if (a.Length != b.Length)
return false;
int diff = 0;
for (int i = 0; i < a.Length; i++)
diff |= a[i] ^ b[i];
return diff == 0;
}
public static string NewNonce()
{
byte[] buf = new byte[16];
using (var rng = RandomNumberGenerator.Create())
{
rng.GetBytes(buf);
}
return Convert.ToHexString(buf).ToLowerInvariant();
}
public static byte[] NewSessionKey()
{
byte[] buf = new byte[32];
using (var rng = RandomNumberGenerator.Create())
{
rng.GetBytes(buf);
}
return buf;
}
/// <summary>Lowercase hex SHA-256 over salt bytes followed by payload bytes.</summary>
public static string HashWithSalt(byte[] salt, byte[] payload)
{
using (var sha = SHA256.Create())
{
byte[] combined = new byte[salt.Length + payload.Length];
Buffer.BlockCopy(salt, 0, combined, 0, salt.Length);
Buffer.BlockCopy(payload, 0, combined, salt.Length, payload.Length);
return Convert.ToHexString(sha.ComputeHash(combined)).ToLowerInvariant();
}
}
}
}
+20
View File
@@ -0,0 +1,20 @@
namespace VanadiumGuard.Protocol
{
/// <summary>
/// How much a detection is worth on its own. Severity is a property of the signal;
/// what to do about it is decided by the server policy engine, never by the client.
/// </summary>
public enum Severity
{
/// <summary>Recorded for context. Never contributes to a verdict.</summary>
Info = 0,
/// <summary>Weak signal. Meaningful only in aggregate.</summary>
Low = 1,
/// <summary>Suspicious but individually explainable.</summary>
Medium = 2,
/// <summary>Hard to explain without tampering.</summary>
High = 3,
/// <summary>Direct evidence of memory or code tampering.</summary>
Critical = 4,
}
}
+28
View File
@@ -0,0 +1,28 @@
namespace VanadiumGuard.Protocol
{
/// <summary>The only actions a client may take, and only when the server says so.</summary>
public enum VerdictAction
{
/// <summary>Keep playing. The default for everything below the review threshold.</summary>
Continue = 0,
/// <summary>Show the player a message. For recoverable states, e.g. "close that tool".</summary>
Warn = 1,
/// <summary>Leave the session cleanly with a reason code. No crash, no data loss.</summary>
Disconnect = 2,
}
/// <summary>Server response to a report or heartbeat.</summary>
public sealed class Verdict
{
public VerdictAction Action { get; set; } = VerdictAction.Continue;
/// <summary>Short stable code shown to the player and logged, e.g. VG-1001.</summary>
public string? ReasonCode { get; set; }
/// <summary>Player-facing text. Deliberately vague about what was detected.</summary>
public string? PlayerMessage { get; set; }
/// <summary>Grace period before the client acts, so an in-flight report can finish.</summary>
public int DelayMs { get; set; }
}
}
+61
View File
@@ -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;
}
}
}
+326
View File
@@ -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);
}
}
}
}
+152
View File
@@ -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);
}
}
}
+566
View File
@@ -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;
}
}
+125
View File
@@ -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);
}
}
}
}
+157
View File
@@ -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" };
}
}
}
+163
View File
@@ -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);
}
}
}
+236
View File
@@ -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 };
}
}
}
}
+249
View File
@@ -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;
}
}
}
}
+22
View File
@@ -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();
}
}
}
+306
View File
@@ -0,0 +1,306 @@
[
{
"composite": "512d74c56be2758e8c53a343ba0cc3995a87d4bec7d5b9c675f98d4449d02222",
"components": {
"machineGuid": "f9eda23b04133f2eb8d2e73bd1888461b0b5d63f3c855c667d9dcaf6b9fd32ab",
"systemVolume": "0cce7790b36507d54749851cbd90c72a93d16d949a89151e51f7af6f1f4c7a14",
"bios": "6ecd41f1ce5204c6b41f9c341906f005aa53ec7ddbbe0573a17648462fcd152c",
"processor": "09390ce1d520ae87a7a56705e356d3e17a56faa3affccef0eae7906b13883ea7",
"osBuild": "2a0c5ce341003e13ffc739ec3d848dd875cda3ab2022f091c34711c63851d80a"
},
"playerIds": [
"anon"
],
"firstSeenUtc": "2026-09-06T21:27:48.3308569+00:00",
"lastSeenUtc": "2026-09-06T21:47:36.9847087+00:00",
"ban": null
},
{
"composite": "c4043f114b9e03ab7f3c083ec7f0eb33a3dce4e01723bca471080c65e7f6f2e9",
"components": {
"machineGuid": "c9595d8723c4f83e0bcfce0e17a80c2d57416df4d17a4ad68d06aae7957103fc",
"systemVolume": "44cbb935b05aea12583d15cc63edf4f0041d9e3499123ac6418f511addc587b4",
"bios": "d4bebb48a157fe241bfafd218b8b21207efdc27ae1188b3e410e02f105cc58ef",
"processor": "6dd5568e83b486eafcaff37474bbd3a77add52aacf05bdd0b7ca26a5eb52b5ad",
"osBuild": "878fe31ddf47bdf8ddc73f92adf6718ad60f758702ce2f1e0a1d5171623d95e4"
},
"playerIds": [
"anon"
],
"firstSeenUtc": "2026-09-06T21:50:03.1208241+00:00",
"lastSeenUtc": "2026-09-06T22:10:05.3980456+00:00",
"ban": null
},
{
"composite": "ec91b4bcc47dd4166329a005c2a7c44d7d21aa84dd9006087c04f654e2c77d97",
"components": {
"machineGuid": "d1ab3937eeccfcb0f28796b3be0b0140654d958c3d3f2f6d5658df859248e51d",
"systemVolume": "7327e928744eabbe9872fb9e76e8d3de3a038d0e89f95ed98dddccd9d8c44688",
"bios": "49acb4c52d81e4d4f669bf9ca435cd989f261f93aadf113c09265618fbc75dd9",
"processor": "ae5a12d7445f0829e23da98fb4cbc674d8cb8923943945acae23996e34f8239b",
"osBuild": "878fe31ddf47bdf8ddc73f92adf6718ad60f758702ce2f1e0a1d5171623d95e4"
},
"playerIds": [
"anon"
],
"firstSeenUtc": "2026-09-06T21:52:01.9527861+00:00",
"lastSeenUtc": "2026-09-06T21:52:01.9527867+00:00",
"ban": null
},
{
"composite": "ebeaaa035a255da0727ad36b7326f2833e0ff553c3399b56203347b93f10c5a5",
"components": {
"machineGuid": "7aa134fdfb78af512c75de110452b0320b1ecfbac72072e6b66264c5c6db2c1a",
"systemVolume": "9675776d85a5097ad1ecc6fd8bb42d113defd2d6ed051dc96fe08f2b2bde7b60",
"bios": "fdb01b87f91759c69f24b4eb342ce95e08c46f0889b5d37473b42af6fc8beb85",
"processor": "8efceab836ef5ad3dbae95703aad6a331f64256830ba0b4454f4b8530b834545",
"osBuild": "878fe31ddf47bdf8ddc73f92adf6718ad60f758702ce2f1e0a1d5171623d95e4"
},
"playerIds": [
"anon"
],
"firstSeenUtc": "2026-09-06T21:53:23.3940512+00:00",
"lastSeenUtc": "2026-09-06T21:53:23.394052+00:00",
"ban": null
},
{
"composite": "7cb919733b790a2968b9520c223aba9c4054aaa5f5e8a52d0a1524ea2edb7503",
"components": {
"machineGuid": "4eea2b8edb1942cd4eb76e95269219b8380e60625c309e5fa23bd974216095a3",
"systemVolume": "c79636b929ad2b84e17e0ae97fa9331a6f67b3de38fab952914858754f8534fd",
"bios": "e020fc72681d894c4f5f135c3e713fa368985686558f259361b6657927e23a9f",
"processor": "f1fe10531b41036b46a8e506350faec2830c42cc0caed9dc53f375e5c48a454a",
"osBuild": "d62b1f4801916bb81b206ad462863dc73f6529bd82e1cfb67aea08de77f0cc55"
},
"playerIds": [
"anon"
],
"firstSeenUtc": "2026-09-06T21:54:36.9667245+00:00",
"lastSeenUtc": "2026-09-06T21:54:36.966725+00:00",
"ban": null
},
{
"composite": "0f08595d349a93c6070cab555e0f05fb158b0bfef3d4030b8e993b7091fadb40",
"components": {
"machineGuid": "b3335deec2857ae87fe1e61b27984727f3a9af1db93ac9f1ce3f55a4472072a8",
"systemVolume": "7f8c2177658a6575eb84130f2f4ddb5e84d9d39a2c8b955a14d3a1c12d642b84",
"bios": "ff3e93900396466c325128dd2ace4ddb377bd08a4d742ded77bf4e3806e22934",
"processor": "68f1cc8d691658a068d87c729ab818a70eaa4e2246c931ab480ffafde9fda294",
"osBuild": "878fe31ddf47bdf8ddc73f92adf6718ad60f758702ce2f1e0a1d5171623d95e4"
},
"playerIds": [
"anon"
],
"firstSeenUtc": "2026-09-06T21:57:06.2544861+00:00",
"lastSeenUtc": "2026-09-06T22:08:29.2170278+00:00",
"ban": null
},
{
"composite": "b27d2e368f1f5f9cc64f823ef7210c366aec75ff3d4b7b3bdd22e611d936eb6d",
"components": {
"machineGuid": "5c8616365d934f02fb9a6ef1c4bf1f07635562ae1355c04fe48e59715811daa8",
"systemVolume": "2f7001193ae5eb3890799ddbd25ab8d1dcd692367bb0680bbe0a169b84edeeef",
"bios": "930b544b0787d8adabdc6cbcf825ad1d6811b8a2017021013db298cdff72882d",
"processor": "ae5a12d7445f0829e23da98fb4cbc674d8cb8923943945acae23996e34f8239b",
"osBuild": "2a0c5ce341003e13ffc739ec3d848dd875cda3ab2022f091c34711c63851d80a"
},
"playerIds": [
"anon"
],
"firstSeenUtc": "2026-09-06T21:58:21.8191331+00:00",
"lastSeenUtc": "2026-09-06T22:09:38.4723464+00:00",
"ban": null
},
{
"composite": "62a5a7e87fd6d62274af87f1cc6422133676cf7c7a91cc165eae28da7ee4d1d9",
"components": {
"machineGuid": "632b8820eb427d38b9cd14fbfb07613c2fbd4eeb00a2c5b6029cfa289b1d1a63",
"systemVolume": "09f918bdc47d5b37cf7ffbf526740c010cf8ed132708e961a66d28263a51036f",
"bios": "c85c6dcece454c5bb7f42975ecef8a613bcaf0207990b76eda8a22184e128689",
"processor": "f888a85c0abd6b1b179a5d320230b3fcf337c10d7429c99fa148b66080bad0f3",
"osBuild": "878fe31ddf47bdf8ddc73f92adf6718ad60f758702ce2f1e0a1d5171623d95e4"
},
"playerIds": [
"anon"
],
"firstSeenUtc": "2026-09-06T21:59:54.9464196+00:00",
"lastSeenUtc": "2026-09-06T22:08:38.2914442+00:00",
"ban": null
},
{
"composite": "5f5e5441eda628d58b27e855ec01dcc42430b56e74ddbc0a98b131ba8e8ac6fb",
"components": {
"machineGuid": "6e2bf4e5f700df7bf1a4b3306a7f09675150791ec9e1a6572b57bb98a7e227b6",
"systemVolume": "21f4d5758124613325ad6d7e17beb97807cb930c09e1023658ec6955280ab5df",
"bios": "072b529231c44b335c2c59c156d9a47a9658ed5170ed477a7e08af58a6e46a6c",
"processor": "42d1fb31f3f80f9011de6891613a247464cbeab76df4346e20dacd3754f08d69",
"osBuild": "878fe31ddf47bdf8ddc73f92adf6718ad60f758702ce2f1e0a1d5171623d95e4"
},
"playerIds": [
"anon"
],
"firstSeenUtc": "2026-09-06T22:01:03.1411868+00:00",
"lastSeenUtc": "2026-09-06T22:01:03.1411878+00:00",
"ban": null
},
{
"composite": "1d8f8a56c44dbe583919a3f6ef0cbc7f2fb00a3a685ca690ea61c68b3725bd42",
"components": {
"machineGuid": "f35c2e02e41bbd3bc309970bdf84fba40061f44e55d2db6cc8a91953d3cf330f",
"systemVolume": "61f080939884c60fb61add1e5a32c7fcdaefd74c7225a8af86337a08aba639d4",
"bios": "5b6030f068047577b9aec07b7ac0b0c47668fff3dcb2e3e6dbff58c0afa2664b",
"processor": "e900ebf7e6782448dbdc45ea0e5e66668b43220f2bbb8c32fe574205482ee965",
"osBuild": "c61b40fdacee27c1f2cb5674ec0fd6a5069d1f0dcd5fa0935146e0b2d62795f9"
},
"playerIds": [
"anon"
],
"firstSeenUtc": "2026-09-06T22:09:06.9648153+00:00",
"lastSeenUtc": "2026-09-06T22:10:27.042989+00:00",
"ban": null
},
{
"composite": "7a9ea88ece43e229bcd9ee08ecdc92188b911eea1c7c052c4e8ab1b88c3d61ea",
"components": {
"machineGuid": "80c9c671e86ac776650a4378c0549e54252568826ca1903c268ded4b89389221",
"systemVolume": "74e32d3fa36e09d12b113848035a121a448a08c519b10825b1e5c24f82ac3547",
"bios": "097c6085a4e9a7f9eac4f353e9bc9bec9351234b8c413b282c01c475699d115b",
"processor": "52601cd83768bf55a9e19a48096c17a733b27f1f111e52fc4bf06b0a7299bd47",
"osBuild": "878fe31ddf47bdf8ddc73f92adf6718ad60f758702ce2f1e0a1d5171623d95e4"
},
"playerIds": [
"anon"
],
"firstSeenUtc": "2026-09-06T22:18:04.1500995+00:00",
"lastSeenUtc": "2026-09-06T22:18:04.1501+00:00",
"ban": null
},
{
"composite": "d70a478cf36fe767ce6afb0656b8172493af19beef6e12cb02657ec962d541ab",
"components": {
"machineGuid": "95b1639ed3194d5603ff118e8052eac342c300802c59946b3c02c34b662458d6",
"systemVolume": "27bceb174c352e133b7306be53b0a866be65bfd9936722457c17459f9b03a6f3",
"bios": "253e0b6ffd83eaef713a10e6f1041d3b0148ec9252355c311677ae98c066813f",
"processor": "d27d0524c8f84078b7eb398a4086408d79f99dc16db2fe0dc0ecc391a322aefb",
"osBuild": "878fe31ddf47bdf8ddc73f92adf6718ad60f758702ce2f1e0a1d5171623d95e4"
},
"playerIds": [
"anon"
],
"firstSeenUtc": "2026-09-06T22:25:16.7106151+00:00",
"lastSeenUtc": "2026-09-06T22:38:10.1118503+00:00",
"ban": null
},
{
"composite": "9cf32157af43e6c701744bcca947d76d709c7b9d63c5eb4d0d337b8f1d4cdaad",
"components": {
"machineGuid": "1491997d133254dbd54fd6627b5e7b28066bf213a8c92c26cec587e852a1470b",
"systemVolume": "e65ec1af09e7e417dee07894bbcb252cf4afa1ad2d66f9fe49b2909e10afd7a1",
"bios": "4884b3f5290f39f4ff1fdd213edf88d4b7c00a642ac91b17204e1d209807d20d",
"processor": "afe58630b2333343b668326a9553c34187c536d7b50354d90f208e776bbfa473",
"osBuild": "2a0c5ce341003e13ffc739ec3d848dd875cda3ab2022f091c34711c63851d80a"
},
"playerIds": [
"anon"
],
"firstSeenUtc": "2026-09-06T22:28:18.3528285+00:00",
"lastSeenUtc": "2026-09-06T22:35:43.8762597+00:00",
"ban": null
},
{
"composite": "4ac7ab7966afd0ab53b9ff463aedfc408bca44f42eee75d27f53c855f6666491",
"components": {
"machineGuid": "d9ea46e8285f1a6f822acdbcecad29dede517f218f2c30a06fdd0e1ff12b369a",
"systemVolume": "0efae5a447cfdf5b1faaa87aaccf7034b8d19447c9bdf9dc844ec616b9d2698c",
"bios": "d0af4cdbd6bb2c169924544d8854815ae1879f11e63b9878cec9d8d5d8c55d3a",
"processor": "52601cd83768bf55a9e19a48096c17a733b27f1f111e52fc4bf06b0a7299bd47",
"osBuild": "665465a4a23e1cd30d50c28a1c5040eedd5fb789d369b5c811598150c362e1b7"
},
"playerIds": [
"anon"
],
"firstSeenUtc": "2026-09-06T22:31:45.5833708+00:00",
"lastSeenUtc": "2026-09-06T22:35:30.2024288+00:00",
"ban": null
},
{
"composite": "f1a5b402763c76263e20163adea94b59f420ae36df499fd861a545d4efb94c12",
"components": {
"machineGuid": "f821e0acc1d9336342ef59daa1bde88ce0ef9b229eb16b8e79f1a344a2babe37",
"systemVolume": "1d3f01821fb04cff9864251d97efc37adb0bc97721433a6549ad920d3c471cdc",
"bios": "f09b6e8ce744e3cfbe24f9b9cd0c26a4ab03c3304ed97b3e1c436d31a7cf42d0",
"processor": "ea7a659cd79ee6ae879669f265a7fa590f07ff7a7bcf7d15f336620af8082e03",
"osBuild": "665465a4a23e1cd30d50c28a1c5040eedd5fb789d369b5c811598150c362e1b7"
},
"playerIds": [
"anon"
],
"firstSeenUtc": "2026-09-06T22:34:01.3648759+00:00",
"lastSeenUtc": "2026-09-06T22:34:01.3648764+00:00",
"ban": null
},
{
"composite": "29813e6c90c354eec28ffcb26bde0d380bbafc985770ef2767fd68bb7f684163",
"components": {
"machineGuid": "aae15e48abc7f5a72eaf92f9dfb19c7ec8b2aa997591ab7052c6c05249939930",
"systemVolume": "4bea1045b3a09030a37eaba0f6faebdd2748aedd219e59c9fa0fabeab8377885",
"bios": "cd47e6bc026fec0a6c18299a0cf6e58dc6e454ca8c5063d384f7d596dd181bb3",
"processor": "ae5a12d7445f0829e23da98fb4cbc674d8cb8923943945acae23996e34f8239b",
"osBuild": "878fe31ddf47bdf8ddc73f92adf6718ad60f758702ce2f1e0a1d5171623d95e4"
},
"playerIds": [
"anon"
],
"firstSeenUtc": "2026-09-06T22:35:14.5417434+00:00",
"lastSeenUtc": "2026-09-06T22:35:14.5417439+00:00",
"ban": null
},
{
"composite": "2201b3b2db7c2e87f8b8e4857f7acdb5e056036b41ba61079c252b2af78ddb9a",
"components": {
"machineGuid": "10c025ebbf3640d74bbb7bba161cc56ce3beac3dc0cc6ad1b5cd7fb301686de9",
"systemVolume": "4c8827decb075955639bff338616d0e571bce4aa54410bd47161b45ca507cd25",
"bios": "35c3ddd0424dc2a76285c539439c768f3a4e2a3c7ff8ef349b76b32717d16245",
"processor": "51dabb8c183b72452b3cdd8d0bfc6735d24aee1fd4b09f362d59a85255e130bd",
"osBuild": "878fe31ddf47bdf8ddc73f92adf6718ad60f758702ce2f1e0a1d5171623d95e4"
},
"playerIds": [
"anon"
],
"firstSeenUtc": "2026-09-06T22:38:26.8837536+00:00",
"lastSeenUtc": "2026-09-06T22:38:26.883754+00:00",
"ban": null
},
{
"composite": "34c078aa4ad4ac78c3dea60b5c9d95385d322bd3fb7441ea588fc4b2ff4ffa58",
"components": {
"machineGuid": "41d45e5002e0e6b5ad2279eb4a1137783d90bb76835d2ff3d0d76009877299a8",
"systemVolume": "34008abd024cd2f6f6c50904e28546873cc7354870007cc5fc0d0117ad00177a",
"bios": "acf72885781c5cbb7a5cd3b2da40c19674ce2b7e146ee640967a8bed3df50270",
"processor": "a66c7687b4204c85881462b854272a283908ea7b9efab117fff7667182194bcb",
"osBuild": "665465a4a23e1cd30d50c28a1c5040eedd5fb789d369b5c811598150c362e1b7"
},
"playerIds": [
"anon"
],
"firstSeenUtc": "2026-09-06T22:39:39.403415+00:00",
"lastSeenUtc": "2026-09-06T22:39:39.4034158+00:00",
"ban": null
},
{
"composite": "22bb697d875df4f40718c607cfe0df5452e5b51d3b23a036f383eae439bdcb20",
"components": {
"machineGuid": "479356da454f0240e54251a19b14077e51dc8306ca63018d0d10ff3e89c6698a",
"systemVolume": "93ba712963ac7a6b7f21209cd8107890ac91fca2a40124f779b72dffb88e3d78",
"bios": "15b1f9e7d9b8710a04f765fffefa92a5e857507881779545289853d53e022623",
"processor": "d149137afa8cd7d2c73d8189afc31afc2353c544546ae971fdc73a4a76bf99dc",
"osBuild": "878fe31ddf47bdf8ddc73f92adf6718ad60f758702ce2f1e0a1d5171623d95e4"
},
"playerIds": [
"anon"
],
"firstSeenUtc": "2026-09-06T23:19:16.3795789+00:00",
"lastSeenUtc": "2026-09-06T23:19:16.3795954+00:00",
"ban": null
}
]
+273
View File
@@ -0,0 +1,273 @@
{
"PolicyVersion": "2024.09.05-1",
"ReportIntervalMs": 5000,
"HeartbeatIntervalMs": 15000,
"Detectors": [
{
"Name": "module",
"Enabled": true,
"IntervalMs": 0,
"MaxConfidence": 100
},
{
"Name": "process",
"Enabled": true,
"IntervalMs": 0,
"MaxConfidence": 60
},
{
"Name": "window",
"Enabled": true,
"IntervalMs": 0,
"MaxConfidence": 60
},
{
"Name": "debugger",
"Enabled": true,
"IntervalMs": 0,
"MaxConfidence": 100
},
{
"Name": "nativehook",
"Enabled": true,
"IntervalMs": 0,
"MaxConfidence": 100
},
{
"Name": "managedpatch",
"Enabled": true,
"IntervalMs": 0,
"MaxConfidence": 0
},
{
"Name": "il2cpp",
"Enabled": true,
"IntervalMs": 0,
"MaxConfidence": 100
}
],
"ModuleDenyList": [
"minhook",
"easyhook",
"blackbone",
"detours",
"polyhook",
"scyllahide",
"scyllahook",
"frida-agent",
"frida-gadget",
"cheatengine",
"ceserver",
"vehdebug",
"speedhack",
"unityexplorer",
"monoinjector",
"melonsampler",
"vmthook",
"internalhack",
"externalhack"
],
"ModuleAllowPrefixes": [
"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": [
"\\windows\\system32\\",
"\\windows\\winsxs\\",
"\\program files\\",
"\\program files (x86)\\",
"\\steam\\steamapps\\",
"\\dotnet\\"
],
"ProcessDenyList": [
"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"
],
"WindowClassDenyList": [
"TFormCheatEngine",
"TMemoryBrowser",
"Qt5152QWindowIcon",
"OLLYDBG",
"ID Window",
"WinDbgFrameClass"
],
"WindowTitleDenyList": [
"Cheat Engine",
"Memory Viewer",
"Add Address Manually",
"x64dbg",
"x32dbg",
"OllyDbg",
"Immunity Debugger",
"IDA -",
"Ghidra:",
"dnSpy",
"ILSpy",
"HTTP Debugger",
"Extreme Injector"
],
"WatchedExports": [
{
"Module": "ntdll.dll",
"Export": "NtProtectVirtualMemory"
},
{
"Module": "ntdll.dll",
"Export": "NtWriteVirtualMemory"
},
{
"Module": "ntdll.dll",
"Export": "NtReadVirtualMemory"
},
{
"Module": "ntdll.dll",
"Export": "NtCreateThreadEx"
},
{
"Module": "ntdll.dll",
"Export": "NtOpenProcess"
},
{
"Module": "ntdll.dll",
"Export": "LdrLoadDll"
}
],
"Il2CppTargets": [
{
"TypeName": "UnityEngine.Camera",
"MethodName": "set_fieldOfView",
"ParameterCount": 1
}
],
"AllowedPatchOwners": [
"vanadiumguard",
"vanadium.patch",
"melonloader"
],
"ChallengeableModules": [
"GameAssembly.dll",
"UnityPlayer.dll",
"VanadiumGuard.dll"
]
}