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 const string RecoveryDirectoryName = ".save_recovery"; 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(SaveIdentityUtility.GetCanonicalPersistentRoot(), VaultDirectoryName); } } private static string RecoveryDirectoryPath { get { return Path.Combine(SaveIdentityUtility.GetCanonicalPersistentRoot(), RecoveryDirectoryName); } } 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); TrySaveRecoveryCopy(category, key, json); 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; } if (TryLoadFromCurrentOrLegacyEncryptedFiles(category, key, legacyPlainPath, out json)) { return true; } if (TryLoadFromRecoveryCopies(category, key, 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); // 同时删除恢复镜像(.save_recovery/*),否则下次 TryLoadRawJson 会从镜像 // 复活刚删掉的数据并回写主存档——删除必须彻底,覆盖所有根目录变体与 .bak。 DeleteRecoveryCopies(category, key); return true; } catch (Exception ex) { Debug.LogWarning($"[SecureSaveVault] Delete failed ({category}/{key}): {ex.Message}"); return false; } } private static void DeleteRecoveryCopies(string category, string key) { IReadOnlyList candidates = GetRecoveryFilePathVariants(category, key); for (int i = 0; i < candidates.Count; i++) { DeleteIfExists(candidates[i]); } } 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; } public static bool HasAnyRecoverableState(string category, string key, string legacyPlainPath = null) { if (string.IsNullOrWhiteSpace(category) || string.IsNullOrWhiteSpace(key)) { return false; } IReadOnlyList roots = SaveIdentityUtility.GetPersistentRootVariants(legacyPlainPath); IReadOnlyList identifierVariants = SaveIdentityUtility.GetApplicationIdentifierVariants(); for (int rootIndex = 0; rootIndex < roots.Count; rootIndex++) { string root = roots[rootIndex]; if (string.IsNullOrWhiteSpace(root)) { continue; } for (int variantIndex = 0; variantIndex < identifierVariants.Count; variantIndex++) { string applicationIdentifier = identifierVariants[variantIndex]; if (File.Exists(GetFilePathForRoot(root, category, key, ".dat", applicationIdentifier)) || File.Exists(GetFilePathForRoot(root, category, key, ".bak", applicationIdentifier))) { return true; } } } IReadOnlyList recoveryCandidates = GetRecoveryFilePathVariants(category, key); for (int i = 0; i < recoveryCandidates.Count; i++) { string path = recoveryCandidates[i]; if (!string.IsNullOrWhiteSpace(path) && File.Exists(path)) { return true; } } return !string.IsNullOrEmpty(legacyPlainPath) && File.Exists(legacyPlainPath); } 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; } if (!TryValidateSignature(category, envelope.payload, envelope.signature)) { 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) { return ComputeSignature(category, payloadBase64, SaveIdentityUtility.GetPrimaryApplicationIdentifier(), SaveIdentityUtility.GetPrimaryDeviceBinding()); } 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, SaveIdentityUtility.GetPrimaryApplicationIdentifier(), SaveIdentityUtility.GetPrimaryDeviceBinding()); 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 IReadOnlyList identifierVariants = SaveIdentityUtility.GetApplicationIdentifierVariants(); IReadOnlyList deviceBindingVariants = SaveIdentityUtility.GetDeviceBindingVariants(); for (int i = 0; i < identifierVariants.Count; i++) { for (int dv = 0; dv < deviceBindingVariants.Count; dv++) { using (var aes = Aes.Create()) { aes.Mode = CipherMode.CBC; aes.Padding = PaddingMode.PKCS7; aes.Key = BuildAesKey(category, key, identifierVariants[i], deviceBindingVariants[dv]); 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; try { using (var decryptor = aes.CreateDecryptor()) { plainBytes = decryptor.TransformFinalBlock(cipher, 0, cipher.Length); if (plainBytes != null) { return true; } } } catch { } } } } plainBytes = null; return false; } 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, SaveIdentityUtility.GetPrimaryApplicationIdentifier(), SaveIdentityUtility.GetPrimaryDeviceBinding()), 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; } IReadOnlyList identifierVariants = SaveIdentityUtility.GetApplicationIdentifierVariants(); IReadOnlyList deviceBindingVariants = SaveIdentityUtility.GetDeviceBindingVariants(); for (int i = 0; i < identifierVariants.Count; i++) { for (int d = 0; d < deviceBindingVariants.Count; d++) { try { plainBytes = s_dpapiUnprotectMethod.Invoke(null, new object[] { protectedBytes, BuildEntropy(category, identifierVariants[i], deviceBindingVariants[d]), s_dpapiCurrentUserScope }) as byte[]; if (plainBytes != null && plainBytes.Length > 0) { return true; } } 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 applicationIdentifier, string deviceBinding) { string seed = applicationIdentifier + "|" + deviceBinding + "|" + SecretSeed + "|" + category; using (var sha = SHA256.Create()) { return sha.ComputeHash(Encoding.UTF8.GetBytes(seed)); } } private static byte[] BuildAesKey(string category, string key, string applicationIdentifier, string deviceBinding) { using (var sha = SHA256.Create()) { string seed = applicationIdentifier + "|" + deviceBinding + "|" + SecretSeed + "|" + category + "|" + key; return sha.ComputeHash(Encoding.UTF8.GetBytes(seed)); } } private static string ShortHash(string value) { using (var sha = SHA256.Create()) { byte[] hash = sha.ComputeHash(Encoding.UTF8.GetBytes(value + "|" + SaveIdentityUtility.GetPrimaryApplicationIdentifier() + "|" + SecretSeed)); return BitConverter.ToString(hash, 0, 12).Replace("-", string.Empty).ToLowerInvariant(); } } private static string ShortHash(string value, string applicationIdentifier) { using (var sha = SHA256.Create()) { byte[] hash = sha.ComputeHash(Encoding.UTF8.GetBytes(value + "|" + applicationIdentifier + "|" + SecretSeed)); return BitConverter.ToString(hash, 0, 12).Replace("-", string.Empty).ToLowerInvariant(); } } private static bool TryLoadFromCurrentOrLegacyEncryptedFiles(string category, string key, string legacyPlainPath, out string json) { json = null; IReadOnlyList roots = SaveIdentityUtility.GetPersistentRootVariants(legacyPlainPath); for (int i = 0; i < roots.Count; i++) { string root = roots[i]; if (string.IsNullOrWhiteSpace(root)) { continue; } IReadOnlyList identifierVariants = SaveIdentityUtility.GetApplicationIdentifierVariants(); for (int variantIndex = 0; variantIndex < identifierVariants.Count; variantIndex++) { string applicationIdentifier = identifierVariants[variantIndex]; string mainPath = GetFilePathForRoot(root, category, key, ".dat", applicationIdentifier); if (TryReadEncryptedFile(category, key, mainPath, out json)) { SaveRawJson(category, key, json, legacyPlainPath); return true; } string backupPath = GetFilePathForRoot(root, category, key, ".bak", applicationIdentifier); if (TryReadEncryptedFile(category, key, backupPath, out json)) { SaveRawJson(category, key, json, legacyPlainPath); return true; } } } return false; } private static bool TryValidateSignature(string category, string payloadBase64, string signature) { IReadOnlyList identifierVariants = SaveIdentityUtility.GetApplicationIdentifierVariants(); IReadOnlyList deviceBindingVariants = SaveIdentityUtility.GetDeviceBindingVariants(); for (int i = 0; i < identifierVariants.Count; i++) { for (int d = 0; d < deviceBindingVariants.Count; d++) { string expectedSignature = ComputeSignature(category, payloadBase64, identifierVariants[i], deviceBindingVariants[d]); if (string.Equals(expectedSignature, signature, StringComparison.Ordinal)) { return true; } } } return false; } private static string ComputeSignature(string category, string payloadBase64, string applicationIdentifier, string deviceBinding) { string signText = payloadBase64 + "|" + category + "|" + applicationIdentifier + "|" + SecretSeed + "|" + deviceBinding; using (var sha = SHA256.Create()) { byte[] hash = sha.ComputeHash(Encoding.UTF8.GetBytes(signText)); return Convert.ToBase64String(hash); } } private static string GetCategoryDirectoryForRoot(string rootPath, string category, string applicationIdentifier) { string safeCategory = ShortHash("cat|" + category, applicationIdentifier); return Path.Combine(rootPath, VaultDirectoryName, "." + safeCategory); } private static string GetFilePathForRoot(string rootPath, string category, string key, string extension, string applicationIdentifier) { string categoryDirectory = GetCategoryDirectoryForRoot(rootPath, category, applicationIdentifier); string safeKey = ShortHash("key|" + key, applicationIdentifier); return Path.Combine(categoryDirectory, "." + safeKey + extension); } private static bool TryLoadFromRecoveryCopies(string category, string key, out string json) { json = null; IReadOnlyList candidates = GetRecoveryFilePathVariants(category, key); for (int i = 0; i < candidates.Count; i++) { string path = candidates[i]; if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) { continue; } try { string loaded = File.ReadAllText(path, Encoding.UTF8); if (string.IsNullOrWhiteSpace(loaded)) { continue; } json = loaded; return true; } catch (Exception ex) { Debug.LogWarning($"[SecureSaveVault] Recovery read failed ({path}): {ex.Message}"); } } return false; } private static void TrySaveRecoveryCopy(string category, string key, string json) { if (string.IsNullOrWhiteSpace(category) || string.IsNullOrWhiteSpace(key) || json == null) { return; } try { Directory.CreateDirectory(RecoveryDirectoryPath); string recoveryPath = GetRecoveryFilePath(category, key); string backupPath = recoveryPath + ".bak"; string tempPath = recoveryPath + ".tmp"; File.WriteAllText(tempPath, json, Encoding.UTF8); if (File.Exists(recoveryPath)) { File.Copy(recoveryPath, backupPath, true); } File.Copy(tempPath, recoveryPath, true); File.Delete(tempPath); } catch (Exception ex) { Debug.LogWarning($"[SecureSaveVault] Recovery mirror save failed ({category}/{key}): {ex.Message}"); } } private static string GetRecoveryFilePath(string category, string key) { string safeName = ShortHash("recovery|" + category + "|" + key); return Path.Combine(RecoveryDirectoryPath, safeName + ".json"); } private static IReadOnlyList GetRecoveryFilePathVariants(string category, string key) { var result = new List(); if (string.IsNullOrWhiteSpace(category) || string.IsNullOrWhiteSpace(key)) { return result; } string safeName = ShortHash("recovery|" + category + "|" + key) + ".json"; string safeBackupName = safeName + ".bak"; IReadOnlyList roots = SaveIdentityUtility.GetPersistentRootVariants(); for (int i = 0; i < roots.Count; i++) { string root = roots[i]; if (string.IsNullOrWhiteSpace(root)) { continue; } AddDistinctPath(result, Path.Combine(root, RecoveryDirectoryName, safeName)); AddDistinctPath(result, Path.Combine(root, RecoveryDirectoryName, safeBackupName)); } return result; } private static void AddDistinctPath(List target, string value) { if (target == null || string.IsNullOrWhiteSpace(value)) { return; } for (int i = 0; i < target.Count; i++) { if (string.Equals(target[i], value, StringComparison.OrdinalIgnoreCase)) { return; } } target.Add(value); } 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 { } } }