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