diff --git a/CLAUDE.md b/CLAUDE.md index 7df916f..7dfc536 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -96,34 +96,29 @@ The client verifies images against an RSA public key whose modulus is a string l `global-metadata.dat`. Patching that literal is fragile; **don't**. Hook the framework instead. The decisive observation: the literal base64-decodes to **exactly 256 bytes and does not start with -`0x30`**, so it is a *raw* 2048-bit modulus, not a DER/SPKI blob. The client therefore cannot hand it -to a key-import API — it has to base64-decode it and hand-build `RSAParameters { Modulus, Exponent }`. -Those are plain `mscorlib` calls with unobfuscated names. Confirmed working at runtime: images load -with no signing check. +`0x30`**, so it is a *raw* 2048-bit modulus, not a DER/SPKI blob. The client base64-decodes it and +hand-builds `RSAParameters`, then verifies with `mscorlib` RSA — plain unobfuscated names. Rather than +touch the modulus, we just force the verify to succeed. Confirmed working at runtime: images load with +no signing check. `Patches/ImageSigningPatch.cs` hooks, all in `Il2Cppmscorlib.dll`: | Hook | Purpose | | --- | --- | -| `Convert.FromBase64String` | catches the literal regardless of which crypto stack consumes it (guarded by a length check first — it runs for every base64 decode in the game) | -| `RSACryptoServiceProvider.ImportParameters` | swaps the modulus in place (both keys are 2048-bit, so no realloc) | -| `RSACryptoServiceProvider.VerifyData` / `VerifyHash` ×2 | forces the verify to succeed | +| `RSACryptoServiceProvider.VerifyData` / `VerifyHash` ×2 | **the fix:** forces the verify to succeed | -Config lives in `[Signing]`. `Disable Signature Verification` defaults **true** — that's the shipped -behaviour, since this setup doesn't use image signing. `Signing Modulus Override` is the secondary -path for a deployment that *does* want signed images (keeps real verification, against your keypair). +Config lives in `[Signing]`, a single knob: `Disable Signature Verification` defaults **true** — +that's the shipped behaviour, since this setup doesn't use image signing. The patch only *removes* the +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. -- **If it ever stops working:** the patch logs `[SIG] stock modulus seen at ` on first sighting. - No such line = verification moved to `BestHTTP.SecureProtocol.Org.BouncyCastle`; the equivalent - hooks there are `RsaKeyParameters..ctor(bool, BigInteger, BigInteger)` and the **concrete** - `RsaDigestSigner`/`PssSigner.VerifySignature` (not the abstract `ISigner` — gotcha 3). -- The stock modulus is hardcoded in the patch. If a future build re-rolls the key, the match silently - stops firing; that log line is how you'd notice. +- **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). ## Inspecting the game diff --git a/Patches/ImageSigningPatch.cs b/Patches/ImageSigningPatch.cs index a424a3e..68f5151 100644 --- a/Patches/ImageSigningPatch.cs +++ b/Patches/ImageSigningPatch.cs @@ -1,156 +1,27 @@ -using System; using HarmonyLib; using Il2CppInterop.Runtime.InteropTypes.Arrays; using Il2CppSystem.Security.Cryptography; -using Convert = Il2CppSystem.Convert; namespace RecNetPlugin.Patches; // Image signing: the client verifies images against an RSA public key whose modulus is a string // literal in global-metadata.dat. Patching that literal is fragile, so we intervene at the framework -// level instead, between the literal and the verify. +// level instead, by forcing the mscorlib RSA verify to succeed. // -// The stored value is 256 bytes once base64-decoded and does NOT start with 0x30, so it is a RAW -// 2048-bit modulus, not a DER/SPKI blob. The client therefore has to base64-decode it and hand-build -// an RSAParameters { Modulus, Exponent }. Both of those steps are plain framework calls with -// unobfuscated names, which is why hooking here survives game rebuilds (unlike the obfuscated -// PromisePatch this replaces). -// -// Two knobs, see [Signing] in the .cfg: +// One knob, see [Signing] in the .cfg: // Disable Signature Verification -> THE FIX (default true). Forces the RSA verify to succeed, so // the modulus never has to match and unsigned images load. This // self-hosted setup does not use image signing. -// Signing Modulus Override -> secondary: swap in your own modulus and keep real verification, -// for a deployment that DOES want signed images. Ignored (well, -// redundant) while the verify is disabled. // -// Both are belt-and-braces: we swap at Convert.FromBase64String (catches the value regardless of -// which crypto stack consumes it) AND at ImportParameters (catches it if the client uses some other -// base64 decoder). Whichever fires first wins; the second sees the already-swapped value and no-ops. -// -// If the log never shows "[SIG] stock modulus seen", the client is not using mscorlib RSA at all — -// the fallback is BestHTTP.SecureProtocol.Org.BouncyCastle, where the equivalent hooks are -// RsaKeyParameters..ctor(bool, BigInteger, BigInteger) and the concrete RsaDigestSigner/PssSigner -// .VerifySignature (NOT the ISigner "interface", which is abstract and never dispatches). +// If images ever stop loading with this on, the client has moved verification off mscorlib RSA onto +// BestHTTP.SecureProtocol.Org.BouncyCastle; the equivalent hooks there are the concrete +// RsaDigestSigner/PssSigner.VerifySignature (NOT the abstract ISigner "interface", which never +// dispatches). [HarmonyPatch] public static class ImageSigningPatch { - // The stock 2048-bit public modulus baked into global-metadata.dat (base64, 344 chars). - private const string StockModulusBase64 = - "X07yXkxaaLcZ1wVXfkWjgFkkqdoLhDFm0GPODsF+Q47pSUlbLvtXGqStnyEJEIrQmgDiicAvCdGRq4lovr2l5sIP" + - "MaoyizsbVHBdwLUrCsji0RvSBnmvN+8KqQ8STnB4DP4pAsPilfD35def4WuX/xMCXB5+hQUVhv27HPV8Dj9XzHuJ" + - "AijIM9UwDZmvUcECyiO4wv+TaZi2+ELBtaLCQR8Gm1ZPeDEwP62Ch6MJy0jx5pkvvD0KdF9Wye+3/Wx31Zn/Trdo" + - "9HL4sGFWPDM9H9kQhZd5wkTHuxpwGIIhlzIwvY2/pBGdZKP6fi1D2jROEmVkBDyhmYY9nO+s3/bndQ=="; - - private const int ModulusBytes = 256; - - private static byte[] _stockModulus; - private static byte[] _override; - private static bool _overrideResolved; - private static bool _loggedSeen; private static bool _loggedForced; - private static byte[] Stock => _stockModulus ??= System.Convert.FromBase64String(StockModulusBase64); - - // Decoded lazily and cached, so a malformed config value is reported once instead of per call. - private static byte[] Override - { - get - { - if (_overrideResolved) - return _override; - _overrideResolved = true; - - var raw = Plugin.SigningModulusOverride.Value; - if (string.IsNullOrWhiteSpace(raw)) - return _override = null; - - try - { - var bytes = System.Convert.FromBase64String(raw.Trim()); - if (bytes.Length != ModulusBytes) - { - Plugin.Log.LogError( - $"[SIG] 'Signing Modulus Override' decoded to {bytes.Length} bytes, expected {ModulusBytes} " + - "(a raw 2048-bit modulus). Ignoring it. Note this must be the bare modulus, NOT a PEM/DER key."); - return _override = null; - } - - Plugin.Log.LogWarning("[SIG] signing modulus override active"); - return _override = bytes; - } - catch (FormatException) - { - Plugin.Log.LogError("[SIG] 'Signing Modulus Override' is not valid base64. Ignoring it."); - return _override = null; - } - } - } - - private static bool IsStock(Il2CppStructArray value) - { - if (value == null || value.Length != ModulusBytes) - return false; - - var stock = Stock; - for (var i = 0; i < ModulusBytes; i++) - if (value[i] != stock[i]) - return false; - - return true; - } - - private static void NoteSeen(string where) - { - if (_loggedSeen) - return; - _loggedSeen = true; - Plugin.Log.LogWarning($"[SIG] stock modulus seen at {where}"); - } - - // Choke point 1: the base64 decode of the literal. Guarded by a length check first, because this - // runs for every base64 decode in the game. - [HarmonyPrefix] - [HarmonyPatch(typeof(Convert), nameof(Convert.FromBase64String))] - private static bool FromBase64StringPrefix(string __0, ref Il2CppStructArray __result) - { - if (__0 == null || __0.Length != StockModulusBase64.Length || __0 != StockModulusBase64) - return true; - - NoteSeen("Convert.FromBase64String"); - - var replacement = Override; - if (replacement == null) - return true; - - var arr = new Il2CppStructArray(ModulusBytes); - for (var i = 0; i < ModulusBytes; i++) - arr[i] = replacement[i]; - - __result = arr; - return false; - } - - // Choke point 2: the key import. Mutates the modulus in place — both are 2048-bit, so the array - // is already the right size and no reallocation is needed. - [HarmonyPrefix] - [HarmonyPatch(typeof(RSACryptoServiceProvider), nameof(RSACryptoServiceProvider.ImportParameters))] - private static void ImportParametersPrefix(RSAParameters __0) - { - var modulus = __0?.Modulus; - if (!IsStock(modulus)) - return; - - NoteSeen("RSACryptoServiceProvider.ImportParameters"); - - var replacement = Override; - if (replacement == null) - return; - - for (var i = 0; i < ModulusBytes; i++) - modulus[i] = replacement[i]; - } - // Forces EVERY mscorlib RSA verification to succeed, not just image signatures. BestHTTP's TLS // uses its own bundled BouncyCastle rather than mscorlib RSA, so this should not touch // certificate validation — but it is a blunt instrument, so it stays behind a config knob rather diff --git a/Plugin.cs b/Plugin.cs index 8bc0775..2cc86b4 100644 --- a/Plugin.cs +++ b/Plugin.cs @@ -29,7 +29,6 @@ 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 SigningModulusOverride { get; private set; } private static bool _corruptDone; @@ -52,8 +51,7 @@ 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. Set false only if you actually want signed images, in which case use 'Signing Modulus Override' instead. NOTE: this forces ALL mscorlib RSA verification to pass, not just image signatures."); - SigningModulusOverride = Config.Bind("Signing", "Signing Modulus Override", "", "Optional alternative to disabling verification: your own RSA public modulus, base64, RAW 2048-bit (256 bytes decoded) — NOT a PEM/DER key. When set, it is substituted for the modulus baked into global-metadata.dat and real verification still runs, so images stay signed with your keypair. Redundant while 'Disable Signature Verification' is true. Empty = leave the stock modulus alone."); + 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."); Harmony.CreateAndPatchAll(typeof(Plugin).Assembly); diff --git a/README.md b/README.md index 905a929..d0edb56 100644 --- a/README.md +++ b/README.md @@ -123,11 +123,7 @@ Inside `config`, edit the `net.rec.plugin.cfg` file and update as needed: **[Signing]** - `Disable Signature Verification` — stops the client checking that images are signed with Rec Room's - private key, so your own server can serve images. **On by default**; leave it alone unless you - specifically want signed images. -- `Signing Modulus Override` — only for setups that *do* want image signing: your own RSA public - modulus (base64, raw 2048-bit — not a PEM/DER key). Keeps real verification, against your keypair. - Leave empty otherwise. + private key, so your own server can serve images. **On by default**; leave it alone. **[Advanced]** - `Enabled Advanced Settings` — must be `true` to apply the custom Photon name server / port below.