Files
SVSimServer/SVSim.EmulatedEntrypoint/Middlewares/ShadowverseTranslationMiddleware.cs
gamer147 5aac24d2b9 feat(deck-builder): /deck_code mint + /deck resolve with 3-min in-memory TTL
Adds the portal pair (shadowverse-portal.com deck-builder endpoints) as
anonymous routes on the app server. The translation middleware learns a new
[NoWireEncryption] attribute that skips both AES calls but keeps the rest of
the msgpack + base64 + envelope pipeline intact, matching prod's portal wire
profile observed in data_dumps/traffic_prod_deckcode.ndjson.

Storage is a 3-minute IMemoryCache — codes are anonymous-global, 4-char
lowercase alphanumeric (matches the shortest prod sample). Foil bit is
stripped on mint to match prod's normalize-on-encode behaviour.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 09:11:21 -04:00

263 lines
14 KiB
C#

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;
/// <summary>
/// Translates incoming requests and outgoing responses from the Shadowverse client into the messagepack format.
/// </summary>
public class ShadowverseTranslationMiddleware : IMiddleware
{
private readonly IActionDescriptorCollectionProvider _actionDescriptorCollectionProvider;
private readonly ShadowverseSessionService _sessionService;
private readonly ILogger<ShadowverseTranslationMiddleware> _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<ShadowverseTranslationMiddleware> 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;
}
/// <summary>
/// Walks a parsed JSON tree into the plain CLR shape MessagePack-CSharp's contractless
/// resolver understands: objects → <c>Dictionary&lt;string, object?&gt;</c>, arrays →
/// <c>List&lt;object?&gt;</c>, 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.
/// </summary>
internal static object? ConvertJsonTreeToPlainObject(JToken? token)
{
if (token is null || token.Type == JTokenType.Null) return null;
return token.Type switch
{
JTokenType.Object => token.Children<JProperty>()
.ToDictionary(p => p.Name, p => ConvertJsonTreeToPlainObject(p.Value)),
JTokenType.Array => token.Children().Select(ConvertJsonTreeToPlainObject).ToList(),
JTokenType.Integer => token.Value<long>(),
JTokenType.Float => token.Value<double>(),
JTokenType.String => token.Value<string>(),
JTokenType.Boolean => token.Value<bool>(),
JTokenType.Date => token.Value<DateTime>(),
JTokenType.Bytes => token.Value<byte[]>(),
_ => token.ToString()
};
}
}