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