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="; /// /// Default cap for the in-memory SID→UDID map. Each entry is roughly 32B SID + 16B Guid /// plus dict + queue overhead — 10k entries ≈ 1 MB of process memory. Sized for the /// emulator's expected ceiling, not prod scale. Long-running dev hosts that keep /// accumulating signups would otherwise grow this dict unboundedly. /// public const int DefaultMaxEntries = 10_000; private readonly int _maxEntries; private readonly ConcurrentDictionary _sessionIdToUdid; private readonly ConcurrentQueue _insertionOrder; public ShadowverseSessionService() : this(DefaultMaxEntries) { } public ShadowverseSessionService(int maxEntries) { if (maxEntries <= 0) throw new ArgumentOutOfRangeException(nameof(maxEntries), "Cap must be positive."); _maxEntries = maxEntries; _sessionIdToUdid = new(); _insertionOrder = new(); } public Guid? GetUdidFromSessionId(string sid) { if (_sessionIdToUdid.TryGetValue(sid, out var udid)) { return udid; } return null; } public void StoreUdidForSessionId(string sid, Guid udid) { // FIFO eviction: only enqueue on first insertion so the queue doesn't grow when // an existing SID is re-stored (the only realistic "update" — same SID always // resolves to the same UDID by construction of ComputeClientSessionId, so this // path is effectively a no-op semantically). if (_sessionIdToUdid.TryAdd(sid, udid)) { _insertionOrder.Enqueue(sid); EvictIfOverCap(); } else { _sessionIdToUdid[sid] = udid; } } private void EvictIfOverCap() { while (_sessionIdToUdid.Count > _maxEntries && _insertionOrder.TryDequeue(out var oldest)) { _sessionIdToUdid.TryRemove(oldest, out _); } } /// /// 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); } }