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>
This commit is contained in:
@@ -11,6 +11,7 @@ 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;
|
||||
@@ -60,6 +61,19 @@ public class ShadowverseTranslationMiddleware : IMiddleware
|
||||
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;
|
||||
@@ -70,10 +84,12 @@ public class ShadowverseTranslationMiddleware : IMiddleware
|
||||
await context.Request.Body.CopyToAsync(requestBytesStream);
|
||||
byte[] requestBytes = requestBytesStream.ToArray();
|
||||
|
||||
// Get encryption values for this request
|
||||
// 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 = _sessionService.GetUdidFromSessionId(sid);
|
||||
if (mappedUdid is null)
|
||||
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*
|
||||
@@ -85,11 +101,13 @@ public class ShadowverseTranslationMiddleware : IMiddleware
|
||||
}
|
||||
string udid = mappedUdid.GetValueOrDefault().ToString();
|
||||
|
||||
// Decrypt incoming data.
|
||||
// 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 = Encryption.Decrypt(requestBytes, udid);
|
||||
decryptedBytes = skipEncryption ? requestBytes : Encryption.Decrypt(requestBytes, udid);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -155,7 +173,9 @@ public class ShadowverseTranslationMiddleware : IMiddleware
|
||||
? null
|
||||
: ConvertJsonTreeToPlainObject(JToken.Parse(responseJson));
|
||||
|
||||
// Wrap the response in a datawrapper
|
||||
// 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,
|
||||
@@ -177,12 +197,12 @@ public class ShadowverseTranslationMiddleware : IMiddleware
|
||||
// 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 = viewer?.ShortUdid ?? 0,
|
||||
ViewerId = viewer?.Id ?? 0,
|
||||
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 = mappedUdid?.ToString() ?? ""
|
||||
Udid = skipEncryption ? "" : (mappedUdid?.ToString() ?? "")
|
||||
}
|
||||
};
|
||||
|
||||
@@ -191,17 +211,24 @@ public class ShadowverseTranslationMiddleware : IMiddleware
|
||||
// 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);
|
||||
packedData = Encryption.Encrypt(packedData, udid);
|
||||
if (!skipEncryption)
|
||||
{
|
||||
packedData = Encryption.Encrypt(packedData, udid);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex,
|
||||
"Response msgpack/encrypt failed for {Path} (viewerId={ViewerId}, udid={Udid}).",
|
||||
path, viewer?.Id, udid);
|
||||
"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)));
|
||||
|
||||
Reference in New Issue
Block a user