yikes,
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user