using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using MessagePack; using MessagePack.Resolvers; using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Mvc.Controllers; using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.Extensions.Primitives; using Newtonsoft.Json.Linq; using SVSim.Database.Models; using SVSim.EmulatedEntrypoint.Constants; using SVSim.EmulatedEntrypoint.Extensions; using SVSim.EmulatedEntrypoint.Infrastructure; using SVSim.EmulatedEntrypoint.Models.Dtos; using SVSim.EmulatedEntrypoint.Models.Dtos.Internal; using SVSim.EmulatedEntrypoint.Security; using SVSim.EmulatedEntrypoint.Services; namespace SVSim.EmulatedEntrypoint.Middlewares; /// /// Translates incoming requests and outgoing responses from the Shadowverse client into the messagepack format. /// public class ShadowverseTranslationMiddleware : IMiddleware { private readonly IActionDescriptorCollectionProvider _actionDescriptorCollectionProvider; private readonly ShadowverseSessionService _sessionService; private readonly ILogger _logger; // Serialization policy MUST match what AddJsonOptions configured on the controllers, or the // model binder won't find the snake_case keys we write into the synthetic request body and // every request 400s with empty ModelState. WhenWritingNull is irrelevant for request // serialization but kept here for symmetry. private static readonly JsonSerializerOptions ControllerJsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; public ShadowverseTranslationMiddleware( IActionDescriptorCollectionProvider actionDescriptorCollectionProvider, ShadowverseSessionService sessionService, ILogger logger) { _actionDescriptorCollectionProvider = actionDescriptorCollectionProvider; _sessionService = sessionService; _logger = logger; } public async Task InvokeAsync(HttpContext context, RequestDelegate next) { bool isUnity = context.Request.Headers.UserAgent.Any(agent => agent?.Contains("UnityPlayer") ?? false); string path = context.Request.Path; ActionDescriptor? endpointDescriptor = _actionDescriptorCollectionProvider.ActionDescriptors.Items.FirstOrDefault(ad => $"/{ad.AttributeRouteInfo.Template}".Equals(path, StringComparison.InvariantCultureIgnoreCase)); if (!isUnity || endpointDescriptor == null) { await next.Invoke(context); return; } // Portal endpoints (shadowverse-portal.com — deck builder, deck image) speak msgpack // and the standard envelope but skip AES on the wire. Detect via [NoWireEncryption] on // the controller or action; this flag toggles the two Encryption calls below but every // other step (msgpack pivot, JSON re-serialize for the binder, envelope wrap, base64 of // the response) stays identical. bool skipEncryption = false; if (endpointDescriptor is ControllerActionDescriptor cad) { skipEncryption = cad.MethodInfo.GetCustomAttributes(typeof(NoWireEncryptionAttribute), inherit: true).Length > 0 || cad.ControllerTypeInfo.GetCustomAttributes(typeof(NoWireEncryptionAttribute), inherit: true).Length > 0; } // Replace response body stream to re-access it. using MemoryStream tempResponseBody = new MemoryStream(); Stream originalResponsebody = context.Response.Body; context.Response.Body = tempResponseBody; // Pull out the request bytes into a stream using MemoryStream requestBytesStream = new MemoryStream(); await context.Request.Body.CopyToAsync(requestBytesStream); byte[] requestBytes = requestBytesStream.ToArray(); // Get encryption values for this request. Portal endpoints don't carry a SID/UDID pair // (they're anonymous-on-the-wire), so the lookup is skipped on the skip-encryption path // — there's nothing to decrypt against. string sid = context.Request.Headers[NetworkConstants.SessionIdHeaderName]; Guid? mappedUdid = skipEncryption ? null : _sessionService.GetUdidFromSessionId(sid); if (mappedUdid is null && !skipEncryption) { // Per design (2026-05-25): warn and continue. Decrypt will fail with Guid.Empty as // the AES key, surfacing as a msgpack/decrypt error below — but now the *root cause* // (the SID wasn't in our dict, likely because the prior request didn't include a UDID // header or the server was restarted between handshake and this call) is in the log. _logger.LogWarning( "No UDID mapping for SID on {Path} (sid={Sid}). Falling back to Guid.Empty — the following decrypt/msgpack error is almost certainly caused by this.", path, sid); } string udid = mappedUdid.GetValueOrDefault().ToString(); // Decrypt incoming data — unless this is a [NoWireEncryption] endpoint, in which case // the request body is already raw msgpack (the client sends portal requests via // _createBodyMsgpack with encrypt=false). byte[] decryptedBytes; try { decryptedBytes = skipEncryption ? requestBytes : Encryption.Decrypt(requestBytes, udid); } catch (Exception ex) { _logger.LogError(ex, "Decrypt failed for {Path} (udid={Udid}, encryptedLen={EncryptedLen}). " + "If udid is all-zero, see the preceding 'No UDID mapping' warning.", path, udid, requestBytes.Length); throw; } var firstParam = endpointDescriptor.Parameters.FirstOrDefault(); if (firstParam is null) { // Action method has no parameters — middleware can't bind the (encrypted+msgpacked) // body to anything. The codebase convention is to take a BaseRequest even for body- // less endpoints (see e.g. PuzzleController.Info(BaseRequest _)). Fail loud with a // specific message rather than NREing below on .ParameterType. throw new InvalidOperationException( $"Action {endpointDescriptor.DisplayName} has no parameters; the SV translation " + "middleware needs at least one to bind the decrypted body. Add a BaseRequest parameter " + "(or a derived DTO) — see other *Info/*Top actions for the convention."); } Type requestType = firstParam.ParameterType; object? data; try { data = MessagePackSerializer.Deserialize(requestType, decryptedBytes); } catch (Exception ex) { // The most common cause is a Guid.Empty decrypt above producing garbage bytes — but // it can also be a genuine schema mismatch (DTO missing [Key], wrong types, etc.), // so include the first few bytes for triage. string bytePrefix = Convert.ToHexString(decryptedBytes.AsSpan(0, Math.Min(16, decryptedBytes.Length))); _logger.LogError(ex, "Msgpack deserialize failed for {Path} into {RequestType} (udid={Udid}, decryptedLen={DecryptedLen}, firstBytes={BytePrefix}). " + "If decrypted bytes look like noise, the SID→UDID mapping was missing (see warnings above).", path, requestType.Name, udid, decryptedBytes.Length, bytePrefix); throw; } // Re-serialize via System.Text.Json with the SAME options the controllers use, so the // model binder sees snake_case keys it can match. Using JsonConvert here writes the // CLR property names (PascalCase) and every property silently binds to default → 400. string json = JsonSerializer.Serialize(data, requestType, ControllerJsonOptions); StringContent newStream = new StringContent(json, Encoding.UTF8, "application/json"); context.Request.Body = newStream.ReadAsStream(); context.Request.Headers.ContentType = new StringValues("application/json"); await next.Invoke(context); Viewer? viewer = context.GetViewer(); // Read the controller's JSON response body. System.Text.Json was configured with // SnakeCaseLower + WhenWritingNull, so the JSON keys are already in the wire shape and // null/optional properties have been omitted. Parse to a JToken tree to preserve that // "absent vs null" information — going back through a typed DTO via JsonConvert would // re-introduce nulls for missing properties and they'd reach the client as msgpack Nil. using MemoryStream responseBytesStream = new MemoryStream(); context.Response.Body.Seek(0, SeekOrigin.Begin); await context.Response.Body.CopyToAsync(responseBytesStream); string responseJson = Encoding.UTF8.GetString(responseBytesStream.ToArray()); object? responseData = string.IsNullOrEmpty(responseJson) ? null : ConvertJsonTreeToPlainObject(JToken.Parse(responseJson)); // Wrap the response in a datawrapper. Portal (no-encryption) endpoints emit an anonymous // envelope — viewer/udid/sid stay zero/empty — matching the prod portal traffic shape // captured in data_dumps/traffic_prod_deckcode.ndjson. DataWrapper wrappedResponseData = new DataWrapper { Data = responseData, DataHeaders = new DataHeaders { Servertime = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), // SID intentionally empty. See docs/api-spec/common/envelope.md §"SID // rotation" — the client's SessionId is a hash-on-read property, so echoing // the request's SID poisons its backing field and the next request hashes // the hash, missing our SID→UDID dict and crashing decryption. To rotate // sessions in the future, use the "stable-prefix + counter" pattern from // that doc (Option B), and pre-hash the rotated value to index the map by // what the client will actually send back on the next request. Sid = "", // TODO error handling ResultCode = 1, // Anonymous endpoints (e.g. /check/special_title with [AllowAnonymous]) reach this // middleware without an authenticated viewer — the auth handler either declined or // failed to find a Steam-linked viewer. The wire still needs short_udid / viewer_id // populated (prod sends real numbers for the title check too, but 0 / 0 satisfies // the client's BaseTask.Parse which only reads result_code + servertime here). ShortUdid = skipEncryption ? 0 : (viewer?.ShortUdid ?? 0), ViewerId = skipEncryption ? 0 : (viewer?.Id ?? 0), // Echo the decrypted-against UDID. Most clients ignore this field; SignUpTask.Parse // requires it (validates against Certification.Udid on the response). Comes from // mappedUdid (the value used for AES); never from controller state. Udid = skipEncryption ? "" : (mappedUdid?.ToString() ?? "") } }; // Convert the response into a messagepack, encrypt it. ContractlessStandardResolver // walks the DataWrapper's typed properties (DataHeaders) AND the boxed object/list/ // primitive tree under Data — emitting only the keys present in the dictionary. var msgPackOptions = MessagePackSerializerOptions.Standard .WithResolver(ContractlessStandardResolver.Instance); // Both branches base64-wrap the response body — the client's NetworkManager.Connect // reads downloadHandler.text and calls Convert.FromBase64String on the no-encryption // path (Cute/NetworkManager.cs:194) and CryptAES.decrypt (which also base64-decodes // internally) on the encrypted path. byte[] packedData; try { packedData = MessagePackSerializer.Serialize(wrappedResponseData, msgPackOptions); if (!skipEncryption) { packedData = Encryption.Encrypt(packedData, udid); } } catch (Exception ex) { _logger.LogError(ex, "Response msgpack{EncryptStep} failed for {Path} (viewerId={ViewerId}, udid={Udid}).", skipEncryption ? "" : "/encrypt", path, viewer?.Id, udid); throw; } await originalResponsebody.WriteAsync(Encoding.UTF8.GetBytes(Convert.ToBase64String(packedData))); context.Response.Body = originalResponsebody; } /// /// Walks a parsed JSON tree into the plain CLR shape MessagePack-CSharp's contractless /// resolver understands: objects → Dictionary<string, object?>, arrays → /// List<object?>, scalars unboxed to their nearest primitive. Crucially, JSON /// objects that lacked a key DON'T get one in the dictionary — preserving "absent" as a /// distinct state from "null" all the way to the msgpack writer. /// internal static object? ConvertJsonTreeToPlainObject(JToken? token) { if (token is null || token.Type == JTokenType.Null) return null; return token.Type switch { JTokenType.Object => token.Children() .ToDictionary(p => p.Name, p => ConvertJsonTreeToPlainObject(p.Value)), JTokenType.Array => token.Children().Select(ConvertJsonTreeToPlainObject).ToList(), JTokenType.Integer => token.Value(), JTokenType.Float => token.Value(), JTokenType.String => token.Value(), JTokenType.Boolean => token.Value(), JTokenType.Date => token.Value(), JTokenType.Bytes => token.Value(), _ => token.ToString() }; } }