841 lines
29 KiB
C#
841 lines
29 KiB
C#
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<T>(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<T>(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<T>(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<string> candidates = GetRecoveryFilePathVariants(category, key);
|
|
for (int i = 0; i < candidates.Count; i++)
|
|
{
|
|
DeleteIfExists(candidates[i]);
|
|
}
|
|
}
|
|
|
|
public static List<string> LoadAllRawJson(string category, string legacyDirectory = null, string legacySearchPattern = "*.json")
|
|
{
|
|
var result = new List<string>();
|
|
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<string> roots = SaveIdentityUtility.GetPersistentRootVariants(legacyPlainPath);
|
|
IReadOnlyList<string> 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<string> 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<SecureSaveEnvelope>(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<string> identifierVariants = SaveIdentityUtility.GetApplicationIdentifierVariants();
|
|
IReadOnlyList<string> 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<string> identifierVariants = SaveIdentityUtility.GetApplicationIdentifierVariants();
|
|
IReadOnlyList<string> 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<string> roots = SaveIdentityUtility.GetPersistentRootVariants(legacyPlainPath);
|
|
for (int i = 0; i < roots.Count; i++)
|
|
{
|
|
string root = roots[i];
|
|
if (string.IsNullOrWhiteSpace(root))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
IReadOnlyList<string> 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<string> identifierVariants = SaveIdentityUtility.GetApplicationIdentifierVariants();
|
|
IReadOnlyList<string> 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<string> 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<string> GetRecoveryFilePathVariants(string category, string key)
|
|
{
|
|
var result = new List<string>();
|
|
if (string.IsNullOrWhiteSpace(category) || string.IsNullOrWhiteSpace(key))
|
|
{
|
|
return result;
|
|
}
|
|
|
|
string safeName = ShortHash("recovery|" + category + "|" + key) + ".json";
|
|
string safeBackupName = safeName + ".bak";
|
|
IReadOnlyList<string> 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<string> 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
|
|
{
|
|
}
|
|
}
|
|
}
|