diff --git a/CLAUDE.md b/CLAUDE.md index 6e2ddee..a92db45 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -113,9 +113,10 @@ check (forces verify-true); it does not swap in a replacement key. Two things to remember: -- **Blast radius:** forcing verify-true affects *all* mscorlib RSA verification, not just images. - BestHTTP's TLS uses its own bundled BouncyCastle, so cert validation appears unaffected — inferred - from assembly layout, not proven. Keep it behind the config knob. +- **Blast radius is the point, not a wart.** Forcing verify-true affects *all* mscorlib RSA + verification, not just images. That breadth is load-bearing — see the warning below. BestHTTP's TLS + uses its own bundled BouncyCastle, so cert validation appears unaffected — inferred from assembly + layout, not proven. Keep it behind the config knob. - **If it ever stops working:** images failing to load with the knob on means verification moved off mscorlib RSA onto `BestHTTP.SecureProtocol.Org.BouncyCastle`; the equivalent hooks there are the **concrete** `RsaDigestSigner`/`PssSigner.VerifySignature` (not the abstract `ISigner` — gotcha 3). diff --git a/Patches/AmplitudePatch.cs b/Patches/AmplitudePatch.cs new file mode 100644 index 0000000..832b8cb --- /dev/null +++ b/Patches/AmplitudePatch.cs @@ -0,0 +1,119 @@ +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using HarmonyLib; + +namespace RecNetPlugin.Patches; + +// Amplitude analytics: the client ships telemetry to Amplitude, which a self-hosted setup has no use +// for (and which leaks play data off-box). Prefix every event-logging entrypoint and swallow the call +// so nothing is ever queued, batched or sent. +// +// One knob, see [Analytics] in the .cfg: +// Disable Amplitude Analytics -> default true; skip every AmplitudeAnalyticsClient.Log* method. +// +// Target resolution: AmplitudeAnalytics.AmplitudeAnalyticsClient in RecRoom.Analytics.Runtime.dll, +// concrete (it derives from SingletonMonoBehaviour, so there is no abstract-interface dispatch +// trap here — see gotcha 3 in CLAUDE.md). All five Log* names are UNobfuscated in the 20230414 build. +// They are still strings, so a rename shows up only as a HarmonyX "Could not find method" in +// LogOutput.log, not as a build error — the per-method [AMPLITUDE] blocked log line below is the real +// proof a hook is live. +// +// Why all five: blocking LogEventAsync alone was not enough — a session's room_stats/perf_stats +// events still went out, and the LogEventAsync prefix never logged at all, so that entrypoint was +// never even called. Those events are the pre-serialized batch the client parks in the +// `pending_room_stats` PlayerPref (visible in the DUID-PROBE log), which points at +// LogSerializedEventAsync rather than LogEventAsync. +[HarmonyPatch] +public static class AmplitudePatch +{ + private static readonly HashSet _loggedBlocked = new(); + + // Returns false to skip the original. Logs once per entrypoint so LogOutput.log shows which door + // the client actually used. + private static bool Block(string entrypoint) + { + if (_loggedBlocked.Add(entrypoint)) + Plugin.Log.LogInfo($"[AMPLITUDE] analytics disabled — blocked {entrypoint}"); + + return false; + } + + [HarmonyPrefix] + [HarmonyPatch(typeof(AmplitudeAnalytics.AmplitudeAnalyticsClient), "LogEventAsync")] + private static bool LogEventAsyncPrefix() => + Plugin.DisableAmplitudeAnalytics.Value && Block("LogEventAsync"); + + [HarmonyPrefix] + [HarmonyPatch(typeof(AmplitudeAnalytics.AmplitudeAnalyticsClient), "LogPrevSessionEventAsync")] + private static bool LogPrevSessionEventAsyncPrefix() => + Plugin.DisableAmplitudeAnalytics.Value && Block("LogPrevSessionEventAsync"); + + // The likely culprit for the room_stats/perf_stats batch — takes the already-serialized + // Dictionary that gets parked in `pending_room_stats`. + [HarmonyPrefix] + [HarmonyPatch(typeof(AmplitudeAnalytics.AmplitudeAnalyticsClient), "LogSerializedEventAsync")] + private static bool LogSerializedEventAsyncPrefix() => + Plugin.DisableAmplitudeAnalytics.Value && Block("LogSerializedEventAsync"); + + [HarmonyPrefix] + [HarmonyPatch(typeof(AmplitudeAnalytics.AmplitudeAnalyticsClient), "LogIdentifyAsync")] + private static bool LogIdentifyAsyncPrefix() => + Plugin.DisableAmplitudeAnalytics.Value && Block("LogIdentifyAsync"); + + // The odd one out: static, and it returns a promise instead of void. Skipping it with a null + // __result would hand the caller something it will chain .Then() on, so we substitute an + // already-resolved promise — the call looks like it succeeded instantly. If we cannot build one, + // we let the original run rather than risk a null-deref at quit time. + [HarmonyPrefix] + [HarmonyPatch(typeof(AmplitudeAnalytics.AmplitudeAnalyticsClient), "LogOutOfSessionEvent")] + private static bool LogOutOfSessionEventPrefix(ref LAHBDKNMNHN __result) + { + if (!Plugin.DisableAmplitudeAnalytics.Value) + return true; + + var resolved = ResolvedPromise(); + if (resolved == null) + return true; + + __result = resolved; + return Block("LogOutOfSessionEvent"); + } + + private static bool _promiseResolved; + private static MethodInfo _resolvedPromiseGetter; + + // Finds the concrete Promise class's static `Resolved` property getter without hardcoding its + // obfuscated name. Among the static, 0-param property getters in RecRoom.Promises.Runtime that + // return the promise interface there are exactly two: the real (obfuscated) property getter and + // the compiler-generated `get__k__BackingField`. The backing field may be null if the + // property initialises lazily, so we drop it by its compiler-generated name — which the + // obfuscator leaves alone — and keep the other one. + private static LAHBDKNMNHN ResolvedPromise() + { + if (!_promiseResolved) + { + _promiseResolved = true; + + var candidates = typeof(LAHBDKNMNHN).Assembly.GetTypes() + .SelectMany(t => t.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static)) + .Where(m => m.ReturnType == typeof(LAHBDKNMNHN) + && m.GetParameters().Length == 0 + && m.IsSpecialName + && !m.Name.EndsWith("_k__BackingField")) + .ToList(); + + if (candidates.Count == 1) + _resolvedPromiseGetter = candidates[0]; + else + Plugin.Log.LogWarning( + $"[AMPLITUDE] expected exactly one resolved-promise getter, found {candidates.Count} " + + "— LogOutOfSessionEvent will NOT be blocked"); + } + + if (_resolvedPromiseGetter == null) + return null; + + return _resolvedPromiseGetter.Invoke(null, null) as LAHBDKNMNHN; + } +} diff --git a/Plugin.cs b/Plugin.cs index 2cc86b4..8cfeae3 100644 --- a/Plugin.cs +++ b/Plugin.cs @@ -29,6 +29,7 @@ public class Plugin : BasePlugin public static ConfigEntry DeviceIdResponseOverride { get; private set; } public static ConfigEntry DeviceIdResponseStatus { get; private set; } public static ConfigEntry DisableSignatureVerification { get; private set; } + public static ConfigEntry DisableAmplitudeAnalytics { get; private set; } private static bool _corruptDone; @@ -51,7 +52,9 @@ public class Plugin : BasePlugin DeviceIdResponseOverride = Config.Bind("Advanced", "DeviceId Response Override", "", "Replace the body of the PlayerReporting/v1/deviceId response with this text, to test what shape the client will accept. Empty = leave the server's response alone."); DeviceIdResponseStatus = Config.Bind("Advanced", "DeviceId Response Status", 200, "HTTP status to force on the PlayerReporting/v1/deviceId response. Only applies when the override body is set."); - DisableSignatureVerification = Config.Bind("Signing", "Disable Signature Verification", true, "Force RSA signature verification to succeed (ON by default), so the client stops checking that images are signed with Rec Room's private key. This is what lets a self-hosted server serve its own images without the baked-in modulus matching. NOTE: this forces ALL mscorlib RSA verification to pass, not just image signatures."); + DisableSignatureVerification = Config.Bind("Signing", "Disable Signature Verification", true, "Force RSA signature verification to succeed (ON by default), so the client stops checking that images are signed with Rec Room's private key. This is what lets a self-hosted server serve its own images without the baked-in modulus matching. NOTE: this forces ALL mscorlib RSA verification to pass, not just image signatures — that breadth is deliberate, see CLAUDE.md."); + + DisableAmplitudeAnalytics = Config.Bind("Analytics", "Disable Amplitude Analytics", true, "Drop every AmplitudeAnalyticsClient.Log* call (ON by default) — LogEventAsync, LogPrevSessionEventAsync, LogSerializedEventAsync, LogIdentifyAsync and LogOutOfSessionEvent — so the client never ships telemetry events to Amplitude. A self-hosted server has no use for them. Set false to let analytics through."); Harmony.CreateAndPatchAll(typeof(Plugin).Assembly);