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