using System; using System.Security.Cryptography; using System.Text; namespace Unity.Pipeline.CodeReload { /// Outcome of opening a signed push envelope. Everything except is a /// rejection; the receiver reports them generically on the wire and specifically in its own log. enum PushVerifyStatus { Ok, /// The receiver has no baked public key — every push is rejected (strict policy). NoKey, /// Structurally not a v1 envelope (bad magic, truncated, absurd lengths). Malformed, /// Envelope parses but the RSA signature does not verify under the baked key. BadSignature, /// Signature is valid but was made for a different session nonce (replay across /// sessions/devices, or the editor's nonce table is stale). NonceMismatch, /// Signature and nonce are valid but the counter did not increase (replay within /// the session, or out-of-order delivery). CounterNotIncreasing, /// The platform's RSA implementation threw — crypto is unusable here. Surfaced by /// the boot self-test; should never be the steady state. CryptoUnavailable, } /// /// Authenticated envelope for editor → player code-reload pushes, plus the project signing-key /// primitives. Pure BCL (System.Security.Cryptography, no Unity API) so it compiles on both /// sides and is unit-testable without a connection. /// /// Wire format (little-endian): /// [magic "UPP1" 4B][session nonce 16B][counter int64][payloadLen int32][payload][RSA signature] /// The signature is RSA-2048 / SHA-256 / PKCS#1 v1.5 over UTF8() followed /// by every envelope byte before the signature, so a signature can never be repurposed by a /// future feature signing other content with the same project key. /// /// Keys are serialized in a deliberately trivial private format (base64 RSAParameters fields) /// rather than XML/PKCS#8: RSA.To/FromXmlString and ImportPkcs8PrivateKey both have per-platform /// availability gaps across Unity scripting backends; base64ing the raw parameters has none. /// static class PushEnvelope { public const string DomainTag = "unity-pipeline/push-reload/v1"; public const int ProtocolVersion = 1; public const int NonceSize = 16; public const int RsaKeySizeBits = 2048; private const string PrivatePrefix = "upp1-rsa:"; private const string PublicPrefix = "upp1-rsa-pub:"; private const int HeaderSize = 4 + NonceSize + 8 + 4; // magic + nonce + counter + payloadLen private const int MaxPayload = 64 * 1024 * 1024; // sanity bound, far above any real push private static readonly byte[] Magic = { (byte)'U', (byte)'P', (byte)'P', (byte)'1' }; // ---- keys ---- /// Generate a fresh RSA-2048 private key (contains the public half). public static string CreatePrivateKey() { using (var rsa = RSA.Create()) { rsa.KeySize = RsaKeySizeBits; var p = rsa.ExportParameters(true); return PrivatePrefix + string.Join(".", B64(p.Modulus), B64(p.Exponent), B64(p.D), B64(p.P), B64(p.Q), B64(p.DP), B64(p.DQ), B64(p.InverseQ)); } } /// Extract the shippable public half of a private key. public static string DerivePublicKey(string privateKey) { var p = ParsePrivate(privateKey); return PublicPrefix + B64(p.Modulus) + "." + B64(p.Exponent); } public static bool IsPublicKey(string key) => !string.IsNullOrEmpty(key) && key.StartsWith(PublicPrefix, StringComparison.Ordinal); public static bool IsPrivateKey(string key) => !string.IsNullOrEmpty(key) && key.StartsWith(PrivatePrefix, StringComparison.Ordinal); /// Short stable id of a keypair (SHA-256 over modulus+exponent, first 8 bytes hex). /// Accepts the public or the private serialization and gives the same answer. public static string Fingerprint(string key) { RSAParameters p = IsPublicKey(key) ? ParsePublic(key) : ParsePrivate(key); using (var sha = SHA256.Create()) { var data = new byte[p.Modulus.Length + p.Exponent.Length]; Buffer.BlockCopy(p.Modulus, 0, data, 0, p.Modulus.Length); Buffer.BlockCopy(p.Exponent, 0, data, p.Modulus.Length, p.Exponent.Length); var hash = sha.ComputeHash(data); var sb = new StringBuilder(16); for (int i = 0; i < 8; i++) sb.Append(hash[i].ToString("x2")); return sb.ToString(); } } /// Cryptographically random session nonce. public static byte[] CreateNonce() { var nonce = new byte[NonceSize]; using (var rng = RandomNumberGenerator.Create()) rng.GetBytes(nonce); return nonce; } // ---- signing (editor side) ---- public static byte[] Sign(string privateKey, byte[] sessionNonce, long counter, byte[] payload) => Sign(privateKey, sessionNonce, counter, payload, DomainTag); /// Domain-tag overload: exists so tests can prove a signature made under a different /// tag is rejected. Production code always signs with . public static byte[] Sign(string privateKey, byte[] sessionNonce, long counter, byte[] payload, string domainTag) { if (sessionNonce == null || sessionNonce.Length != NonceSize) throw new ArgumentException($"session nonce must be {NonceSize} bytes", nameof(sessionNonce)); payload = payload ?? Array.Empty(); var preamble = new byte[HeaderSize + payload.Length]; Buffer.BlockCopy(Magic, 0, preamble, 0, 4); Buffer.BlockCopy(sessionNonce, 0, preamble, 4, NonceSize); WriteInt64(preamble, 4 + NonceSize, counter); WriteInt32(preamble, 4 + NonceSize + 8, payload.Length); Buffer.BlockCopy(payload, 0, preamble, HeaderSize, payload.Length); byte[] sig; using (var rsa = RSA.Create()) { rsa.ImportParameters(ParsePrivate(privateKey)); sig = rsa.SignData(Tagged(domainTag, preamble), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); } var envelope = new byte[preamble.Length + sig.Length]; Buffer.BlockCopy(preamble, 0, envelope, 0, preamble.Length); Buffer.BlockCopy(sig, 0, envelope, preamble.Length, sig.Length); return envelope; } // ---- opening (player side) ---- /// Cheap structural check: does this look like a v1 envelope (as opposed to a /// legacy raw payload from a pre-signing editor)? public static bool LooksLikeEnvelope(byte[] data) => data != null && data.Length >= HeaderSize && data[0] == Magic[0] && data[1] == Magic[1] && data[2] == Magic[2] && data[3] == Magic[3]; /// Parse without any crypto. Editor-play-mode strip only — never a security gate. public static bool TryParse(byte[] envelope, out byte[] nonce, out long counter, out byte[] payload) { nonce = null; counter = 0; payload = null; if (!LooksLikeEnvelope(envelope)) return false; nonce = new byte[NonceSize]; Buffer.BlockCopy(envelope, 4, nonce, 0, NonceSize); counter = ReadInt64(envelope, 4 + NonceSize); int payloadLen = ReadInt32(envelope, 4 + NonceSize + 8); if (payloadLen < 0 || payloadLen > MaxPayload || HeaderSize + payloadLen > envelope.Length) return false; payload = new byte[payloadLen]; Buffer.BlockCopy(envelope, HeaderSize, payload, 0, payloadLen); return true; } /// /// Full verification: structure, signature under , session nonce /// equality, strictly increasing counter. On , /// and are set. Stateless — callers own /// the last-counter bookkeeping (see ). /// public static PushVerifyStatus TryOpen(string publicKey, byte[] envelope, byte[] expectedNonce, long lastCounter, out long counter, out byte[] payload) { counter = 0; payload = null; if (string.IsNullOrEmpty(publicKey)) return PushVerifyStatus.NoKey; if (!LooksLikeEnvelope(envelope)) return PushVerifyStatus.Malformed; int payloadLen = ReadInt32(envelope, 4 + NonceSize + 8); if (payloadLen < 0 || payloadLen > MaxPayload) return PushVerifyStatus.Malformed; int preambleLen = HeaderSize + payloadLen; if (preambleLen >= envelope.Length) return PushVerifyStatus.Malformed; // signature must be non-empty // Signature first: nothing later is meaningful on unauthenticated bytes. try { var preamble = new byte[preambleLen]; Buffer.BlockCopy(envelope, 0, preamble, 0, preambleLen); var sig = new byte[envelope.Length - preambleLen]; Buffer.BlockCopy(envelope, preambleLen, sig, 0, sig.Length); using (var rsa = RSA.Create()) { rsa.ImportParameters(ParsePublic(publicKey)); if (!rsa.VerifyData(Tagged(DomainTag, preamble), sig, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1)) return PushVerifyStatus.BadSignature; } } catch (FormatException) { return PushVerifyStatus.Malformed; } // corrupt key string catch (Exception) { return PushVerifyStatus.CryptoUnavailable; } if (expectedNonce == null || expectedNonce.Length != NonceSize) return PushVerifyStatus.NonceMismatch; // Constant-time not required (the nonce is not secret; it is a liveness binding), but // the full compare is trivial anyway. int diff = 0; for (int i = 0; i < NonceSize; i++) diff |= envelope[4 + i] ^ expectedNonce[i]; if (diff != 0) return PushVerifyStatus.NonceMismatch; long c = ReadInt64(envelope, 4 + NonceSize); if (c <= lastCounter) return PushVerifyStatus.CounterNotIncreasing; counter = c; payload = new byte[payloadLen]; Buffer.BlockCopy(envelope, HeaderSize, payload, 0, payloadLen); return PushVerifyStatus.Ok; } /// /// Boot-time probe that the platform can run the verify path at all (RSA import + VerifyData /// on a garbage signature must return false, not throw). Catches an IL2CPP/platform crypto /// gap on day one instead of as mysterious per-push rejects. /// public static bool VerifySelfTest(string publicKey, out string error) { error = null; try { using (var rsa = RSA.Create()) { rsa.ImportParameters(ParsePublic(publicKey)); var garbageSig = new byte[RsaKeySizeBits / 8]; if (rsa.VerifyData(new byte[] { 1, 2, 3 }, garbageSig, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1)) { error = "garbage signature verified — RSA implementation is broken here"; return false; } } return true; } catch (Exception ex) { error = ex.GetType().Name + ": " + ex.Message; return false; } } // ---- internals ---- private static byte[] Tagged(string domainTag, byte[] preamble) { var tag = Encoding.UTF8.GetBytes(domainTag ?? ""); var signed = new byte[tag.Length + preamble.Length]; Buffer.BlockCopy(tag, 0, signed, 0, tag.Length); Buffer.BlockCopy(preamble, 0, signed, tag.Length, preamble.Length); return signed; } private static RSAParameters ParsePrivate(string key) { if (!IsPrivateKey(key)) throw new FormatException("not a " + PrivatePrefix + " private key"); var f = key.Substring(PrivatePrefix.Length).Split('.'); if (f.Length != 8) throw new FormatException("private key must have 8 fields"); return new RSAParameters { Modulus = D64(f[0]), Exponent = D64(f[1]), D = D64(f[2]), P = D64(f[3]), Q = D64(f[4]), DP = D64(f[5]), DQ = D64(f[6]), InverseQ = D64(f[7]), }; } private static RSAParameters ParsePublic(string key) { if (!IsPublicKey(key)) throw new FormatException("not a " + PublicPrefix + " public key"); var f = key.Substring(PublicPrefix.Length).Split('.'); if (f.Length != 2) throw new FormatException("public key must have 2 fields"); return new RSAParameters { Modulus = D64(f[0]), Exponent = D64(f[1]) }; } private static string B64(byte[] b) => Convert.ToBase64String(b ?? Array.Empty()); private static byte[] D64(string s) => Convert.FromBase64String(s); private static void WriteInt32(byte[] buf, int off, int v) { buf[off] = (byte)v; buf[off + 1] = (byte)(v >> 8); buf[off + 2] = (byte)(v >> 16); buf[off + 3] = (byte)(v >> 24); } private static void WriteInt64(byte[] buf, int off, long v) { for (int i = 0; i < 8; i++) buf[off + i] = (byte)(v >> (8 * i)); } private static int ReadInt32(byte[] buf, int off) => buf[off] | (buf[off + 1] << 8) | (buf[off + 2] << 16) | (buf[off + 3] << 24); private static long ReadInt64(byte[] buf, int off) { long v = 0; for (int i = 0; i < 8; i++) v |= (long)buf[off + i] << (8 * i); return v; } } /// /// The player-side gate: one per receiver session. Owns the session nonce and the last-accepted /// counter, so is the single call the receiver makes per push. /// sealed class PushVerifier { private readonly string m_PublicKey; private readonly byte[] m_SessionNonce; private long m_LastCounter; public PushVerifier(string publicKey, byte[] sessionNonce) { m_PublicKey = publicKey; m_SessionNonce = sessionNonce; } public byte[] SessionNonce => m_SessionNonce; public PushVerifyStatus Verify(byte[] envelope, out byte[] payload) { var status = PushEnvelope.TryOpen(m_PublicKey, envelope, m_SessionNonce, m_LastCounter, out var counter, out payload); if (status == PushVerifyStatus.Ok) m_LastCounter = counter; return status; } } }