diff --git a/SVSim.EmulatedEntrypoint/Controllers/ToolController.cs b/SVSim.EmulatedEntrypoint/Controllers/ToolController.cs index ea54700..6c67755 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/ToolController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/ToolController.cs @@ -4,6 +4,7 @@ using SVSim.Database.Repositories.Viewer; using SVSim.EmulatedEntrypoint.Extensions; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests; using SVSim.EmulatedEntrypoint.Models.Dtos.Responses; +using SVSim.EmulatedEntrypoint.Services; namespace SVSim.EmulatedEntrypoint.Controllers; @@ -11,11 +12,16 @@ public class ToolController : SVSimController { private readonly ILogger _logger; private readonly IViewerRepository _viewerRepository; + private readonly ShadowverseSessionService _sessionService; - public ToolController(ILogger logger, IViewerRepository viewerRepository) + public ToolController( + ILogger logger, + IViewerRepository viewerRepository, + ShadowverseSessionService sessionService) { _logger = logger; _viewerRepository = viewerRepository; + _sessionService = sessionService; } /// @@ -43,6 +49,13 @@ public class ToolController : SVSimController ?? await _viewerRepository.RegisterAnonymousViewer(udid); HttpContext.SetViewer(viewer); + + // Pre-store the SID the client will compute and use for its very next request. After + // signup the client switches to SID-only headers (no UDID), so without this mapping the + // translation middleware can't decrypt the next body. Formula mirrors the decompiled + // Cute/Certification.SessionId getter — see ShadowverseSessionService.ComputeClientSessionId. + _sessionService.StoreSessionForViewer(viewer.Id, udid); + _logger.LogInformation("Signup resolved for udid={Udid} → viewer_id={ViewerId}, short_udid={ShortUdid}.", udid, viewer.Id, viewer.ShortUdid); diff --git a/SVSim.EmulatedEntrypoint/Services/ShadowverseSessionService.cs b/SVSim.EmulatedEntrypoint/Services/ShadowverseSessionService.cs index 54a883b..0ed8612 100644 --- a/SVSim.EmulatedEntrypoint/Services/ShadowverseSessionService.cs +++ b/SVSim.EmulatedEntrypoint/Services/ShadowverseSessionService.cs @@ -1,9 +1,19 @@ using System.Collections.Concurrent; +using System.Globalization; +using System.Security.Cryptography; +using System.Text; namespace SVSim.EmulatedEntrypoint.Services; public class ShadowverseSessionService { + /// + /// Salt the client's Cute/Cryptographer.MakeMd5 appends to every input before hashing. + /// Must match the decompiled client exactly — the server computes SIDs that the client + /// also computes locally for its outgoing request headers, and any mismatch breaks decrypt. + /// + private const string MakeMd5Salt = "r!I@ws8e5i="; + private readonly ConcurrentDictionary _sessionIdToUdid; public ShadowverseSessionService() @@ -25,4 +35,30 @@ public class ShadowverseSessionService { _sessionIdToUdid.AddOrUpdate(sid, _ => udid, (_, _) => udid); } + + /// + /// Replicates the client's Cute/Certification.SessionId getter: + /// MakeMd5(viewerId.ToString() + udid.ToString("D")). Returned as lowercase hex. + /// The client computes this once after signup and sends it as the SID header on every + /// subsequent request — the server must produce the same value to map back to the UDID. + /// + public string ComputeClientSessionId(long viewerId, Guid udid) + { + string input = viewerId.ToString(CultureInfo.InvariantCulture) + + udid.ToString("D") + + MakeMd5Salt; + byte[] hash = MD5.HashData(Encoding.UTF8.GetBytes(input)); + return Convert.ToHexString(hash).ToLowerInvariant(); + } + + /// + /// Pre-stores the SID→UDID mapping the client will use for its first SID-only request + /// after /tool/signup. Without this, the translation middleware can't decrypt the + /// next request body (no UDID header, no mapping, falls back to Guid.Empty). + /// + public void StoreSessionForViewer(long viewerId, Guid udid) + { + string sid = ComputeClientSessionId(viewerId, udid); + StoreUdidForSessionId(sid, udid); + } } \ No newline at end of file diff --git a/SVSim.UnitTests/Services/ShadowverseSessionServiceTests.cs b/SVSim.UnitTests/Services/ShadowverseSessionServiceTests.cs new file mode 100644 index 0000000..4f6e511 --- /dev/null +++ b/SVSim.UnitTests/Services/ShadowverseSessionServiceTests.cs @@ -0,0 +1,38 @@ +using NUnit.Framework; +using SVSim.EmulatedEntrypoint.Services; + +namespace SVSim.UnitTests.Services; + +public class ShadowverseSessionServiceTests +{ + /// + /// Fixture captured live from a fresh signup against this server. The client computed this + /// exact SID locally and sent it on the next /check/game_start request. Pinning the formula + /// here means any future refactor of + /// that drifts from Cute/Cryptographer.MakeMd5(viewerId + udid) will fail this test + /// before the user discovers it as a decrypt failure on game_start. + /// + [Test] + public void ComputeClientSessionId_matches_captured_fixture() + { + var svc = new ShadowverseSessionService(); + const long viewerId = 1; + var udid = new System.Guid("62747917-93bc-454c-abb4-ef423b3c9317"); + + string sid = svc.ComputeClientSessionId(viewerId, udid); + + Assert.That(sid, Is.EqualTo("dc4aac79d35fe15dfb6262e0071bb03c")); + } + + [Test] + public void StoreSessionForViewer_makes_sid_resolvable_to_udid() + { + var svc = new ShadowverseSessionService(); + const long viewerId = 1; + var udid = new System.Guid("62747917-93bc-454c-abb4-ef423b3c9317"); + + svc.StoreSessionForViewer(viewerId, udid); + + Assert.That(svc.GetUdidFromSessionId("dc4aac79d35fe15dfb6262e0071bb03c"), Is.EqualTo(udid)); + } +}