Initial commit: AIGame Unity client with URP, HybridCLR, YooAsset and tooling.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,336 @@
|
||||
using System;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace Unity.Pipeline.CodeReload
|
||||
{
|
||||
/// <summary>Outcome of opening a signed push envelope. Everything except <see cref="Ok"/> is a
|
||||
/// rejection; the receiver reports them generically on the wire and specifically in its own log.</summary>
|
||||
enum PushVerifyStatus
|
||||
{
|
||||
Ok,
|
||||
/// <summary>The receiver has no baked public key — every push is rejected (strict policy).</summary>
|
||||
NoKey,
|
||||
/// <summary>Structurally not a v1 envelope (bad magic, truncated, absurd lengths).</summary>
|
||||
Malformed,
|
||||
/// <summary>Envelope parses but the RSA signature does not verify under the baked key.</summary>
|
||||
BadSignature,
|
||||
/// <summary>Signature is valid but was made for a different session nonce (replay across
|
||||
/// sessions/devices, or the editor's nonce table is stale).</summary>
|
||||
NonceMismatch,
|
||||
/// <summary>Signature and nonce are valid but the counter did not increase (replay within
|
||||
/// the session, or out-of-order delivery).</summary>
|
||||
CounterNotIncreasing,
|
||||
/// <summary>The platform's RSA implementation threw — crypto is unusable here. Surfaced by
|
||||
/// the boot self-test; should never be the steady state.</summary>
|
||||
CryptoUnavailable,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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(<see cref="DomainTag"/>) 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.
|
||||
/// </summary>
|
||||
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 ----
|
||||
|
||||
/// <summary>Generate a fresh RSA-2048 private key (contains the public half).</summary>
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Extract the shippable public half of a private key.</summary>
|
||||
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);
|
||||
|
||||
/// <summary>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.</summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Cryptographically random session nonce.</summary>
|
||||
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);
|
||||
|
||||
/// <summary>Domain-tag overload: exists so tests can prove a signature made under a different
|
||||
/// tag is rejected. Production code always signs with <see cref="DomainTag"/>.</summary>
|
||||
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<byte>();
|
||||
|
||||
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) ----
|
||||
|
||||
/// <summary>Cheap structural check: does this look like a v1 envelope (as opposed to a
|
||||
/// legacy raw payload from a pre-signing editor)?</summary>
|
||||
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];
|
||||
|
||||
/// <summary>Parse without any crypto. Editor-play-mode strip only — never a security gate.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Full verification: structure, signature under <paramref name="publicKey"/>, session nonce
|
||||
/// equality, strictly increasing counter. On <see cref="PushVerifyStatus.Ok"/>,
|
||||
/// <paramref name="payload"/> and <paramref name="counter"/> are set. Stateless — callers own
|
||||
/// the last-counter bookkeeping (see <see cref="PushVerifier"/>).
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<byte>());
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The player-side gate: one per receiver session. Owns the session nonce and the last-accepted
|
||||
/// counter, so <see cref="Verify"/> is the single call the receiver makes per push.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user