chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:49:17 +08:00
commit 7243d5823b
2201 changed files with 257291 additions and 0 deletions
@@ -0,0 +1,220 @@
using System;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using UnityEngine;
namespace MCPForUnity.Editor.Security
{
/// <summary>
/// Cross-platform fallback key store used when no OS secret service is available
/// (e.g. Linux without libsecret, or headless CI). Encrypts each key at rest with
/// AES-256-CBC + HMAC-SHA256 (encrypt-then-MAC). The derivation passphrase is a
/// per-user random master secret (persisted once, 0600) combined with a machine id,
/// run through PBKDF2. Ciphertext lives under the user app-data dir — never under the
/// repo / Assets. This is weaker than an OS keychain (the master secret sits on disk)
/// but far better than plaintext, and it never leaks keys to git or the bridge.
/// </summary>
internal sealed class EncryptedFileKeyStore : ISecureKeyStore
{
private const int Iterations = 200_000;
private readonly string _dir;
public EncryptedFileKeyStore() : this(DefaultDir()) { }
/// <summary>Test seam: point the store at a throwaway directory.</summary>
internal EncryptedFileKeyStore(string storageDir)
{
_dir = storageDir;
Directory.CreateDirectory(_dir);
TryChmod(_dir, "700");
}
internal static string DefaultDir()
{
string appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
if (string.IsNullOrEmpty(appData))
{
string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
appData = Path.Combine(home, ".config");
}
return Path.Combine(appData, "MCPForUnity", "AssetGenKeys");
}
private string KeyFile(string providerId) => Path.Combine(_dir, "key_" + providerId + ".bin");
public bool Has(string providerId)
=> !string.IsNullOrEmpty(providerId) && File.Exists(KeyFile(providerId));
public bool TryGet(string providerId, out string apiKey)
{
apiKey = null;
if (string.IsNullOrEmpty(providerId)) return false;
string path = KeyFile(providerId);
if (!File.Exists(path)) return false;
try
{
byte[] blob = Convert.FromBase64String(File.ReadAllText(path).Trim());
apiKey = Encoding.UTF8.GetString(Decrypt(blob));
return true;
}
catch
{
return false;
}
}
public void Set(string providerId, string apiKey)
{
if (string.IsNullOrEmpty(providerId)) return;
if (string.IsNullOrEmpty(apiKey)) { Delete(providerId); return; }
byte[] blob = Encrypt(Encoding.UTF8.GetBytes(apiKey));
string path = KeyFile(providerId);
File.WriteAllText(path, Convert.ToBase64String(blob));
TryChmod(path, "600");
}
public void Delete(string providerId)
{
if (string.IsNullOrEmpty(providerId)) return;
try
{
string path = KeyFile(providerId);
if (File.Exists(path)) File.Delete(path);
}
catch { /* best effort */ }
}
// ---------- crypto ----------
private void DeriveKeys(out byte[] encKey, out byte[] macKey)
{
byte[] master = LoadOrCreate(Path.Combine(_dir, "secret.bin"), 32);
byte[] salt = LoadOrCreate(Path.Combine(_dir, "salt.bin"), 16);
string password = Convert.ToBase64String(master) + "|" + MachineId();
using (var kdf = new Rfc2898DeriveBytes(password, salt, Iterations, HashAlgorithmName.SHA256))
{
byte[] material = kdf.GetBytes(64);
encKey = new byte[32];
macKey = new byte[32];
Buffer.BlockCopy(material, 0, encKey, 0, 32);
Buffer.BlockCopy(material, 32, macKey, 0, 32);
}
}
private byte[] Encrypt(byte[] plaintext)
{
DeriveKeys(out byte[] encKey, out byte[] macKey);
byte[] iv = RandomBytes(16);
byte[] ct;
using (var aes = Aes.Create())
{
aes.Key = encKey; aes.IV = iv; aes.Mode = CipherMode.CBC; aes.Padding = PaddingMode.PKCS7;
using (var enc = aes.CreateEncryptor())
ct = enc.TransformFinalBlock(plaintext, 0, plaintext.Length);
}
byte[] mac;
using (var h = new HMACSHA256(macKey))
mac = h.ComputeHash(Concat(iv, ct));
return Concat(iv, mac, ct);
}
private byte[] Decrypt(byte[] blob)
{
if (blob.Length < 48) throw new CryptographicException("ciphertext too short");
DeriveKeys(out byte[] encKey, out byte[] macKey);
byte[] iv = new byte[16];
byte[] mac = new byte[32];
byte[] ct = new byte[blob.Length - 48];
Buffer.BlockCopy(blob, 0, iv, 0, 16);
Buffer.BlockCopy(blob, 16, mac, 0, 32);
Buffer.BlockCopy(blob, 48, ct, 0, ct.Length);
using (var h = new HMACSHA256(macKey))
{
byte[] expected = h.ComputeHash(Concat(iv, ct));
if (!FixedTimeEquals(expected, mac))
throw new CryptographicException("MAC verification failed");
}
using (var aes = Aes.Create())
{
aes.Key = encKey; aes.IV = iv; aes.Mode = CipherMode.CBC; aes.Padding = PaddingMode.PKCS7;
using (var dec = aes.CreateDecryptor())
return dec.TransformFinalBlock(ct, 0, ct.Length);
}
}
// ---------- helpers ----------
private byte[] LoadOrCreate(string path, int len)
{
if (File.Exists(path))
{
byte[] existing = File.ReadAllBytes(path);
if (existing.Length == len) return existing;
}
byte[] fresh = RandomBytes(len);
File.WriteAllBytes(path, fresh);
TryChmod(path, "600");
return fresh;
}
private static string MachineId()
{
try
{
string id = SystemInfo.deviceUniqueIdentifier;
if (!string.IsNullOrEmpty(id) && id != SystemInfo.unsupportedIdentifier) return id;
}
catch { /* not in a Unity runtime context */ }
return Environment.MachineName ?? "unknown-machine";
}
private static byte[] RandomBytes(int n)
{
byte[] b = new byte[n];
using (var rng = RandomNumberGenerator.Create()) rng.GetBytes(b);
return b;
}
private static byte[] Concat(params byte[][] parts)
{
int total = 0;
foreach (byte[] p in parts) total += p.Length;
byte[] result = new byte[total];
int offset = 0;
foreach (byte[] p in parts)
{
Buffer.BlockCopy(p, 0, result, offset, p.Length);
offset += p.Length;
}
return result;
}
private static bool FixedTimeEquals(byte[] a, byte[] b)
{
if (a == null || b == null || a.Length != b.Length) return false;
int diff = 0;
for (int i = 0; i < a.Length; i++) diff |= a[i] ^ b[i];
return diff == 0;
}
private static void TryChmod(string path, string mode)
{
try
{
if (Application.platform == RuntimePlatform.WindowsEditor) return;
var psi = new System.Diagnostics.ProcessStartInfo("/bin/chmod")
{
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardError = true,
RedirectStandardOutput = true,
};
psi.ArgumentList.Add(mode);
psi.ArgumentList.Add(path);
using (var p = System.Diagnostics.Process.Start(psi)) p?.WaitForExit(2000);
}
catch { /* hardening is best-effort */ }
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: be82d82467544ffa8bbc8c64a8020636
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,26 @@
using System;
namespace MCPForUnity.Editor.Security
{
/// <summary>
/// Read-only environment-variable override for provider keys, intended for CI/headless
/// and power users. Resolution order is env → secure store (see <see cref="SecureKeyStore"/>).
/// Env values are never written back to any store.
/// </summary>
internal static class EnvKeyOverride
{
/// <summary>e.g. "tripo" → "MCPFORUNITY_TRIPO_API_KEY".</summary>
internal static string EnvVarName(string providerId)
=> "MCPFORUNITY_" + (providerId ?? string.Empty).ToUpperInvariant() + "_API_KEY";
internal static bool TryGet(string providerId, out string apiKey)
{
apiKey = null;
if (string.IsNullOrEmpty(providerId)) return false;
string v = Environment.GetEnvironmentVariable(EnvVarName(providerId));
if (string.IsNullOrEmpty(v)) return false;
apiKey = v;
return true;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 12d89d2f3dd846d3975bd5beb190a741
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,25 @@
namespace MCPForUnity.Editor.Security
{
/// <summary>
/// At-rest storage for AI asset-generation provider API keys.
///
/// Keys are written ONLY to the OS secure store (Keychain / Credential Manager /
/// libsecret) or an encrypted-file fallback. They are never placed in EditorPrefs,
/// never sent over the MCP bridge, never returned by any MCP tool, and never logged.
/// <see cref="Has"/> reports existence only — there is no API that returns all keys.
/// </summary>
public interface ISecureKeyStore
{
/// <summary>Resolve the key for a provider id (e.g. "tripo"). Returns false when absent.</summary>
bool TryGet(string providerId, out string apiKey);
/// <summary>Store (or overwrite) the key for a provider id. Empty/null deletes it.</summary>
void Set(string providerId, string apiKey);
/// <summary>Remove the stored key for a provider id (no-op when absent).</summary>
void Delete(string providerId);
/// <summary>True when a key exists for the provider id. Never returns the value.</summary>
bool Has(string providerId);
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: fc35b98fa68548c5ab34badae87b99d3
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,103 @@
using System;
using System.Diagnostics;
namespace MCPForUnity.Editor.Security
{
/// <summary>
/// Linux libsecret-backed key store via the `secret-tool` CLI. The secret value is
/// passed on stdin (never as a process argument). Falls back to
/// <see cref="EncryptedFileKeyStore"/> when secret-tool is not installed.
/// </summary>
internal sealed class LinuxSecretToolKeyStore : ISecureKeyStore
{
private const string Service = SecureKeyStoreConstants.ServiceName;
internal static bool IsAvailable()
{
try
{
var psi = NewPsi();
psi.ArgumentList.Add("--version");
using (var p = Process.Start(psi))
{
p.WaitForExit(2000);
return p.ExitCode == 0;
}
}
catch { return false; }
}
public bool Has(string providerId) => TryGet(providerId, out _);
public bool TryGet(string providerId, out string apiKey)
{
apiKey = null;
if (string.IsNullOrEmpty(providerId)) return false;
try
{
var psi = NewPsi();
psi.ArgumentList.Add("lookup");
psi.ArgumentList.Add("service"); psi.ArgumentList.Add(Service);
psi.ArgumentList.Add("account"); psi.ArgumentList.Add(providerId);
using (var p = Process.Start(psi))
{
string outp = p.StandardOutput.ReadToEnd();
p.WaitForExit(5000);
if (p.ExitCode != 0) return false;
apiKey = (outp ?? string.Empty).TrimEnd('\n', '\r');
return !string.IsNullOrEmpty(apiKey);
}
}
catch { return false; }
}
public void Set(string providerId, string apiKey)
{
if (string.IsNullOrEmpty(providerId)) return;
if (string.IsNullOrEmpty(apiKey)) { Delete(providerId); return; }
try
{
var psi = NewPsi(redirectIn: true);
psi.ArgumentList.Add("store");
psi.ArgumentList.Add("--label=MCPForUnity AssetGen");
psi.ArgumentList.Add("service"); psi.ArgumentList.Add(Service);
psi.ArgumentList.Add("account"); psi.ArgumentList.Add(providerId);
using (var p = Process.Start(psi))
{
p.StandardInput.Write(apiKey);
p.StandardInput.Close();
p.WaitForExit(5000);
}
}
catch { /* best effort */ }
}
public void Delete(string providerId)
{
if (string.IsNullOrEmpty(providerId)) return;
try
{
var psi = NewPsi();
psi.ArgumentList.Add("clear");
psi.ArgumentList.Add("service"); psi.ArgumentList.Add(Service);
psi.ArgumentList.Add("account"); psi.ArgumentList.Add(providerId);
using (var p = Process.Start(psi)) p.WaitForExit(5000);
}
catch { /* best effort */ }
}
private static ProcessStartInfo NewPsi(bool redirectIn = false)
{
var psi = new ProcessStartInfo("/usr/bin/env")
{
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
RedirectStandardInput = redirectIn,
};
psi.ArgumentList.Add("secret-tool");
return psi;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: eddf52a01df445629b408e5a7593356e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,67 @@
using System;
using System.Diagnostics;
namespace MCPForUnity.Editor.Security
{
/// <summary>
/// macOS Keychain-backed key store via /usr/bin/security generic passwords.
/// Service = MCPForUnity.AssetGen, account = provider id.
/// </summary>
internal sealed class MacKeychainKeyStore : ISecureKeyStore
{
private const string Security = "/usr/bin/security";
private const string Service = SecureKeyStoreConstants.ServiceName;
public bool Has(string providerId) => TryGet(providerId, out _);
public bool TryGet(string providerId, out string apiKey)
{
apiKey = null;
if (string.IsNullOrEmpty(providerId)) return false;
(int code, string stdout, _) = Run("find-generic-password", "-s", Service, "-a", providerId, "-w");
if (code != 0) return false;
apiKey = (stdout ?? string.Empty).TrimEnd('\n', '\r');
return !string.IsNullOrEmpty(apiKey);
}
public void Set(string providerId, string apiKey)
{
if (string.IsNullOrEmpty(providerId)) return;
if (string.IsNullOrEmpty(apiKey)) { Delete(providerId); return; }
// -U overwrites an existing item for this service/account.
Run("add-generic-password", "-U", "-s", Service, "-a", providerId, "-w", apiKey);
}
public void Delete(string providerId)
{
if (string.IsNullOrEmpty(providerId)) return;
Run("delete-generic-password", "-s", Service, "-a", providerId);
}
private static (int code, string stdout, string stderr) Run(params string[] args)
{
try
{
var psi = new ProcessStartInfo(Security)
{
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
};
foreach (string a in args) psi.ArgumentList.Add(a);
using (var p = Process.Start(psi))
{
string outp = p.StandardOutput.ReadToEnd();
string err = p.StandardError.ReadToEnd();
p.WaitForExit(5000);
return (p.ExitCode, outp, err);
}
}
catch (Exception e)
{
return (-1, null, e.Message);
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: fca6d32a6be24c138e8c32ae824602f1
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,36 @@
using System.Text.RegularExpressions;
namespace MCPForUnity.Editor.Security
{
/// <summary>
/// Scrubs secrets out of text before it is logged or returned. Use on every error/log
/// path that might contain an auth header or a key value.
/// </summary>
public static class SecretRedactor
{
private const string Mask = "***REDACTED***";
// Authorization-scheme tokens: "Bearer xxx", "Key xxx", "Token xxx".
private static readonly Regex SchemeToken = new Regex(
@"\b(Bearer|Key|Token)\s+\S{6,}",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
/// <summary>Redact auth-scheme tokens from arbitrary text (cheap; no store reads).</summary>
public static string Scrub(string text)
{
if (string.IsNullOrEmpty(text)) return text;
return SchemeToken.Replace(text, m => m.Groups[1].Value + " " + Mask);
}
/// <summary>Redact a specific known secret value as well as auth-scheme tokens.</summary>
public static string Scrub(string text, string secret)
{
if (string.IsNullOrEmpty(text)) return text;
if (!string.IsNullOrEmpty(secret) && secret.Length >= 4)
{
text = text.Replace(secret, Mask);
}
return Scrub(text);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 08d3717562bc41cca65e51e8eb165084
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,65 @@
using UnityEngine;
namespace MCPForUnity.Editor.Security
{
/// <summary>
/// Entry point for at-rest provider key storage. Selects the best OS secure store for
/// the current platform and layers the read-only environment-variable override on top.
/// Keys never transit the MCP bridge; only the C# editor side reads them, at call time.
/// </summary>
public static class SecureKeyStore
{
private static ISecureKeyStore _current;
public static ISecureKeyStore Current => _current ??= Build();
/// <summary>Test seam: substitute an in-memory or temp-dir store.</summary>
internal static void OverrideForTests(ISecureKeyStore store) => _current = store;
/// <summary>Test seam: clear the cached instance.</summary>
internal static void ResetForTests() => _current = null;
private static ISecureKeyStore Build() => new EnvOverlayKeyStore(SelectPlatform());
private static ISecureKeyStore SelectPlatform()
{
switch (Application.platform)
{
case RuntimePlatform.OSXEditor:
return new MacKeychainKeyStore();
case RuntimePlatform.WindowsEditor:
return new WindowsCredentialKeyStore();
case RuntimePlatform.LinuxEditor:
return LinuxSecretToolKeyStore.IsAvailable()
? (ISecureKeyStore)new LinuxSecretToolKeyStore()
: new EncryptedFileKeyStore();
default:
return new EncryptedFileKeyStore();
}
}
}
/// <summary>
/// Wraps a platform store so an <c>MCPFORUNITY_&lt;PROVIDER&gt;_API_KEY</c> environment
/// variable takes precedence on reads. Writes always go to the underlying store.
/// </summary>
internal sealed class EnvOverlayKeyStore : ISecureKeyStore
{
private readonly ISecureKeyStore _inner;
public EnvOverlayKeyStore(ISecureKeyStore inner) { _inner = inner; }
public bool TryGet(string providerId, out string apiKey)
{
if (EnvKeyOverride.TryGet(providerId, out apiKey)) return true;
return _inner.TryGet(providerId, out apiKey);
}
public bool Has(string providerId)
=> EnvKeyOverride.TryGet(providerId, out _) || _inner.Has(providerId);
public void Set(string providerId, string apiKey) => _inner.Set(providerId, apiKey);
public void Delete(string providerId) => _inner.Delete(providerId);
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 1e75b60b25da4b9b9c0dc0df42fa1017
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,15 @@
namespace MCPForUnity.Editor.Security
{
/// <summary>Shared constants for the secure key store.</summary>
internal static class SecureKeyStoreConstants
{
/// <summary>Service / target namespace used in the OS secure store.</summary>
internal const string ServiceName = "MCPForUnity.AssetGen";
/// <summary>Known asset-generation provider ids (lowercase).</summary>
internal static readonly string[] ProviderIds =
{
"tripo", "meshy", "sketchfab", "fal", "openrouter"
};
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 59ac5388ff204ea99427dfab8a0370f0
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,106 @@
using System;
using System.Runtime.InteropServices;
using System.Text;
namespace MCPForUnity.Editor.Security
{
/// <summary>
/// Windows Credential Manager-backed key store (DPAPI-protected by the OS) via advapi32
/// Cred* APIs. Target = "MCPForUnity.AssetGen:&lt;provider&gt;", blob = UTF-8 key bytes.
/// </summary>
internal sealed class WindowsCredentialKeyStore : ISecureKeyStore
{
private const int CRED_TYPE_GENERIC = 1;
private const int CRED_PERSIST_LOCAL_MACHINE = 2;
private static string Target(string providerId)
=> SecureKeyStoreConstants.ServiceName + ":" + providerId;
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct CREDENTIAL
{
public int Flags;
public int Type;
public string TargetName;
public string Comment;
public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten;
public int CredentialBlobSize;
public IntPtr CredentialBlob;
public int Persist;
public int AttributeCount;
public IntPtr Attributes;
public string TargetAlias;
public string UserName;
}
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode, EntryPoint = "CredReadW")]
private static extern bool CredRead(string target, int type, int reservedFlag, out IntPtr credentialPtr);
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode, EntryPoint = "CredWriteW")]
private static extern bool CredWrite([In] ref CREDENTIAL credential, int flags);
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode, EntryPoint = "CredDeleteW")]
private static extern bool CredDelete(string target, int type, int flags);
[DllImport("advapi32.dll", EntryPoint = "CredFree")]
private static extern void CredFree(IntPtr buffer);
public bool Has(string providerId) => TryGet(providerId, out _);
public bool TryGet(string providerId, out string apiKey)
{
apiKey = null;
if (string.IsNullOrEmpty(providerId)) return false;
if (!CredRead(Target(providerId), CRED_TYPE_GENERIC, 0, out IntPtr ptr)) return false;
try
{
var cred = Marshal.PtrToStructure<CREDENTIAL>(ptr);
if (cred.CredentialBlobSize <= 0 || cred.CredentialBlob == IntPtr.Zero) return false;
byte[] bytes = new byte[cred.CredentialBlobSize];
Marshal.Copy(cred.CredentialBlob, bytes, 0, cred.CredentialBlobSize);
apiKey = Encoding.UTF8.GetString(bytes);
return !string.IsNullOrEmpty(apiKey);
}
finally
{
CredFree(ptr);
}
}
public void Set(string providerId, string apiKey)
{
if (string.IsNullOrEmpty(providerId)) return;
if (string.IsNullOrEmpty(apiKey)) { Delete(providerId); return; }
byte[] blob = Encoding.UTF8.GetBytes(apiKey);
IntPtr blobPtr = Marshal.AllocHGlobal(blob.Length);
try
{
Marshal.Copy(blob, 0, blobPtr, blob.Length);
var cred = new CREDENTIAL
{
Type = CRED_TYPE_GENERIC,
TargetName = Target(providerId),
CredentialBlobSize = blob.Length,
CredentialBlob = blobPtr,
Persist = CRED_PERSIST_LOCAL_MACHINE,
UserName = providerId,
};
if (!CredWrite(ref cred, 0))
{
throw new InvalidOperationException(
"CredWrite failed (Win32 " + Marshal.GetLastWin32Error() + ")");
}
}
finally
{
Marshal.FreeHGlobal(blobPtr);
}
}
public void Delete(string providerId)
{
if (string.IsNullOrEmpty(providerId)) return;
CredDelete(Target(providerId), CRED_TYPE_GENERIC, 0);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 4c777dcfa11b446897576292db1982a7
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: