using System; using System.Collections; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Diagnostics; using System.IO; using System.Linq; using System.Reflection; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Runtime.Versioning; using System.Security.Cryptography; using System.Text; using System.Threading; using System.Threading.Tasks; using BepInEx; using BepInEx.Bootstrap; using BepInEx.Configuration; using HarmonyLib; using Microsoft.CodeAnalysis; using RunicSentinel.Admission; using RunicSentinelClient.Runtime; using UnityEngine; [assembly: CompilationRelaxations(8)] [assembly: RuntimeCompatibility(WrapNonExceptionThrows = true)] [assembly: Debuggable(DebuggableAttribute.DebuggingModes.IgnoreSymbolStoreSequencePoints)] [assembly: AssemblyTitle("Runic Sentinel Client")] [assembly: AssemblyDescription("Minimal client-only profile reporter for authoritative Runic Sentinel admission")] [assembly: AssemblyCompany("Chazman")] [assembly: AssemblyProduct("Runic Sentinel Client")] [assembly: AssemblyFileVersion("1.0.0.0")] [assembly: AssemblyInformationalVersion("1.0.0")] [assembly: InternalsVisibleTo("RunicSentinelClient.Tests")] [assembly: TargetFramework(".NETStandard,Version=v2.1", FrameworkDisplayName = ".NET Standard 2.1")] [assembly: AssemblyVersion("1.0.0.0")] [module: RefSafetyRules(11)] namespace Microsoft.CodeAnalysis { [CompilerGenerated] [Embedded] internal sealed class EmbeddedAttribute : Attribute { } } namespace System.Runtime.CompilerServices { [CompilerGenerated] [Embedded] [AttributeUsage(AttributeTargets.Module, AllowMultiple = false, Inherited = false)] internal sealed class RefSafetyRulesAttribute : Attribute { public readonly int Version; public RefSafetyRulesAttribute(int P_0) { Version = P_0; } } } namespace RunicSentinel.Admission { internal sealed class AdmissionPluginEvidence { internal string Id { get; } internal string Version { get; } internal string Sha256 { get; } internal AdmissionPluginEvidence(string id, string version, string sha256) { Id = AdmissionValidation.RequireAtom(id, 1, 128, "id"); Version = AdmissionValidation.RequireAtom(version, 1, 64, "version"); Sha256 = AdmissionValidation.RequireLowerHex(sha256, 64, "sha256"); } } internal sealed class AdmissionClientProfile { internal long CapturedUnixSeconds { get; } internal string Digest { get; } internal IReadOnlyList Plugins { get; } internal AdmissionClientProfile(long capturedUnixSeconds, string digest, IList plugins) { if (capturedUnixSeconds < 0) { throw new ArgumentOutOfRangeException("capturedUnixSeconds"); } CapturedUnixSeconds = capturedUnixSeconds; Digest = AdmissionValidation.RequireLowerHex(digest, 64, "digest"); if (plugins == null) { throw new ArgumentNullException("plugins"); } Plugins = new ReadOnlyCollection(new List(plugins)); } } internal sealed class AdmissionChallenge { private readonly byte[] _nonce; internal string RequestId { get; } internal byte[] Nonce => (byte[])_nonce.Clone(); internal long IssuedUnixSeconds { get; } internal long DeadlineUnixSeconds { get; } internal AdmissionChallenge(string requestId, byte[] nonce, long issuedUnixSeconds, long deadlineUnixSeconds) { RequestId = AdmissionValidation.RequireLowerHex(requestId, 32, "requestId"); if (nonce == null || nonce.Length != 32) { throw new ArgumentOutOfRangeException("nonce"); } if (issuedUnixSeconds < 0 || deadlineUnixSeconds < issuedUnixSeconds) { throw new ArgumentOutOfRangeException("issuedUnixSeconds"); } _nonce = (byte[])nonce.Clone(); IssuedUnixSeconds = issuedUnixSeconds; DeadlineUnixSeconds = deadlineUnixSeconds; } } internal sealed class AdmissionReport { internal string RequestId { get; } internal string ClientVersion { get; } internal long CapturedUnixSeconds { get; } internal long IssuedUnixSeconds { get; } internal string ProfileDigest { get; } internal string NonceBinding { get; } internal IReadOnlyList Plugins { get; } internal AdmissionReport(string requestId, string clientVersion, long capturedUnixSeconds, long issuedUnixSeconds, string profileDigest, string nonceBinding, IList plugins) { RequestId = AdmissionValidation.RequireLowerHex(requestId, 32, "requestId"); ClientVersion = AdmissionValidation.RequireAtom(clientVersion, 1, 32, "clientVersion"); if (capturedUnixSeconds < 0 || issuedUnixSeconds < 0) { throw new ArgumentOutOfRangeException("capturedUnixSeconds"); } ProfileDigest = AdmissionValidation.RequireLowerHex(profileDigest, 64, "profileDigest"); NonceBinding = AdmissionValidation.RequireLowerHex(nonceBinding, 64, "nonceBinding"); if (plugins == null || plugins.Count > 512) { throw new ArgumentOutOfRangeException("plugins"); } CapturedUnixSeconds = capturedUnixSeconds; IssuedUnixSeconds = issuedUnixSeconds; Plugins = new ReadOnlyCollection(new List(plugins)); } } internal sealed class AdmissionDecisionMessage { internal string RequestId { get; } internal bool Accepted { get; } internal bool ResumeHandshake { get; } internal string ReasonCode { get; } internal long PolicySequence { get; } internal string PolicyProfile { get; } internal long IssuedUnixSeconds { get; } internal AdmissionDecisionMessage(string requestId, bool accepted, bool resumeHandshake, string reasonCode, long policySequence, string policyProfile, long issuedUnixSeconds) { RequestId = AdmissionValidation.RequireLowerHex(requestId, 32, "requestId"); ReasonCode = AdmissionValidation.RequireReason(reasonCode, "reasonCode"); if (policySequence < 0 || issuedUnixSeconds < 0) { throw new ArgumentOutOfRangeException("policySequence"); } if (!string.IsNullOrEmpty(policyProfile)) { AdmissionValidation.RequireAtom(policyProfile, 1, 64, "policyProfile"); } if (resumeHandshake && !accepted) { throw new ArgumentException("Only an accepted decision may resume the native handshake.", "resumeHandshake"); } Accepted = accepted; ResumeHandshake = resumeHandshake; PolicySequence = policySequence; PolicyProfile = policyProfile ?? string.Empty; IssuedUnixSeconds = issuedUnixSeconds; } } internal static class AdmissionValidation { internal static string RequireAtom(string value, int minimum, int maximum, string parameterName) { if (!IsAtom(value, minimum, maximum)) { throw new ArgumentException("Value is not a canonical ASCII atom.", parameterName); } return value; } internal static bool IsAtom(string value, int minimum, int maximum) { if (value == null || value.Length < minimum || value.Length > maximum) { return false; } foreach (char c in value) { if ((c < 'a' || c > 'z') && (c < 'A' || c > 'Z') && (c < '0' || c > '9') && c != '.' && c != '-' && c != '_') { return false; } } return true; } internal static string RequireLowerHex(string value, int length, string parameterName) { if (!IsLowerHex(value, length)) { throw new ArgumentException("Value is not canonical lowercase hexadecimal.", parameterName); } return value; } internal static bool IsLowerHex(string value, int length) { if (value == null || value.Length != length) { return false; } foreach (char c in value) { if ((c < '0' || c > '9') && (c < 'a' || c > 'f')) { return false; } } return true; } internal static string RequireReason(string value, string parameterName) { if (!IsReason(value)) { throw new ArgumentException("Reason code is not canonical.", parameterName); } return value; } internal static bool IsReason(string value) { if (value == null || value.Length < 1 || value.Length > 96) { return false; } foreach (char c in value) { if ((c < 'a' || c > 'z') && (c < '0' || c > '9') && c != '-' && c != '.') { return false; } } return true; } } internal static class AdmissionProfileCanonicalizer { internal const int MaximumCanonicalBytes = 262144; private const string Header = "RUNIC-SENTINEL-CLIENT-PROFILE/1\n"; internal static bool TryCreate(IEnumerable source, long capturedUnixSeconds, out AdmissionClientProfile profile, out string failure) { profile = null; failure = string.Empty; if (source == null || capturedUnixSeconds < 0) { failure = "profile-missing"; return false; } List list = new List(); foreach (AdmissionPluginEvidence item in source) { if (item == null) { failure = "profile-entry-null"; return false; } if (list.Count >= 512) { failure = "profile-plugin-cap"; return false; } list.Add(item); } list.Sort((AdmissionPluginEvidence left, AdmissionPluginEvidence right) => string.CompareOrdinal(left.Id, right.Id)); string a = null; StringBuilder stringBuilder = new StringBuilder("RUNIC-SENTINEL-CLIENT-PROFILE/1\n", Math.Min(32768, 64 + list.Count * 160)); foreach (AdmissionPluginEvidence item2 in list) { if (string.Equals(a, item2.Id, StringComparison.Ordinal)) { failure = "profile-plugin-duplicate"; return false; } a = item2.Id; stringBuilder.Append(item2.Id).Append('|').Append(item2.Version) .Append('|') .Append(item2.Sha256) .Append('\n'); if (stringBuilder.Length > 262144) { failure = "profile-size"; return false; } } byte[] bytes = Encoding.UTF8.GetBytes(stringBuilder.ToString()); if (bytes.Length > 262144) { failure = "profile-size"; return false; } string digest; using (SHA256 sHA = SHA256.Create()) { digest = Hex(sHA.ComputeHash(bytes)); } profile = new AdmissionClientProfile(capturedUnixSeconds, digest, list); return true; } internal static string Hex(byte[] bytes) { if (bytes == null) { throw new ArgumentNullException("bytes"); } StringBuilder stringBuilder = new StringBuilder(bytes.Length * 2); for (int i = 0; i < bytes.Length; i++) { stringBuilder.Append(bytes[i].ToString("x2")); } return stringBuilder.ToString(); } internal static bool FixedTimeEquals(string left, string right) { if (left == null || right == null) { return false; } int num = left.Length ^ right.Length; int num2 = Math.Max(left.Length, right.Length); for (int i = 0; i < num2; i++) { char c = ((i < left.Length) ? left[i] : '\0'); char c2 = ((i < right.Length) ? right[i] : '\0'); num |= c ^ c2; } return num == 0; } } internal enum AdmissionMessageKind : byte { Challenge = 1, Report, Decision } internal static class AdmissionProtocolV2 { internal const string DirectRpcName = "chazman.RunicSentinel.Admission.v2"; internal const int WireSchema = 2; internal const int MaximumPlugins = 512; internal const int MaximumFrameBytes = 262144; internal const int NonceBytes = 32; internal const int RequestIdHexLength = 32; internal const long MaximumClockSkewSeconds = 60L; internal const long MaximumMessageAgeSeconds = 300L; internal const long MaximumChallengeLifetimeSeconds = 120L; private const int Magic = 843141970; private const int Terminal = 843337285; private static readonly UTF8Encoding StrictUtf8 = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true); internal static AdmissionChallenge CreateChallenge(long nowUnixSeconds, long lifetimeSeconds) { if (nowUnixSeconds < 0 || lifetimeSeconds < 1 || lifetimeSeconds > 120 || nowUnixSeconds > long.MaxValue - lifetimeSeconds) { throw new ArgumentOutOfRangeException("lifetimeSeconds"); } byte[] array = new byte[32]; byte[] array2 = new byte[16]; using (RandomNumberGenerator randomNumberGenerator = RandomNumberGenerator.Create()) { randomNumberGenerator.GetBytes(array); randomNumberGenerator.GetBytes(array2); } return new AdmissionChallenge(AdmissionProfileCanonicalizer.Hex(array2), array, nowUnixSeconds, nowUnixSeconds + lifetimeSeconds); } internal static bool IsChallengeCurrent(AdmissionChallenge challenge, long nowUnixSeconds, out string failure) { failure = string.Empty; if (challenge == null || nowUnixSeconds < 0) { failure = "challenge-missing"; return false; } if (challenge.DeadlineUnixSeconds < challenge.IssuedUnixSeconds || challenge.DeadlineUnixSeconds - challenge.IssuedUnixSeconds > 120) { failure = "challenge-lifetime"; return false; } if (challenge.IssuedUnixSeconds < nowUnixSeconds - 300 || challenge.IssuedUnixSeconds > nowUnixSeconds + 60 || nowUnixSeconds > challenge.DeadlineUnixSeconds) { failure = "challenge-stale"; return false; } return true; } internal static AdmissionReport CreateReport(AdmissionChallenge challenge, AdmissionClientProfile profile, string clientVersion, long nowUnixSeconds) { if (!IsChallengeCurrent(challenge, nowUnixSeconds, out var failure)) { throw new InvalidOperationException(failure); } if (profile == null) { throw new ArgumentNullException("profile"); } if (!TryComputeNonceBinding(challenge.RequestId, challenge.Nonce, profile.Digest, out var binding)) { throw new InvalidOperationException("nonce-binding-failed"); } return new AdmissionReport(challenge.RequestId, clientVersion, profile.CapturedUnixSeconds, nowUnixSeconds, profile.Digest, binding, new List(profile.Plugins)); } internal static bool TryValidateReport(AdmissionChallenge challenge, AdmissionReport report, long receivedUnixSeconds, out AdmissionClientProfile profile, out string failure) { profile = null; failure = string.Empty; if (!IsChallengeCurrent(challenge, receivedUnixSeconds, out failure)) { return false; } if (report == null || !AdmissionProfileCanonicalizer.FixedTimeEquals(challenge.RequestId, report.RequestId)) { failure = "report-request-mismatch"; return false; } if (report.IssuedUnixSeconds < receivedUnixSeconds - 300 || report.IssuedUnixSeconds > receivedUnixSeconds + 60 || report.CapturedUnixSeconds > receivedUnixSeconds + 60) { failure = "report-stale"; return false; } if (!AdmissionProfileCanonicalizer.TryCreate(report.Plugins, report.CapturedUnixSeconds, out profile, out failure)) { return false; } if (!AdmissionProfileCanonicalizer.FixedTimeEquals(profile.Digest, report.ProfileDigest)) { profile = null; failure = "report-digest-mismatch"; return false; } if (!TryComputeNonceBinding(challenge.RequestId, challenge.Nonce, profile.Digest, out var binding) || !AdmissionProfileCanonicalizer.FixedTimeEquals(binding, report.NonceBinding)) { profile = null; failure = "report-binding-mismatch"; return false; } return true; } internal static bool TryComputeNonceBinding(string requestId, byte[] nonce, string profileDigest, out string binding) { binding = string.Empty; if (!AdmissionValidation.IsLowerHex(requestId, 32) || nonce == null || nonce.Length != 32 || !AdmissionValidation.IsLowerHex(profileDigest, 64)) { return false; } string s = "RUNIC-SENTINEL-ADMISSION-BINDING/2\n" + requestId + "\n" + AdmissionProfileCanonicalizer.Hex(nonce) + "\n" + profileDigest + "\n"; using (SHA256 sHA = SHA256.Create()) { binding = AdmissionProfileCanonicalizer.Hex(sHA.ComputeHash(Encoding.ASCII.GetBytes(s))); } return true; } internal static byte[] EncodeChallenge(AdmissionChallenge value) { if (value == null) { throw new ArgumentNullException("value"); } return Encode(AdmissionMessageKind.Challenge, delegate(BinaryWriter writer) { WriteString(writer, value.RequestId, 32); byte[] nonce = value.Nonce; writer.Write(nonce.Length); writer.Write(nonce); writer.Write(value.IssuedUnixSeconds); writer.Write(value.DeadlineUnixSeconds); }); } internal static byte[] EncodeReport(AdmissionReport value) { if (value == null) { throw new ArgumentNullException("value"); } if (value.Plugins.Count > 512) { throw new ArgumentOutOfRangeException("value"); } string previous = null; return Encode(AdmissionMessageKind.Report, delegate(BinaryWriter writer) { WriteString(writer, value.RequestId, 32); WriteString(writer, value.ClientVersion, 32); writer.Write(value.CapturedUnixSeconds); writer.Write(value.IssuedUnixSeconds); WriteString(writer, value.ProfileDigest, 64); WriteString(writer, value.NonceBinding, 64); writer.Write(value.Plugins.Count); foreach (AdmissionPluginEvidence plugin in value.Plugins) { if (plugin == null || (previous != null && string.CompareOrdinal(previous, plugin.Id) >= 0)) { throw new InvalidDataException("Profile entries must be strictly ordered."); } previous = plugin.Id; WriteString(writer, plugin.Id, 128); WriteString(writer, plugin.Version, 64); WriteString(writer, plugin.Sha256, 64); } }); } internal static byte[] EncodeDecision(AdmissionDecisionMessage value) { if (value == null) { throw new ArgumentNullException("value"); } return Encode(AdmissionMessageKind.Decision, delegate(BinaryWriter writer) { WriteString(writer, value.RequestId, 32); writer.Write(value.Accepted ? ((byte)1) : ((byte)0)); writer.Write(value.ResumeHandshake ? ((byte)1) : ((byte)0)); WriteString(writer, value.ReasonCode, 96); writer.Write(value.PolicySequence); WriteString(writer, value.PolicyProfile, 64); writer.Write(value.IssuedUnixSeconds); }); } internal static bool TryGetKind(byte[] bytes, out AdmissionMessageKind kind, out string failure) { kind = (AdmissionMessageKind)0; failure = string.Empty; try { using MemoryStream input = NewReadStream(bytes); using BinaryReader reader = new BinaryReader(input, StrictUtf8); if (!ReadHeader(reader, out kind, out failure)) { return false; } return true; } catch { kind = (AdmissionMessageKind)0; failure = "frame-malformed"; return false; } } internal static bool TryDecodeChallenge(byte[] bytes, out AdmissionChallenge value, out string failure) { value = null; failure = string.Empty; try { using MemoryStream memoryStream = NewReadStream(bytes); using BinaryReader binaryReader = new BinaryReader(memoryStream, StrictUtf8); if (!ReadExpectedHeader(binaryReader, AdmissionMessageKind.Challenge, out failure) || !TryReadString(binaryReader, memoryStream, 32, out var value2) || !AdmissionValidation.IsLowerHex(value2, 32)) { failure = "challenge-shape"; return false; } int num = binaryReader.ReadInt32(); if (num != 32 || Remaining(memoryStream) < (long)num + 20L) { failure = "challenge-nonce"; return false; } byte[] nonce = binaryReader.ReadBytes(num); long num2 = binaryReader.ReadInt64(); long num3 = binaryReader.ReadInt64(); if (num2 < 0 || num3 < num2 || num3 - num2 > 120 || !ReadTerminal(binaryReader, memoryStream)) { failure = "challenge-shape"; return false; } value = new AdmissionChallenge(value2, nonce, num2, num3); return true; } catch { value = null; failure = "challenge-malformed"; return false; } } internal static bool TryDecodeReport(byte[] bytes, out AdmissionReport value, out string failure) { value = null; failure = string.Empty; try { using MemoryStream memoryStream = NewReadStream(bytes); using BinaryReader binaryReader = new BinaryReader(memoryStream, StrictUtf8); if (!ReadExpectedHeader(binaryReader, AdmissionMessageKind.Report, out failure) || !TryReadString(binaryReader, memoryStream, 32, out var value2) || !TryReadString(binaryReader, memoryStream, 32, out var value3)) { failure = "report-shape"; return false; } long num = binaryReader.ReadInt64(); long num2 = binaryReader.ReadInt64(); if (!TryReadString(binaryReader, memoryStream, 64, out var value4) || !TryReadString(binaryReader, memoryStream, 64, out var value5)) { failure = "report-shape"; return false; } int num3 = binaryReader.ReadInt32(); if (num3 < 0 || num3 > 512) { failure = "report-plugin-cap"; return false; } List list = new List(num3); string text = null; for (int i = 0; i < num3; i++) { if (!TryReadString(binaryReader, memoryStream, 128, out var value6) || !TryReadString(binaryReader, memoryStream, 64, out var value7) || !TryReadString(binaryReader, memoryStream, 64, out var value8) || !AdmissionValidation.IsAtom(value6, 1, 128) || !AdmissionValidation.IsAtom(value7, 1, 64) || !AdmissionValidation.IsLowerHex(value8, 64) || (text != null && string.CompareOrdinal(text, value6) >= 0)) { failure = "report-plugin-shape"; return false; } text = value6; list.Add(new AdmissionPluginEvidence(value6, value7, value8)); } if (!AdmissionValidation.IsLowerHex(value2, 32) || !AdmissionValidation.IsAtom(value3, 1, 32) || num < 0 || num2 < 0 || !AdmissionValidation.IsLowerHex(value4, 64) || !AdmissionValidation.IsLowerHex(value5, 64) || !ReadTerminal(binaryReader, memoryStream)) { failure = "report-shape"; return false; } value = new AdmissionReport(value2, value3, num, num2, value4, value5, list); return true; } catch { value = null; failure = "report-malformed"; return false; } } internal static bool TryDecodeDecision(byte[] bytes, out AdmissionDecisionMessage value, out string failure) { value = null; failure = string.Empty; try { using MemoryStream memoryStream = NewReadStream(bytes); using BinaryReader binaryReader = new BinaryReader(memoryStream, StrictUtf8); if (!ReadExpectedHeader(binaryReader, AdmissionMessageKind.Decision, out failure) || !TryReadString(binaryReader, memoryStream, 32, out var value2)) { failure = "decision-shape"; return false; } byte b = binaryReader.ReadByte(); byte b2 = binaryReader.ReadByte(); if (!TryReadString(binaryReader, memoryStream, 96, out var value3)) { failure = "decision-shape"; return false; } long num = binaryReader.ReadInt64(); if (!TryReadString(binaryReader, memoryStream, 64, out var value4)) { failure = "decision-shape"; return false; } long num2 = binaryReader.ReadInt64(); if (!AdmissionValidation.IsLowerHex(value2, 32) || b > 1 || b2 > 1 || (b2 == 1 && b != 1) || !AdmissionValidation.IsReason(value3) || num < 0 || num2 < 0 || (value4.Length > 0 && !AdmissionValidation.IsAtom(value4, 1, 64)) || !ReadTerminal(binaryReader, memoryStream)) { failure = "decision-shape"; return false; } value = new AdmissionDecisionMessage(value2, b == 1, b2 == 1, value3, num, value4, num2); return true; } catch { value = null; failure = "decision-malformed"; return false; } } internal static bool IsDecisionFresh(AdmissionDecisionMessage value, long nowUnixSeconds) { if (value != null && nowUnixSeconds >= 0 && value.IssuedUnixSeconds >= nowUnixSeconds - 300) { return value.IssuedUnixSeconds <= nowUnixSeconds + 60; } return false; } private static byte[] Encode(AdmissionMessageKind kind, Action body) { using MemoryStream memoryStream = new MemoryStream(); using BinaryWriter binaryWriter = new BinaryWriter(memoryStream, StrictUtf8); binaryWriter.Write(843141970); binaryWriter.Write(2); binaryWriter.Write((byte)kind); body(binaryWriter); binaryWriter.Write(843337285); binaryWriter.Flush(); if (memoryStream.Length <= 0 || memoryStream.Length > 262144) { throw new InvalidDataException("Admission frame exceeds its wire bound."); } return memoryStream.ToArray(); } private static MemoryStream NewReadStream(byte[] bytes) { if (bytes == null || bytes.Length == 0 || bytes.Length > 262144) { throw new InvalidDataException("Admission frame size is invalid."); } return new MemoryStream(bytes, writable: false); } private static bool ReadHeader(BinaryReader reader, out AdmissionMessageKind kind, out string failure) { kind = (AdmissionMessageKind)0; failure = string.Empty; if (reader.ReadInt32() != 843141970 || reader.ReadInt32() != 2) { failure = "frame-header"; return false; } kind = (AdmissionMessageKind)reader.ReadByte(); if ((int)kind < 1 || (int)kind > 3) { kind = (AdmissionMessageKind)0; failure = "frame-kind"; return false; } return true; } private static bool ReadExpectedHeader(BinaryReader reader, AdmissionMessageKind expected, out string failure) { if (!ReadHeader(reader, out var kind, out failure)) { return false; } if (kind == expected) { return true; } failure = "frame-kind"; return false; } private static void WriteString(BinaryWriter writer, string value, int maximumCharacters) { if (value == null || value.Length > maximumCharacters) { throw new InvalidDataException("Admission string exceeds its bound."); } byte[] bytes = StrictUtf8.GetBytes(value); if (bytes.Length > maximumCharacters) { throw new InvalidDataException("Admission string exceeds its byte bound."); } writer.Write(bytes.Length); writer.Write(bytes); } private static bool TryReadString(BinaryReader reader, MemoryStream stream, int maximumBytes, out string value) { value = string.Empty; int num = reader.ReadInt32(); if (num < 0 || num > maximumBytes || Remaining(stream) < num) { return false; } byte[] array = reader.ReadBytes(num); if (array.Length != num) { return false; } value = StrictUtf8.GetString(array); return value.Length <= maximumBytes; } private static bool ReadTerminal(BinaryReader reader, MemoryStream stream) { if (Remaining(stream) == 4 && reader.ReadInt32() == 843337285) { return Remaining(stream) == 0; } return false; } private static long Remaining(MemoryStream stream) { return stream.Length - stream.Position; } } } namespace RunicSentinelClient { internal static class ClientConfiguration { internal static ConfigEntry Enabled { get; private set; } internal static void Bind(ConfigFile config) { Enabled = config.Bind("General", "Enabled", true, "Enable client-only Runic Sentinel admission profile reporting. The setting is sampled at startup; the plugin remains inert on servers."); } } [BepInPlugin("chazman.RunicSentinelClient", "Runic Sentinel Client", "1.0.0")] public sealed class Plugin : BaseUnityPlugin { public const string Guid = "chazman.RunicSentinelClient"; public const string Name = "Runic Sentinel Client"; public const string Version = "1.0.0"; private Harmony _harmony; internal static ClientAdmissionRuntime ActiveRuntime { get; private set; } private void Awake() { //IL_0070: Unknown result type (might be due to invalid IL or missing references) //IL_007a: Expected O, but got Unknown ClientConfiguration.Bind(((BaseUnityPlugin)this).Config); ConfigEntry enabled = ClientConfiguration.Enabled; if (enabled != null && !enabled.Value) { ((BaseUnityPlugin)this).Logger.LogInfo((object)"Runic Sentinel Client is disabled; no worker or RPC handler was created."); return; } if (Application.isBatchMode) { ((BaseUnityPlugin)this).Logger.LogInfo((object)"Runic Sentinel Client is client-only and remains inert in batch mode."); return; } try { ActiveRuntime = new ClientAdmissionRuntime(delegate(string message) { ((BaseUnityPlugin)this).Logger.LogInfo((object)message); }, delegate(string message) { ((BaseUnityPlugin)this).Logger.LogWarning((object)message); }); _harmony = new Harmony("chazman.RunicSentinelClient"); _harmony.PatchAll(typeof(Plugin).Assembly); if (!ClientTransportPatches.IsInstalled("chazman.RunicSentinelClient")) { throw new InvalidOperationException("The pre-admission connection patch was not installed."); } ((BaseUnityPlugin)this).Logger.LogInfo((object)"Runic Sentinel Client v1.0.0 initialized. It activates only for a client connection and reports self-observed compatibility evidence to the server."); } catch (Exception ex) { Shutdown(); ((BaseUnityPlugin)this).Logger.LogError((object)("Runic Sentinel Client failed safely: " + ex.GetType().Name + ".")); } } private void Update() { try { ActiveRuntime?.Tick(); } catch (Exception ex) { ((BaseUnityPlugin)this).Logger.LogWarning((object)("Sentinel Client admission tick failed safely (" + ex.GetType().Name + ").")); } } private void OnDestroy() { Shutdown(); } private void Shutdown() { try { Harmony harmony = _harmony; if (harmony != null) { harmony.UnpatchSelf(); } } catch { } _harmony = null; try { ActiveRuntime?.Dispose(); } catch { } ActiveRuntime = null; } } } namespace RunicSentinelClient.Runtime { internal sealed class ClientAdmissionRuntime : IDisposable { private readonly Action _information; private readonly Action _warning; private ClientProfileCollector _profiles; private ClientAdmissionTransport _transport; private bool _disposed; internal ClientAdmissionRuntime(Action information, Action warning) { _information = information; _warning = warning; } internal void OnConnectionStarted(ZNet network, ZNetPeer peer) { if (!_disposed && !((Object)(object)network == (Object)null) && ZNet.instance == network && !network.IsServer() && peer != null && peer.m_server && peer.m_rpc != null) { EnsureStarted(); _transport.AttachServerPeer(network, peer); } } internal void Tick() { if (!_disposed) { ZNet instance = ZNet.instance; if ((Object)(object)instance != (Object)null && instance.IsServer()) { StopClientServices(); return; } if ((Object)(object)instance == (Object)null) { StopClientServices(); return; } EnsureStarted(); _transport.Tick(); } } public void Dispose() { if (!_disposed) { _disposed = true; StopClientServices(); } } private void EnsureStarted() { if (_profiles == null) { _profiles = new ClientProfileCollector(); _transport = new ClientAdmissionTransport(_profiles, _information, _warning); _profiles.Start(); _information?.Invoke("Sentinel Client started a bounded local plugin profile."); } } private void StopClientServices() { try { _transport?.Dispose(); } catch { } _transport = null; try { _profiles?.Dispose(); } catch { } _profiles = null; } } internal sealed class ClientAdmissionTransport : IDisposable { internal const int MaximumNativeResumeAttempts = 3; internal const int MaximumTrackedPeers = 64; private static readonly FieldInfo RpcFunctionsField = typeof(ZRpc).GetField("m_functions", BindingFlags.Instance | BindingFlags.NonPublic); private readonly object _gate = new object(); private readonly ClientProfileCollector _profiles; private readonly Action _information; private readonly Action _warning; private ZNet _network; private ZNetPeer _serverPeer; private ZRpc _serverRpc; private object _registeredHandler; private ZNet _blockedNetwork; private ZNetPeer _blockedPeer; private ZRpc _blockedRpc; private AdmissionChallenge _challenge; private string _sentRequestId = string.Empty; private string _completedRequestId = string.Empty; private string _reportedProfileFailureStatus = string.Empty; private int _nativeResumeAttempts; private bool _nativeHandshakeResumed; private bool _disposed; internal ClientAdmissionTransport(ClientProfileCollector profiles, Action information, Action warning) { _profiles = profiles ?? throw new ArgumentNullException("profiles"); _information = information; _warning = warning; } internal bool AttachServerPeer(ZNet network, ZNetPeer peer) { if ((Object)(object)network == (Object)null || ZNet.instance != network || network.IsServer() || peer == null || !peer.m_server || peer.m_rpc == null) { return false; } ZRpc rpc = peer.m_rpc; lock (_gate) { if (_disposed) { return false; } if (_network == network && _serverPeer == peer && _serverRpc == rpc) { return true; } if (_blockedNetwork == network && _blockedPeer == peer && _blockedRpc == rpc) { return false; } DetachLocked(); int num = StableHash("chazman.RunicSentinel.Admission.v2"); if (!TryGetFunctionMap(rpc, out var functions)) { BlockLocked(network, peer, rpc); Notify(_warning, "Sentinel Client could not access the direct RPC registry."); return false; } if (functions.Contains(num)) { BlockLocked(network, peer, rpc); Notify(_warning, "Sentinel Client left the direct RPC with its existing Sentinel responder."); return false; } try { rpc.Register("chazman.RunicSentinel.Admission.v2", (Action)ReceivePackage); object obj = functions[num]; if (obj == null) { rpc.Unregister("chazman.RunicSentinel.Admission.v2"); BlockLocked(network, peer, rpc); Notify(_warning, "Sentinel Client could not verify its direct RPC registration."); return false; } _network = network; _serverPeer = peer; _serverRpc = rpc; _registeredHandler = obj; ResetExchangeLocked(); Notify(_information, "Sentinel Client bound its admission handler before native peer admission."); return true; } catch (Exception ex) { try { if (functions.Contains(num)) { rpc.Unregister("chazman.RunicSentinel.Admission.v2"); } } catch { } BlockLocked(network, peer, rpc); Notify(_warning, "Sentinel Client direct RPC registration failed safely (" + ex.GetType().Name + ")."); return false; } } } internal void Tick() { //IL_032a: Unknown result type (might be due to invalid IL or missing references) //IL_0330: Expected O, but got Unknown ZNet instance = ZNet.instance; if ((Object)(object)instance == (Object)null || instance.IsServer()) { ClearDisconnected(); return; } ZNetPeer val; try { val = instance.GetServerPeer(); } catch { val = null; } if (val?.m_rpc != null) { AttachServerPeer(instance, val); } ZRpc serverRpc; AdmissionChallenge challenge; lock (_gate) { if (_disposed) { return; } if (_serverRpc == null) { if ((Object)(object)_blockedNetwork != (Object)null && (_blockedNetwork != instance || !IsTrackedServerPeer(instance, _blockedPeer))) { ClearBlockedLocked(); } return; } if (_network != instance || _serverPeer == null || !_serverPeer.m_server || _serverPeer.m_rpc != _serverRpc || !IsTrackedServerPeer(instance, _serverPeer)) { DetachLocked(); return; } if (_challenge == null || string.Equals(_sentRequestId, _challenge.RequestId, StringComparison.Ordinal)) { return; } serverRpc = _serverRpc; challenge = _challenge; } long nowUnixSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); if (!AdmissionProtocolV2.IsChallengeCurrent(challenge, nowUnixSeconds, out var failure)) { lock (_gate) { if (_challenge == challenge) { _challenge = null; } } Notify(_warning, "Sentinel admission challenge was rejected: " + failure + "."); return; } if (!_profiles.TryGet(out var profile, out var status)) { bool flag = false; lock (_gate) { if (!string.Equals(status, "capturing-profile", StringComparison.Ordinal) && !string.Equals(status, "not-started", StringComparison.Ordinal) && !string.Equals(_reportedProfileFailureStatus, status, StringComparison.Ordinal)) { _reportedProfileFailureStatus = status; flag = true; } } if (flag) { Notify(_warning, "Sentinel Client could not answer the admission challenge (" + status + ")."); } return; } byte[] array; try { array = AdmissionProtocolV2.EncodeReport(AdmissionProtocolV2.CreateReport(challenge, profile, "1.0.0", nowUnixSeconds)); } catch (Exception ex) { Notify(_warning, "Sentinel admission report creation failed safely (" + ex.GetType().Name + ")."); return; } lock (_gate) { if (_disposed || _serverRpc != serverRpc || _challenge != challenge || string.Equals(_sentRequestId, challenge.RequestId, StringComparison.Ordinal)) { return; } _sentRequestId = challenge.RequestId; } try { if (!RpcConnected(serverRpc)) { lock (_gate) { if (string.Equals(_sentRequestId, challenge.RequestId, StringComparison.Ordinal)) { _sentRequestId = string.Empty; } return; } } serverRpc.Invoke("chazman.RunicSentinel.Admission.v2", new object[1] { (object)new ZPackage(array) }); Notify(_information, "Sentinel Client submitted an admission profile report (" + profile.Plugins.Count + " plugins)."); } catch (Exception ex2) { lock (_gate) { if (string.Equals(_sentRequestId, challenge.RequestId, StringComparison.Ordinal)) { _sentRequestId = string.Empty; } } Notify(_warning, "Sentinel admission report send failed safely (" + ex2.GetType().Name + ")."); } } public void Dispose() { lock (_gate) { if (!_disposed) { _disposed = true; DetachLocked(); } } } private void ReceivePackage(ZRpc rpc, ZPackage package) { if (rpc == null || package == null) { return; } lock (_gate) { if (_disposed || _serverRpc != rpc || (Object)(object)_network == (Object)null || ZNet.instance != _network || _network.IsServer() || _serverPeer == null || !_serverPeer.m_server || _serverPeer.m_rpc != rpc) { return; } } byte[] array; try { if (package.Size() <= 0 || package.Size() > 262144) { return; } array = package.GetArray(); } catch { return; } if (AdmissionProtocolV2.TryGetKind(array, out var kind, out var _)) { switch (kind) { case AdmissionMessageKind.Challenge: ReceiveChallenge(rpc, array); break; case AdmissionMessageKind.Decision: ReceiveDecision(rpc, array); break; } } } private void ReceiveChallenge(ZRpc rpc, byte[] bytes) { if (!AdmissionProtocolV2.TryDecodeChallenge(bytes, out var value, out var failure) || !AdmissionProtocolV2.IsChallengeCurrent(value, DateTimeOffset.UtcNow.ToUnixTimeSeconds(), out failure)) { Notify(_warning, "Sentinel admission challenge was rejected: " + failure + "."); return; } bool flag; lock (_gate) { if (_disposed || _serverRpc != rpc || string.Equals(_completedRequestId, value.RequestId, StringComparison.Ordinal)) { return; } flag = _challenge == null || !string.Equals(_challenge.RequestId, value.RequestId, StringComparison.Ordinal); _challenge = value; _sentRequestId = string.Empty; } if (flag) { Notify(_information, "Sentinel Client received an admission challenge."); } } private void ReceiveDecision(ZRpc rpc, byte[] bytes) { if (!AdmissionProtocolV2.TryDecodeDecision(bytes, out var value, out var failure) || !AdmissionProtocolV2.IsDecisionFresh(value, DateTimeOffset.UtcNow.ToUnixTimeSeconds())) { Notify(_warning, "Sentinel admission decision was rejected: " + failure + "."); return; } bool flag = false; lock (_gate) { if (_disposed || _serverRpc != rpc || _challenge == null || !string.Equals(_challenge.RequestId, value.RequestId, StringComparison.Ordinal) || !string.Equals(_sentRequestId, value.RequestId, StringComparison.Ordinal) || string.Equals(_completedRequestId, value.RequestId, StringComparison.Ordinal)) { return; } flag = value.Accepted && value.ResumeHandshake; if (flag) { if (_nativeHandshakeResumed || _nativeResumeAttempts >= 3) { return; } _nativeResumeAttempts++; } else { _completedRequestId = value.RequestId; } } if (value.Accepted) { Notify(_information, "Sentinel admission accepted (" + value.ReasonCode + ")."); } else { Notify(_warning, "Sentinel admission denied (" + value.ReasonCode + ")."); } if (!flag) { return; } try { rpc.Invoke("ServerHandshake", Array.Empty()); lock (_gate) { if (!_disposed && _serverRpc == rpc && _challenge != null && string.Equals(_challenge.RequestId, value.RequestId, StringComparison.Ordinal)) { _nativeHandshakeResumed = true; _completedRequestId = value.RequestId; } } } catch (Exception ex) { Notify(_warning, "Sentinel could not resume the native handshake (" + ex.GetType().Name + ")."); } } private void ClearDisconnected() { lock (_gate) { ClearDisconnectedLocked(); } } private void ClearDisconnectedLocked() { if ((!((Object)(object)_network != (Object)null) || ZNet.instance != _network || _network.IsServer() || _serverPeer == null || !_serverPeer.m_server || _serverRpc == null || _serverPeer.m_rpc != _serverRpc || !IsTrackedServerPeer(_network, _serverPeer)) && (_serverRpc != null || !((Object)(object)_blockedNetwork != (Object)null) || ZNet.instance != _blockedNetwork || _blockedNetwork.IsServer() || !IsTrackedServerPeer(_blockedNetwork, _blockedPeer))) { DetachLocked(); } } private void DetachLocked() { ZRpc serverRpc = _serverRpc; object registeredHandler = _registeredHandler; _network = null; _serverPeer = null; _serverRpc = null; _registeredHandler = null; ClearBlockedLocked(); ResetExchangeLocked(); if (serverRpc == null || registeredHandler == null || !TryGetFunctionMap(serverRpc, out var functions)) { return; } int num = StableHash("chazman.RunicSentinel.Admission.v2"); try { if (functions[num] == registeredHandler) { serverRpc.Unregister("chazman.RunicSentinel.Admission.v2"); } } catch { } } private void BlockLocked(ZNet network, ZNetPeer peer, ZRpc rpc) { _blockedNetwork = network; _blockedPeer = peer; _blockedRpc = rpc; } private void ClearBlockedLocked() { _blockedNetwork = null; _blockedPeer = null; _blockedRpc = null; } private static bool IsTrackedServerPeer(ZNet network, ZNetPeer expected) { if ((Object)(object)network == (Object)null || expected == null || !expected.m_server) { return false; } List peers; try { peers = network.GetPeers(); } catch { return false; } if (peers == null) { return false; } int num = 0; foreach (ZNetPeer item in peers) { if (num++ >= 64) { break; } if (item == expected) { return true; } } return false; } private static bool RpcConnected(ZRpc rpc) { try { return rpc != null && rpc.IsConnected(); } catch { return false; } } private static void Notify(Action sink, string message) { try { sink?.Invoke(message); } catch { } } private static bool TryGetFunctionMap(ZRpc rpc, out IDictionary functions) { functions = null; if (rpc == null || RpcFunctionsField == null) { return false; } try { functions = RpcFunctionsField.GetValue(rpc) as IDictionary; return functions != null; } catch { functions = null; return false; } } private static int StableHash(string value) { int num = 5381; int num2 = num; for (int i = 0; i < value.Length && value[i] != 0; i += 2) { num = ((num << 5) + num) ^ value[i]; if (i == value.Length - 1 || value[i + 1] == '\0') { break; } num2 = ((num2 << 5) + num2) ^ value[i + 1]; } return num + num2 * 1566083941; } private void ResetExchangeLocked() { _challenge = null; _sentRequestId = string.Empty; _completedRequestId = string.Empty; _reportedProfileFailureStatus = string.Empty; _nativeResumeAttempts = 0; _nativeHandshakeResumed = false; } } internal sealed class ClientProfileCollector : IDisposable { private readonly object _gate = new object(); private CancellationTokenSource _cancellation; private AdmissionClientProfile _profile; private string _status = "not-started"; private bool _started; private bool _disposed; internal void Start() { CancellationToken token; lock (_gate) { if (_disposed) { throw new ObjectDisposedException("ClientProfileCollector"); } if (_started) { return; } _started = true; _status = "capturing-profile"; _cancellation = new CancellationTokenSource(); token = _cancellation.Token; } ClientPluginFile[] files; try { files = CaptureLoadedPlugins(); } catch (Exception ex) { Publish(null, "profile-capture-failed-" + ex.GetType().Name, token); return; } Task.Run(delegate { if (ClientProfileBuilder.TryBuild(files, DateTimeOffset.UtcNow.ToUnixTimeSeconds, token, out var profile, out var failure)) { Publish(profile, "ready", token); } else if (!token.IsCancellationRequested) { Publish(null, failure, token); } }, token); } internal bool TryGet(out AdmissionClientProfile profile, out string status) { lock (_gate) { profile = _profile; status = _status; return profile != null; } } public void Dispose() { CancellationTokenSource cancellation; lock (_gate) { if (_disposed) { return; } _disposed = true; cancellation = _cancellation; _cancellation = null; _profile = null; _status = "disposed"; } if (cancellation == null) { return; } try { cancellation.Cancel(); } catch { } try { cancellation.Dispose(); } catch { } } private void Publish(AdmissionClientProfile profile, string status, CancellationToken token) { lock (_gate) { if (!_disposed && !token.IsCancellationRequested) { _profile = profile; _status = (AdmissionValidation.IsReason(status) ? status : "profile-build-failed"); } } } private static ClientPluginFile[] CaptureLoadedPlugins() { if (Chainloader.PluginInfos.Count > 512) { throw new InvalidDataException("Plugin cap exceeded."); } List list = new List(Chainloader.PluginInfos.Count); foreach (KeyValuePair item in Chainloader.PluginInfos.OrderBy, string>((KeyValuePair keyValuePair) => keyValuePair.Key, StringComparer.Ordinal)) { if (list.Count >= 512) { throw new InvalidDataException("Plugin cap exceeded."); } PluginInfo value = item.Value; string text = ((value != null) ? value.Location : null); object obj; if (value == null) { obj = null; } else { BepInPlugin metadata = value.Metadata; obj = ((metadata != null) ? metadata.Version?.ToString() : null); } string version = (string)obj; list.Add(new ClientPluginFile(item.Key, version, Path.GetFullPath(text ?? string.Empty))); } return list.ToArray(); } } internal sealed class ClientPluginFile { internal string Id { get; } internal string Version { get; } internal string Path { get; } internal ClientPluginFile(string id, string version, string path) { Id = AdmissionValidation.RequireAtom(id, 1, 128, "id"); Version = AdmissionValidation.RequireAtom(version, 1, 64, "version"); if (string.IsNullOrWhiteSpace(path)) { throw new ArgumentException("Path is required.", "path"); } Path = System.IO.Path.GetFullPath(path); } } internal static class ClientProfileBuilder { private sealed class FileHash { internal string Digest { get; } internal long Length { get; } internal long WriteTicks { get; } internal FileHash(string digest, long length, long writeTicks) { Digest = digest; Length = length; WriteTicks = writeTicks; } } internal const long MaximumPluginBytes = 536870912L; internal const long MaximumTotalPluginBytes = 4294967296L; private static readonly StringComparer PathComparer = (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal); internal static bool TryBuild(IEnumerable source, Func unixTime, CancellationToken cancellation, out AdmissionClientProfile profile, out string failure) { profile = null; failure = string.Empty; try { if (source == null || unixTime == null) { failure = "profile-input-missing"; return false; } List list = new List(); foreach (ClientPluginFile item in source) { if (item == null) { failure = "profile-entry-null"; return false; } if (list.Count >= 512) { failure = "profile-plugin-cap"; return false; } list.Add(item); } List list2 = new List(list.Count); Dictionary dictionary = new Dictionary(PathComparer); byte[] buffer = new byte[65536]; long num = 0L; foreach (ClientPluginFile item2 in list) { cancellation.ThrowIfCancellationRequested(); string fullPath = Path.GetFullPath(item2.Path); if (!dictionary.TryGetValue(fullPath, out var value)) { FileInfo fileInfo = new FileInfo(fullPath); if (!fileInfo.Exists || fileInfo.Length <= 0 || fileInfo.Length > 536870912 || num > 4294967296L - fileInfo.Length) { failure = "profile-file-bound"; return false; } long length = fileInfo.Length; long ticks = fileInfo.LastWriteTimeUtc.Ticks; num += length; string digest = HashExact(fullPath, length, buffer, cancellation); fileInfo.Refresh(); if (!fileInfo.Exists || fileInfo.Length != length || fileInfo.LastWriteTimeUtc.Ticks != ticks) { failure = "profile-file-changed"; return false; } value = new FileHash(digest, length, ticks); dictionary.Add(fullPath, value); } list2.Add(new AdmissionPluginEvidence(item2.Id, item2.Version, value.Digest)); } long num2 = unixTime(); if (num2 < 0) { failure = "profile-clock"; return false; } return AdmissionProfileCanonicalizer.TryCreate(list2, num2, out profile, out failure); } catch (OperationCanceledException) { failure = "profile-cancelled"; return false; } catch (Exception) { failure = "profile-build-failed"; return false; } } private static string HashExact(string path, long expectedLength, byte[] buffer, CancellationToken cancellation) { using FileStream fileStream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, buffer.Length, FileOptions.SequentialScan); using SHA256 sHA = SHA256.Create(); if (fileStream.Length != expectedLength) { throw new IOException("Plugin changed before hashing."); } long num = expectedLength; while (num > 0) { cancellation.ThrowIfCancellationRequested(); int count = (int)Math.Min(buffer.Length, num); int num2 = fileStream.Read(buffer, 0, count); if (num2 <= 0) { throw new EndOfStreamException("Plugin ended during hashing."); } sHA.TransformBlock(buffer, 0, num2, buffer, 0); num -= num2; } if (fileStream.ReadByte() != -1 || fileStream.Length != expectedLength) { throw new IOException("Plugin grew during hashing."); } sHA.TransformFinalBlock(Array.Empty(), 0, 0); return AdmissionProfileCanonicalizer.Hex(sHA.Hash); } } [HarmonyPatch(typeof(ZNet), "OnNewConnection", new Type[] { typeof(ZNetPeer) })] internal static class ClientTransportPatches { [HarmonyPrefix] [HarmonyPriority(800)] private static void ZNetOnNewConnectionPrefix(ZNet __instance, [HarmonyArgument(0)] ZNetPeer peer) { try { Plugin.ActiveRuntime?.OnConnectionStarted(__instance, peer); } catch { } } internal static bool IsInstalled(string owner) { if (string.IsNullOrEmpty(owner)) { return false; } MethodInfo methodInfo = AccessTools.DeclaredMethod(typeof(ZNet), "OnNewConnection", new Type[1] { typeof(ZNetPeer) }, (Type[])null); return ((methodInfo == null) ? null : Harmony.GetPatchInfo((MethodBase)methodInfo))?.Prefixes.Any((Patch patch) => string.Equals(patch.owner, owner, StringComparison.Ordinal) && patch.priority == 800 && patch.PatchMethod?.DeclaringType == typeof(ClientTransportPatches)) ?? false; } } }