chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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_<PROVIDER>_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:<provider>", 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:
|
||||
Reference in New Issue
Block a user