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
@@ -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 };
}
}
}
}