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 { /// Outcome of validating a signed request. public enum SignedRequestStatus { Ok, MissingHeaders, UnknownSession, ClockSkew, BadSignature, BadAttestation, Replay, BadBody, } public sealed class SignedRequest 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; } /// /// Reads and validates a signed request body. /// /// 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. /// /// public static class SignedRequestReader { private static readonly JsonSerializerOptions Json = new JsonSerializerOptions { PropertyNameCaseInsensitive = true, }; public static async Task> ReadAsync( 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 { Status = SignedRequestStatus.MissingHeaders }; } GuardSession? session = sessions.Get(sessionId); if (session == null) return new SignedRequest { 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 { Status = SignedRequestStatus.MissingHeaders, Session = session }; } double skew = Math.Abs((DateTimeOffset.UtcNow - DateTimeOffset.FromUnixTimeMilliseconds(timestamp)).TotalSeconds); if (skew > ProtocolConstants.MaxClockSkewSeconds) return new SignedRequest { 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 { 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 { 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 { Status = SignedRequestStatus.Replay, Session = session }; try { T? payload = JsonSerializer.Deserialize(body, Json); if (payload == null) return new SignedRequest { Status = SignedRequestStatus.BadBody, Session = session }; return new SignedRequest { Status = SignedRequestStatus.Ok, Session = session, Payload = payload, }; } catch (JsonException) { return new SignedRequest { Status = SignedRequestStatus.BadBody, Session = session }; } } } }