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; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user