using System; using System.Collections.Generic; using System.IO; using System.Security.Cryptography; using System.Reflection; using System.Text; using UnityEngine; [Serializable] public class SecureSaveEnvelope { public int version; public string payload; public string signature; public long savedUtcTicks; } public static class SecureSaveVault { private const string SecretSeed = "ban_total.secure_save_v2"; private const string VaultDirectoryName = ".cache_bridge"; private static bool s_dpapiInitialized; private static bool s_dpapiSupported; private static MethodInfo s_dpapiProtectMethod; private static MethodInfo s_dpapiUnprotectMethod; private static object s_dpapiCurrentUserScope; private static string VaultDirectoryPath { get { return Path.Combine(Application.persistentDataPath, VaultDirectoryName); } } public static bool SaveJson(string category, string key, T data, string legacyPlainPath = null) { if (!typeof(T).IsValueType && (object)data == null) { return false; } string json = JsonUtility.ToJson(data, false); return SaveRawJson(category, key, json, legacyPlainPath); } public static bool TryLoadJson(string category, string key, out T data, string legacyPlainPath = null) { data = default(T); string json; if (!TryLoadRawJson(category, key, out json, legacyPlainPath)) { return false; } if (string.IsNullOrEmpty(json)) { return false; } try { data = JsonUtility.FromJson(json); if (typeof(T).IsValueType) { return true; } return (object)data != null; } catch (Exception ex) { Debug.LogWarning($"[SecureSaveVault] JSON parse failed: {ex.Message}"); data = default(T); return false; } } public static bool SaveRawJson(string category, string key, string json, string legacyPlainPath = null) { if (string.IsNullOrWhiteSpace(category) || string.IsNullOrWhiteSpace(key) || json == null) { return false; } string mainPath = GetFilePath(category, key, ".dat"); string backupPath = GetFilePath(category, key, ".bak"); string tempPath = GetFilePath(category, key, ".tmp"); try { string directory = Path.GetDirectoryName(mainPath); Directory.CreateDirectory(directory); TryHidePath(directory); string envelopeJson = BuildEnvelopeJson(category, key, json); File.WriteAllText(tempPath, envelopeJson, Encoding.UTF8); TryHidePath(tempPath); if (File.Exists(mainPath)) { File.Copy(mainPath, backupPath, true); TryHidePath(backupPath); } File.Copy(tempPath, mainPath, true); TryHidePath(mainPath); File.Delete(tempPath); DeleteLegacyPlainFile(legacyPlainPath); return true; } catch (Exception ex) { Debug.LogWarning($"[SecureSaveVault] Save failed ({category}/{key}): {ex.Message}"); return false; } } public static bool TryLoadRawJson(string category, string key, out string json, string legacyPlainPath = null) { json = null; if (string.IsNullOrWhiteSpace(category) || string.IsNullOrWhiteSpace(key)) { return false; } string mainPath = GetFilePath(category, key, ".dat"); string backupPath = GetFilePath(category, key, ".bak"); if (TryReadEncryptedFile(category, key, mainPath, out json)) { return true; } if (TryReadEncryptedFile(category, key, backupPath, out json)) { SaveRawJson(category, key, json, legacyPlainPath); return true; } if (!string.IsNullOrEmpty(legacyPlainPath) && File.Exists(legacyPlainPath)) { try { json = File.ReadAllText(legacyPlainPath, Encoding.UTF8); if (!string.IsNullOrEmpty(json)) { SaveRawJson(category, key, json, legacyPlainPath); return true; } } catch (Exception ex) { Debug.LogWarning($"[SecureSaveVault] Legacy migration failed ({legacyPlainPath}): {ex.Message}"); } } return false; } public static bool Delete(string category, string key, string legacyPlainPath = null) { try { DeleteIfExists(GetFilePath(category, key, ".dat")); DeleteIfExists(GetFilePath(category, key, ".bak")); DeleteIfExists(GetFilePath(category, key, ".tmp")); DeleteLegacyPlainFile(legacyPlainPath); return true; } catch (Exception ex) { Debug.LogWarning($"[SecureSaveVault] Delete failed ({category}/{key}): {ex.Message}"); return false; } } public static List LoadAllRawJson(string category, string legacyDirectory = null, string legacySearchPattern = "*.json") { var result = new List(); if (string.IsNullOrWhiteSpace(category)) { return result; } string categoryDirectory = GetCategoryDirectory(category); if (Directory.Exists(categoryDirectory)) { string[] encryptedFiles = Directory.GetFiles(categoryDirectory, "*.dat", SearchOption.TopDirectoryOnly); for (int i = 0; i < encryptedFiles.Length; i++) { string path = encryptedFiles[i]; string key = ExtractKeyFromFileName(path); if (string.IsNullOrEmpty(key)) { continue; } string json; if (TryReadEncryptedFile(category, key, path, out json) && !string.IsNullOrEmpty(json)) { result.Add(json); } } } if (!string.IsNullOrEmpty(legacyDirectory) && Directory.Exists(legacyDirectory)) { string[] legacyFiles = Directory.GetFiles(legacyDirectory, legacySearchPattern, SearchOption.TopDirectoryOnly); for (int i = 0; i < legacyFiles.Length; i++) { string legacyPath = legacyFiles[i]; try { string json = File.ReadAllText(legacyPath, Encoding.UTF8); if (string.IsNullOrEmpty(json)) { continue; } string legacyKey = Path.GetFileNameWithoutExtension(legacyPath); SaveRawJson(category, legacyKey, json, legacyPath); result.Add(json); } catch (Exception ex) { Debug.LogWarning($"[SecureSaveVault] Legacy bulk migration failed ({legacyPath}): {ex.Message}"); } } } return result; } public static int CountEncryptedFiles(string category) { if (string.IsNullOrWhiteSpace(category)) { return 0; } string categoryDirectory = GetCategoryDirectory(category); if (!Directory.Exists(categoryDirectory)) { return 0; } return Directory.GetFiles(categoryDirectory, "*.dat", SearchOption.TopDirectoryOnly).Length; } private static bool TryReadEncryptedFile(string category, string key, string filePath, out string json) { json = null; if (!File.Exists(filePath)) { return false; } try { string envelopeJson = File.ReadAllText(filePath, Encoding.UTF8); var envelope = JsonUtility.FromJson(envelopeJson); if (envelope == null || string.IsNullOrEmpty(envelope.payload) || string.IsNullOrEmpty(envelope.signature)) { return false; } string expectedSignature = ComputeSignature(category, envelope.payload); if (!string.Equals(expectedSignature, envelope.signature, StringComparison.Ordinal)) { Debug.LogWarning($"[SecureSaveVault] Signature mismatch ({category}/{key}). Possible tampering detected."); return false; } byte[] protectedBytes = Convert.FromBase64String(envelope.payload); byte[] plainBytes; if (!TryUnprotectBytes(category, key, protectedBytes, out plainBytes)) { return false; } json = Encoding.UTF8.GetString(plainBytes); return true; } catch (Exception ex) { Debug.LogWarning($"[SecureSaveVault] Read failed ({category}/{key}): {ex.Message}"); return false; } } private static string BuildEnvelopeJson(string category, string key, string json) { byte[] plainBytes = Encoding.UTF8.GetBytes(json); byte[] protectedBytes = ProtectBytes(category, key, plainBytes); string payloadBase64 = Convert.ToBase64String(protectedBytes); var envelope = new SecureSaveEnvelope { version = 2, payload = payloadBase64, signature = ComputeSignature(category, payloadBase64), savedUtcTicks = DateTime.UtcNow.Ticks }; return JsonUtility.ToJson(envelope, false); } private static string GetCategoryDirectory(string category) { string safeCategory = ShortHash("cat|" + category); return Path.Combine(VaultDirectoryPath, "." + safeCategory); } private static string GetFilePath(string category, string key, string extension) { string categoryDirectory = GetCategoryDirectory(category); string safeKey = ShortHash("key|" + key); return Path.Combine(categoryDirectory, "." + safeKey + extension); } private static string ExtractKeyFromFileName(string path) { string fileName = Path.GetFileNameWithoutExtension(path); if (string.IsNullOrEmpty(fileName)) { return null; } return fileName.StartsWith(".") ? fileName.Substring(1) : fileName; } private static string ComputeSignature(string category, string payloadBase64) { string signText = payloadBase64 + "|" + category + "|" + Application.identifier + "|" + SecretSeed + "|" + SystemInfo.deviceUniqueIdentifier; using (var sha = SHA256.Create()) { byte[] hash = sha.ComputeHash(Encoding.UTF8.GetBytes(signText)); return Convert.ToBase64String(hash); } } private static byte[] ProtectBytes(string category, string key, byte[] plainBytes) { #if UNITY_EDITOR_WIN || UNITY_STANDALONE_WIN byte[] dpapiBytes; if (TryProtectWithDpapi(category, plainBytes, out dpapiBytes)) { return dpapiBytes; } #endif using (var aes = Aes.Create()) { aes.Mode = CipherMode.CBC; aes.Padding = PaddingMode.PKCS7; aes.Key = BuildAesKey(category, key); aes.GenerateIV(); using (var encryptor = aes.CreateEncryptor()) { byte[] cipherBytes = encryptor.TransformFinalBlock(plainBytes, 0, plainBytes.Length); byte[] result = new byte[aes.IV.Length + cipherBytes.Length]; Buffer.BlockCopy(aes.IV, 0, result, 0, aes.IV.Length); Buffer.BlockCopy(cipherBytes, 0, result, aes.IV.Length, cipherBytes.Length); return result; } } } private static bool TryUnprotectBytes(string category, string key, byte[] protectedBytes, out byte[] plainBytes) { plainBytes = null; try { #if UNITY_EDITOR_WIN || UNITY_STANDALONE_WIN if (TryUnprotectWithDpapi(category, protectedBytes, out plainBytes)) { return plainBytes != null; } #endif using (var aes = Aes.Create()) { aes.Mode = CipherMode.CBC; aes.Padding = PaddingMode.PKCS7; aes.Key = BuildAesKey(category, key); int ivLength = aes.BlockSize / 8; if (protectedBytes == null || protectedBytes.Length <= ivLength) { return false; } byte[] iv = new byte[ivLength]; byte[] cipher = new byte[protectedBytes.Length - ivLength]; Buffer.BlockCopy(protectedBytes, 0, iv, 0, ivLength); Buffer.BlockCopy(protectedBytes, ivLength, cipher, 0, cipher.Length); aes.IV = iv; using (var decryptor = aes.CreateDecryptor()) { plainBytes = decryptor.TransformFinalBlock(cipher, 0, cipher.Length); return plainBytes != null; } } } catch (Exception ex) { Debug.LogWarning($"[SecureSaveVault] Decrypt failed ({category}/{key}): {ex.Message}"); plainBytes = null; return false; } } #if UNITY_EDITOR_WIN || UNITY_STANDALONE_WIN private static bool TryProtectWithDpapi(string category, byte[] plainBytes, out byte[] protectedBytes) { protectedBytes = null; if (!EnsureDpapi()) { return false; } try { protectedBytes = s_dpapiProtectMethod.Invoke(null, new object[] { plainBytes, BuildEntropy(category), s_dpapiCurrentUserScope }) as byte[]; return protectedBytes != null && protectedBytes.Length > 0; } catch (Exception ex) { Debug.LogWarning($"[SecureSaveVault] DPAPI protect failed, fallback to AES: {ex.Message}"); protectedBytes = null; return false; } } private static bool TryUnprotectWithDpapi(string category, byte[] protectedBytes, out byte[] plainBytes) { plainBytes = null; if (!EnsureDpapi()) { return false; } try { plainBytes = s_dpapiUnprotectMethod.Invoke(null, new object[] { protectedBytes, BuildEntropy(category), s_dpapiCurrentUserScope }) as byte[]; return plainBytes != null && plainBytes.Length > 0; } catch { plainBytes = null; return false; } } private static bool EnsureDpapi() { if (s_dpapiInitialized) { return s_dpapiSupported; } s_dpapiInitialized = true; try { Type protectedDataType = Type.GetType("System.Security.Cryptography.ProtectedData, System.Security.Cryptography.ProtectedData") ?? Type.GetType("System.Security.Cryptography.ProtectedData, System.Security"); Type scopeType = Type.GetType("System.Security.Cryptography.DataProtectionScope, System.Security.Cryptography.ProtectedData") ?? Type.GetType("System.Security.Cryptography.DataProtectionScope, System.Security"); if (protectedDataType == null || scopeType == null) { s_dpapiSupported = false; return false; } s_dpapiProtectMethod = protectedDataType.GetMethod("Protect", BindingFlags.Public | BindingFlags.Static, null, new[] { typeof(byte[]), typeof(byte[]), scopeType }, null); s_dpapiUnprotectMethod = protectedDataType.GetMethod("Unprotect", BindingFlags.Public | BindingFlags.Static, null, new[] { typeof(byte[]), typeof(byte[]), scopeType }, null); if (s_dpapiProtectMethod == null || s_dpapiUnprotectMethod == null) { s_dpapiSupported = false; return false; } s_dpapiCurrentUserScope = Enum.Parse(scopeType, "CurrentUser"); s_dpapiSupported = s_dpapiCurrentUserScope != null; return s_dpapiSupported; } catch (Exception ex) { Debug.LogWarning($"[SecureSaveVault] DPAPI initialize failed, fallback to AES: {ex.Message}"); s_dpapiSupported = false; return false; } } #endif private static byte[] BuildEntropy(string category) { string seed = Application.identifier + "|" + SystemInfo.deviceUniqueIdentifier + "|" + SecretSeed + "|" + category; using (var sha = SHA256.Create()) { return sha.ComputeHash(Encoding.UTF8.GetBytes(seed)); } } private static byte[] BuildAesKey(string category, string key) { return BuildEntropy(category); } private static string ShortHash(string value) { using (var sha = SHA256.Create()) { byte[] hash = sha.ComputeHash(Encoding.UTF8.GetBytes(value + "|" + Application.identifier + "|" + SecretSeed)); return BitConverter.ToString(hash, 0, 12).Replace("-", string.Empty).ToLowerInvariant(); } } private static void DeleteLegacyPlainFile(string legacyPlainPath) { if (string.IsNullOrEmpty(legacyPlainPath) || !File.Exists(legacyPlainPath)) { return; } DeleteIfExists(legacyPlainPath); } private static void DeleteIfExists(string path) { if (!string.IsNullOrEmpty(path) && File.Exists(path)) { File.Delete(path); } } private static void TryHidePath(string path) { try { if (Application.platform != RuntimePlatform.WindowsEditor && Application.platform != RuntimePlatform.WindowsPlayer) { return; } var attributes = File.GetAttributes(path); if ((attributes & FileAttributes.Hidden) == 0) { File.SetAttributes(path, attributes | FileAttributes.Hidden); } } catch { } } }