using System; using System.IO; using System.Security.Cryptography; using System.Text; using UnityEngine; namespace MCPForUnity.Editor.Security { /// /// 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. /// internal sealed class EncryptedFileKeyStore : ISecureKeyStore { private const int Iterations = 200_000; private readonly string _dir; public EncryptedFileKeyStore() : this(DefaultDir()) { } /// Test seam: point the store at a throwaway directory. 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 */ } } } }