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 { /// Log category marker, so endpoint logs are not filed under an unrelated type. 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" })); } /// /// 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. /// private static async Task OpenSessionAsync( HttpContext context, SessionOpenRequest request, IPlayerAuthenticator authenticator, ISessionStore sessions, PolicyProvider policies, MachineRegistry machines, GuardServerOptions options, ILogger 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 ReportAsync( HttpContext context, ISessionStore sessions, PolicyEngine policyEngine) { SignedRequest signed = await SignedRequestReader.ReadAsync(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 { ["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 HeartbeatAsync( HttpContext context, ISessionStore sessions, PolicyEngine policyEngine, PolicyProvider policies, ChallengeService challenges) { SignedRequest signed = await SignedRequestReader.ReadAsync(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() : new[] { next }, NewPolicyVersion = policy.PolicyVersion == session.PolicyVersion ? null : policy.PolicyVersion, }); } /// /// 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. /// 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, }); } /// /// Bans a machine, either by its composite id or by naming a player whose machines /// should be banned - moderators know accounts, not hashes. /// /// 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. /// /// private static IResult BanMachine( HttpContext context, MachineBanRequest request, MachineRegistry machines, ISessionStore sessions, GuardServerOptions options, ILogger 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(); 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 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()); } /// /// 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. /// private static int EndSessionsOnMachines( ISessionStore sessions, IReadOnlyCollection composites, ILogger logger) { var banned = new HashSet(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, }; } /// /// Maps a validation failure to a response, recording the ones that indicate /// tampering rather than an ordinary network problem. /// 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)); } } }