ui基本完毕,修了一大把的bug
This commit is contained in:
@@ -12,26 +12,50 @@ public static class AllyHeroDeployLedgerStorage
|
||||
private const string BackupFileName = ".ahd.bak";
|
||||
private const string TempFileName = ".ahd.tmp";
|
||||
|
||||
private static string VaultDirectoryPath => Path.Combine(Application.persistentDataPath, VaultDirectoryName);
|
||||
private static string VaultDirectoryPath => Path.Combine(SaveIdentityUtility.GetCanonicalPersistentRoot(), VaultDirectoryName);
|
||||
private static string MainFilePath => Path.Combine(VaultDirectoryPath, MainFileName);
|
||||
private static string BackupFilePath => Path.Combine(VaultDirectoryPath, BackupFileName);
|
||||
private static string TempFilePath => Path.Combine(VaultDirectoryPath, TempFileName);
|
||||
|
||||
public static bool HasExistingSaveFile()
|
||||
{
|
||||
return File.Exists(MainFilePath) || File.Exists(BackupFilePath);
|
||||
var mainCandidates = SaveIdentityUtility.GetVaultFilePathVariants(MainFileName);
|
||||
for (int i = 0; i < mainCandidates.Count; i++)
|
||||
{
|
||||
if (File.Exists(mainCandidates[i]))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
var backupCandidates = SaveIdentityUtility.GetVaultFilePathVariants(BackupFileName);
|
||||
for (int i = 0; i < backupCandidates.Count; i++)
|
||||
{
|
||||
if (File.Exists(backupCandidates[i]))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static bool TryLoad(out AllyHeroDeployLedgerPayload payload)
|
||||
{
|
||||
payload = CreateDefaultPayload();
|
||||
|
||||
if (TryReadPayload(MainFilePath, out payload))
|
||||
if (TryReadPayloadFromVariants(MainFileName, out payload))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (TryReadPayload(BackupFilePath, out payload))
|
||||
if (TryReadPayloadFromVariants(BackupFileName, out payload))
|
||||
{
|
||||
TrySave(payload);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (PlayerProgressBackupService.TryRestoreAllyHeroDeploy(out payload))
|
||||
{
|
||||
TrySave(payload);
|
||||
return true;
|
||||
@@ -60,6 +84,7 @@ public static class AllyHeroDeployLedgerStorage
|
||||
File.Copy(TempFilePath, MainFilePath, true);
|
||||
TryHidePath(MainFilePath);
|
||||
File.Delete(TempFilePath);
|
||||
PlayerProgressBackupService.SaveAllyHeroDeploy(payload);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -96,17 +121,32 @@ public static class AllyHeroDeployLedgerStorage
|
||||
return false;
|
||||
}
|
||||
|
||||
string expectedSignature = ComputeSignature(envelope.payload);
|
||||
if (!string.Equals(expectedSignature, envelope.signature, StringComparison.Ordinal))
|
||||
if (!TryValidateSignature(envelope.payload, envelope.signature))
|
||||
{
|
||||
Debug.LogWarning("[AllyHeroDeployLedgerStorage] Save signature mismatch. Possible tampering detected.");
|
||||
return false;
|
||||
}
|
||||
|
||||
byte[] encryptedBytes = Convert.FromBase64String(envelope.payload);
|
||||
byte[] plainBytes = XorTransform(encryptedBytes, BuildKeyBytes());
|
||||
string payloadJson = Encoding.UTF8.GetString(plainBytes);
|
||||
AllyHeroDeployLedgerPayload loadedPayload = JsonUtility.FromJson<AllyHeroDeployLedgerPayload>(payloadJson);
|
||||
AllyHeroDeployLedgerPayload loadedPayload = null;
|
||||
var identifierVariants = SaveIdentityUtility.GetApplicationIdentifierVariants();
|
||||
for (int i = 0; i < identifierVariants.Count; i++)
|
||||
{
|
||||
try
|
||||
{
|
||||
byte[] plainBytes = XorTransform(encryptedBytes, BuildKeyBytes(identifierVariants[i]));
|
||||
string payloadJson = Encoding.UTF8.GetString(plainBytes);
|
||||
loadedPayload = JsonUtility.FromJson<AllyHeroDeployLedgerPayload>(payloadJson);
|
||||
if (loadedPayload != null)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
if (loadedPayload == null)
|
||||
{
|
||||
return false;
|
||||
@@ -122,6 +162,21 @@ public static class AllyHeroDeployLedgerStorage
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryReadPayloadFromVariants(string fileName, out AllyHeroDeployLedgerPayload payload)
|
||||
{
|
||||
payload = CreateDefaultPayload();
|
||||
var candidates = SaveIdentityUtility.GetVaultFilePathVariants(fileName);
|
||||
for (int i = 0; i < candidates.Count; i++)
|
||||
{
|
||||
if (TryReadPayload(candidates[i], out payload))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string BuildEnvelopeJson(AllyHeroDeployLedgerPayload payload)
|
||||
{
|
||||
payload.lastUpdatedUtcTicks = DateTime.UtcNow.Ticks;
|
||||
@@ -141,7 +196,7 @@ public static class AllyHeroDeployLedgerStorage
|
||||
|
||||
private static string ComputeSignature(string payloadBase64)
|
||||
{
|
||||
string signText = payloadBase64 + "|" + Application.identifier + "|" + SecretSeed + "|" + SystemInfo.deviceUniqueIdentifier;
|
||||
string signText = payloadBase64 + "|" + SaveIdentityUtility.GetPrimaryApplicationIdentifier() + "|" + SecretSeed + "|" + SystemInfo.deviceUniqueIdentifier;
|
||||
using (SHA256 sha = SHA256.Create())
|
||||
{
|
||||
byte[] bytes = Encoding.UTF8.GetBytes(signText);
|
||||
@@ -151,14 +206,45 @@ public static class AllyHeroDeployLedgerStorage
|
||||
}
|
||||
|
||||
private static byte[] BuildKeyBytes()
|
||||
{
|
||||
return BuildKeyBytes(SaveIdentityUtility.GetPrimaryApplicationIdentifier());
|
||||
}
|
||||
|
||||
private static byte[] BuildKeyBytes(string applicationIdentifier)
|
||||
{
|
||||
using (SHA256 sha = SHA256.Create())
|
||||
{
|
||||
string seed = Application.identifier + "|" + SystemInfo.deviceUniqueIdentifier + "|" + SecretSeed;
|
||||
string seed = applicationIdentifier + "|" + SystemInfo.deviceUniqueIdentifier + "|" + SecretSeed;
|
||||
return sha.ComputeHash(Encoding.UTF8.GetBytes(seed));
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryValidateSignature(string payloadBase64, string signature)
|
||||
{
|
||||
var identifierVariants = SaveIdentityUtility.GetApplicationIdentifierVariants();
|
||||
for (int i = 0; i < identifierVariants.Count; i++)
|
||||
{
|
||||
string expectedSignature = ComputeSignature(payloadBase64, identifierVariants[i]);
|
||||
if (string.Equals(expectedSignature, signature, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string ComputeSignature(string payloadBase64, string applicationIdentifier)
|
||||
{
|
||||
string signText = payloadBase64 + "|" + applicationIdentifier + "|" + SecretSeed + "|" + SystemInfo.deviceUniqueIdentifier;
|
||||
using (SHA256 sha = SHA256.Create())
|
||||
{
|
||||
byte[] bytes = Encoding.UTF8.GetBytes(signText);
|
||||
byte[] hash = sha.ComputeHash(bytes);
|
||||
return Convert.ToBase64String(hash);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] XorTransform(byte[] source, byte[] key)
|
||||
{
|
||||
byte[] result = new byte[source.Length];
|
||||
|
||||
@@ -43,9 +43,13 @@ public static class DlcManifestService
|
||||
AppendIfValid(result, Path.Combine(Application.persistentDataPath, RuntimeFolderName, ManifestFolderName));
|
||||
AppendIfValid(result, Path.Combine(Application.persistentDataPath, RuntimeFolderName));
|
||||
|
||||
#if !UNITY_ANDROID
|
||||
// 项目外(可执行文件同级目录)的 DLC 扫描仅在 PC 端有意义;
|
||||
// 安卓无此目录概念,Application.dataPath 指向 APK,跳过以避免无效/异常路径。
|
||||
string playerRoot = GetPlayerRootDirectory();
|
||||
AppendIfValid(result, Path.Combine(playerRoot, RuntimeFolderName, ManifestFolderName));
|
||||
AppendIfValid(result, Path.Combine(playerRoot, RuntimeFolderName));
|
||||
#endif
|
||||
|
||||
return result.ToArray();
|
||||
}
|
||||
|
||||
@@ -91,9 +91,13 @@ public static class DlcPackageArchiveService
|
||||
AppendIfValid(result, Path.Combine(Application.persistentDataPath, RuntimeFolderName, PackageFolderName));
|
||||
AppendIfValid(result, Path.Combine(Application.persistentDataPath, RuntimeFolderName));
|
||||
|
||||
#if !UNITY_ANDROID
|
||||
// 项目外(可执行文件同级目录)的 DLC 扫描仅在 PC 端有意义;
|
||||
// 安卓无此目录概念,Application.dataPath 指向 APK,跳过以避免无效/异常路径。
|
||||
string playerRoot = GetPlayerRootDirectory();
|
||||
AppendIfValid(result, Path.Combine(playerRoot, RuntimeFolderName, PackageFolderName));
|
||||
AppendIfValid(result, Path.Combine(playerRoot, RuntimeFolderName));
|
||||
#endif
|
||||
|
||||
return result.ToArray();
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ public static class DushMaterialLedgerStorage
|
||||
|
||||
private static string VaultDirectoryPath
|
||||
{
|
||||
get { return Path.Combine(Application.persistentDataPath, VaultDirectoryName); }
|
||||
get { return Path.Combine(SaveIdentityUtility.GetCanonicalPersistentRoot(), VaultDirectoryName); }
|
||||
}
|
||||
|
||||
private static string MainFilePath
|
||||
@@ -36,12 +36,18 @@ public static class DushMaterialLedgerStorage
|
||||
{
|
||||
payload = CreateDefaultPayload();
|
||||
|
||||
if (TryReadPayload(MainFilePath, out payload))
|
||||
if (TryReadPayloadFromVariants(MainFileName, out payload))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (TryReadPayload(BackupFilePath, out payload))
|
||||
if (TryReadPayloadFromVariants(BackupFileName, out payload))
|
||||
{
|
||||
TrySave(payload);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (PlayerProgressBackupService.TryRestoreDushMaterial(out payload))
|
||||
{
|
||||
TrySave(payload);
|
||||
return true;
|
||||
@@ -70,6 +76,7 @@ public static class DushMaterialLedgerStorage
|
||||
File.Copy(TempFilePath, MainFilePath, true);
|
||||
TryHidePath(MainFilePath);
|
||||
File.Delete(TempFilePath);
|
||||
PlayerProgressBackupService.SaveDushMaterial(payload);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -106,17 +113,32 @@ public static class DushMaterialLedgerStorage
|
||||
return false;
|
||||
}
|
||||
|
||||
var expectedSignature = ComputeSignature(envelope.payload);
|
||||
if (!string.Equals(expectedSignature, envelope.signature, StringComparison.Ordinal))
|
||||
if (!TryValidateSignature(envelope.payload, envelope.signature))
|
||||
{
|
||||
Debug.LogWarning("[DushMaterialLedgerStorage] Save signature mismatch. Possible tampering detected.");
|
||||
return false;
|
||||
}
|
||||
|
||||
var encryptedBytes = Convert.FromBase64String(envelope.payload);
|
||||
var plainBytes = XorTransform(encryptedBytes, BuildKeyBytes());
|
||||
var payloadJson = Encoding.UTF8.GetString(plainBytes);
|
||||
var loadedPayload = JsonUtility.FromJson<DushMaterialLedgerPayload>(payloadJson);
|
||||
DushMaterialLedgerPayload loadedPayload = null;
|
||||
var identifierVariants = SaveIdentityUtility.GetApplicationIdentifierVariants();
|
||||
for (int i = 0; i < identifierVariants.Count; i++)
|
||||
{
|
||||
try
|
||||
{
|
||||
var plainBytes = XorTransform(encryptedBytes, BuildKeyBytes(identifierVariants[i]));
|
||||
var payloadJson = Encoding.UTF8.GetString(plainBytes);
|
||||
loadedPayload = JsonUtility.FromJson<DushMaterialLedgerPayload>(payloadJson);
|
||||
if (loadedPayload != null)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
if (loadedPayload == null)
|
||||
{
|
||||
return false;
|
||||
@@ -132,6 +154,21 @@ public static class DushMaterialLedgerStorage
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryReadPayloadFromVariants(string fileName, out DushMaterialLedgerPayload payload)
|
||||
{
|
||||
payload = CreateDefaultPayload();
|
||||
var candidates = SaveIdentityUtility.GetVaultFilePathVariants(fileName);
|
||||
for (int i = 0; i < candidates.Count; i++)
|
||||
{
|
||||
if (TryReadPayload(candidates[i], out payload))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string BuildEnvelopeJson(DushMaterialLedgerPayload payload)
|
||||
{
|
||||
payload.lastUpdatedUtcTicks = DateTime.UtcNow.Ticks;
|
||||
@@ -151,7 +188,7 @@ public static class DushMaterialLedgerStorage
|
||||
|
||||
private static string ComputeSignature(string payloadBase64)
|
||||
{
|
||||
var signText = payloadBase64 + "|" + Application.identifier + "|" + SecretSeed + "|" + SystemInfo.deviceUniqueIdentifier;
|
||||
var signText = payloadBase64 + "|" + SaveIdentityUtility.GetPrimaryApplicationIdentifier() + "|" + SecretSeed + "|" + SystemInfo.deviceUniqueIdentifier;
|
||||
using (var sha = SHA256.Create())
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(signText);
|
||||
@@ -161,14 +198,45 @@ public static class DushMaterialLedgerStorage
|
||||
}
|
||||
|
||||
private static byte[] BuildKeyBytes()
|
||||
{
|
||||
return BuildKeyBytes(SaveIdentityUtility.GetPrimaryApplicationIdentifier());
|
||||
}
|
||||
|
||||
private static byte[] BuildKeyBytes(string applicationIdentifier)
|
||||
{
|
||||
using (var sha = SHA256.Create())
|
||||
{
|
||||
var seed = Application.identifier + "|" + SystemInfo.deviceUniqueIdentifier + "|" + SecretSeed;
|
||||
var seed = applicationIdentifier + "|" + SystemInfo.deviceUniqueIdentifier + "|" + SecretSeed;
|
||||
return sha.ComputeHash(Encoding.UTF8.GetBytes(seed));
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryValidateSignature(string payloadBase64, string signature)
|
||||
{
|
||||
var identifierVariants = SaveIdentityUtility.GetApplicationIdentifierVariants();
|
||||
for (int i = 0; i < identifierVariants.Count; i++)
|
||||
{
|
||||
var expectedSignature = ComputeSignature(payloadBase64, identifierVariants[i]);
|
||||
if (string.Equals(expectedSignature, signature, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string ComputeSignature(string payloadBase64, string applicationIdentifier)
|
||||
{
|
||||
var signText = payloadBase64 + "|" + applicationIdentifier + "|" + SecretSeed + "|" + SystemInfo.deviceUniqueIdentifier;
|
||||
using (var sha = SHA256.Create())
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(signText);
|
||||
var hash = sha.ComputeHash(bytes);
|
||||
return Convert.ToBase64String(hash);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] XorTransform(byte[] source, byte[] key)
|
||||
{
|
||||
var result = new byte[source.Length];
|
||||
|
||||
@@ -12,7 +12,7 @@ public static class EquipmentConsumableLedgerStorage
|
||||
private const string BackupFileName = ".eqc.bak";
|
||||
private const string TempFileName = ".eqc.tmp";
|
||||
|
||||
private static string VaultDirectoryPath => Path.Combine(Application.persistentDataPath, VaultDirectoryName);
|
||||
private static string VaultDirectoryPath => Path.Combine(SaveIdentityUtility.GetCanonicalPersistentRoot(), VaultDirectoryName);
|
||||
private static string MainFilePath => Path.Combine(VaultDirectoryPath, MainFileName);
|
||||
private static string BackupFilePath => Path.Combine(VaultDirectoryPath, BackupFileName);
|
||||
private static string TempFilePath => Path.Combine(VaultDirectoryPath, TempFileName);
|
||||
@@ -21,12 +21,18 @@ public static class EquipmentConsumableLedgerStorage
|
||||
{
|
||||
payload = CreateDefaultPayload();
|
||||
|
||||
if (TryReadPayload(MainFilePath, out payload))
|
||||
if (TryReadPayloadFromVariants(MainFileName, out payload))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (TryReadPayload(BackupFilePath, out payload))
|
||||
if (TryReadPayloadFromVariants(BackupFileName, out payload))
|
||||
{
|
||||
TrySave(payload);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (PlayerProgressBackupService.TryRestoreEquipmentConsumable(out payload))
|
||||
{
|
||||
TrySave(payload);
|
||||
return true;
|
||||
@@ -55,6 +61,7 @@ public static class EquipmentConsumableLedgerStorage
|
||||
File.Copy(TempFilePath, MainFilePath, true);
|
||||
TryHidePath(MainFilePath);
|
||||
File.Delete(TempFilePath);
|
||||
PlayerProgressBackupService.SaveEquipmentConsumable(payload);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -91,17 +98,32 @@ public static class EquipmentConsumableLedgerStorage
|
||||
return false;
|
||||
}
|
||||
|
||||
var expectedSignature = ComputeSignature(envelope.payload);
|
||||
if (!string.Equals(expectedSignature, envelope.signature, StringComparison.Ordinal))
|
||||
if (!TryValidateSignature(envelope.payload, envelope.signature))
|
||||
{
|
||||
Debug.LogWarning("[EquipmentConsumableLedgerStorage] Save signature mismatch. Possible tampering detected.");
|
||||
return false;
|
||||
}
|
||||
|
||||
var encryptedBytes = Convert.FromBase64String(envelope.payload);
|
||||
var plainBytes = XorTransform(encryptedBytes, BuildKeyBytes());
|
||||
var payloadJson = Encoding.UTF8.GetString(plainBytes);
|
||||
var loadedPayload = JsonUtility.FromJson<EquipmentConsumableLedgerPayload>(payloadJson);
|
||||
EquipmentConsumableLedgerPayload loadedPayload = null;
|
||||
var identifierVariants = SaveIdentityUtility.GetApplicationIdentifierVariants();
|
||||
for (int i = 0; i < identifierVariants.Count; i++)
|
||||
{
|
||||
try
|
||||
{
|
||||
var plainBytes = XorTransform(encryptedBytes, BuildKeyBytes(identifierVariants[i]));
|
||||
var payloadJson = Encoding.UTF8.GetString(plainBytes);
|
||||
loadedPayload = JsonUtility.FromJson<EquipmentConsumableLedgerPayload>(payloadJson);
|
||||
if (loadedPayload != null)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
if (loadedPayload == null)
|
||||
{
|
||||
return false;
|
||||
@@ -117,6 +139,21 @@ public static class EquipmentConsumableLedgerStorage
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryReadPayloadFromVariants(string fileName, out EquipmentConsumableLedgerPayload payload)
|
||||
{
|
||||
payload = CreateDefaultPayload();
|
||||
var candidates = SaveIdentityUtility.GetVaultFilePathVariants(fileName);
|
||||
for (int i = 0; i < candidates.Count; i++)
|
||||
{
|
||||
if (TryReadPayload(candidates[i], out payload))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string BuildEnvelopeJson(EquipmentConsumableLedgerPayload payload)
|
||||
{
|
||||
payload.lastUpdatedUtcTicks = DateTime.UtcNow.Ticks;
|
||||
@@ -136,7 +173,7 @@ public static class EquipmentConsumableLedgerStorage
|
||||
|
||||
private static string ComputeSignature(string payloadBase64)
|
||||
{
|
||||
var signText = payloadBase64 + "|" + Application.identifier + "|" + SecretSeed + "|" + SystemInfo.deviceUniqueIdentifier;
|
||||
var signText = payloadBase64 + "|" + SaveIdentityUtility.GetPrimaryApplicationIdentifier() + "|" + SecretSeed + "|" + SystemInfo.deviceUniqueIdentifier;
|
||||
using (var sha = SHA256.Create())
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(signText);
|
||||
@@ -146,14 +183,45 @@ public static class EquipmentConsumableLedgerStorage
|
||||
}
|
||||
|
||||
private static byte[] BuildKeyBytes()
|
||||
{
|
||||
return BuildKeyBytes(SaveIdentityUtility.GetPrimaryApplicationIdentifier());
|
||||
}
|
||||
|
||||
private static byte[] BuildKeyBytes(string applicationIdentifier)
|
||||
{
|
||||
using (var sha = SHA256.Create())
|
||||
{
|
||||
var seed = Application.identifier + "|" + SystemInfo.deviceUniqueIdentifier + "|" + SecretSeed;
|
||||
var seed = applicationIdentifier + "|" + SystemInfo.deviceUniqueIdentifier + "|" + SecretSeed;
|
||||
return sha.ComputeHash(Encoding.UTF8.GetBytes(seed));
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryValidateSignature(string payloadBase64, string signature)
|
||||
{
|
||||
var identifierVariants = SaveIdentityUtility.GetApplicationIdentifierVariants();
|
||||
for (int i = 0; i < identifierVariants.Count; i++)
|
||||
{
|
||||
var expectedSignature = ComputeSignature(payloadBase64, identifierVariants[i]);
|
||||
if (string.Equals(expectedSignature, signature, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string ComputeSignature(string payloadBase64, string applicationIdentifier)
|
||||
{
|
||||
var signText = payloadBase64 + "|" + applicationIdentifier + "|" + SecretSeed + "|" + SystemInfo.deviceUniqueIdentifier;
|
||||
using (var sha = SHA256.Create())
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(signText);
|
||||
var hash = sha.ComputeHash(bytes);
|
||||
return Convert.ToBase64String(hash);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] XorTransform(byte[] source, byte[] key)
|
||||
{
|
||||
var result = new byte[source.Length];
|
||||
|
||||
@@ -14,7 +14,7 @@ public static class ExpBottleLedgerStorage
|
||||
|
||||
private static string VaultDirectoryPath
|
||||
{
|
||||
get { return Path.Combine(Application.persistentDataPath, VaultDirectoryName); }
|
||||
get { return Path.Combine(SaveIdentityUtility.GetCanonicalPersistentRoot(), VaultDirectoryName); }
|
||||
}
|
||||
|
||||
private static string MainFilePath
|
||||
@@ -36,12 +36,18 @@ public static class ExpBottleLedgerStorage
|
||||
{
|
||||
payload = CreateDefaultPayload();
|
||||
|
||||
if (TryReadPayload(MainFilePath, out payload))
|
||||
if (TryReadPayloadFromVariants(MainFileName, out payload))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (TryReadPayload(BackupFilePath, out payload))
|
||||
if (TryReadPayloadFromVariants(BackupFileName, out payload))
|
||||
{
|
||||
TrySave(payload);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (PlayerProgressBackupService.TryRestoreExpBottle(out payload))
|
||||
{
|
||||
TrySave(payload);
|
||||
return true;
|
||||
@@ -70,6 +76,7 @@ public static class ExpBottleLedgerStorage
|
||||
File.Copy(TempFilePath, MainFilePath, true);
|
||||
TryHidePath(MainFilePath);
|
||||
File.Delete(TempFilePath);
|
||||
PlayerProgressBackupService.SaveExpBottle(payload);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -106,17 +113,32 @@ public static class ExpBottleLedgerStorage
|
||||
return false;
|
||||
}
|
||||
|
||||
var expectedSignature = ComputeSignature(envelope.payload);
|
||||
if (!string.Equals(expectedSignature, envelope.signature, StringComparison.Ordinal))
|
||||
if (!TryValidateSignature(envelope.payload, envelope.signature))
|
||||
{
|
||||
Debug.LogWarning("[ExpBottleLedgerStorage] Save signature mismatch. Possible tampering detected.");
|
||||
return false;
|
||||
}
|
||||
|
||||
var encryptedBytes = Convert.FromBase64String(envelope.payload);
|
||||
var plainBytes = XorTransform(encryptedBytes, BuildKeyBytes());
|
||||
var payloadJson = Encoding.UTF8.GetString(plainBytes);
|
||||
var loadedPayload = JsonUtility.FromJson<ExpBottleLedgerPayload>(payloadJson);
|
||||
ExpBottleLedgerPayload loadedPayload = null;
|
||||
var identifierVariants = SaveIdentityUtility.GetApplicationIdentifierVariants();
|
||||
for (int i = 0; i < identifierVariants.Count; i++)
|
||||
{
|
||||
try
|
||||
{
|
||||
var plainBytes = XorTransform(encryptedBytes, BuildKeyBytes(identifierVariants[i]));
|
||||
var payloadJson = Encoding.UTF8.GetString(plainBytes);
|
||||
loadedPayload = JsonUtility.FromJson<ExpBottleLedgerPayload>(payloadJson);
|
||||
if (loadedPayload != null)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
if (loadedPayload == null)
|
||||
{
|
||||
return false;
|
||||
@@ -132,6 +154,21 @@ public static class ExpBottleLedgerStorage
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryReadPayloadFromVariants(string fileName, out ExpBottleLedgerPayload payload)
|
||||
{
|
||||
payload = CreateDefaultPayload();
|
||||
var candidates = SaveIdentityUtility.GetVaultFilePathVariants(fileName);
|
||||
for (int i = 0; i < candidates.Count; i++)
|
||||
{
|
||||
if (TryReadPayload(candidates[i], out payload))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string BuildEnvelopeJson(ExpBottleLedgerPayload payload)
|
||||
{
|
||||
payload.lastUpdatedUtcTicks = DateTime.UtcNow.Ticks;
|
||||
@@ -151,7 +188,7 @@ public static class ExpBottleLedgerStorage
|
||||
|
||||
private static string ComputeSignature(string payloadBase64)
|
||||
{
|
||||
var signText = payloadBase64 + "|" + Application.identifier + "|" + SecretSeed + "|" + SystemInfo.deviceUniqueIdentifier;
|
||||
var signText = payloadBase64 + "|" + SaveIdentityUtility.GetPrimaryApplicationIdentifier() + "|" + SecretSeed + "|" + SystemInfo.deviceUniqueIdentifier;
|
||||
using (var sha = SHA256.Create())
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(signText);
|
||||
@@ -161,14 +198,45 @@ public static class ExpBottleLedgerStorage
|
||||
}
|
||||
|
||||
private static byte[] BuildKeyBytes()
|
||||
{
|
||||
return BuildKeyBytes(SaveIdentityUtility.GetPrimaryApplicationIdentifier());
|
||||
}
|
||||
|
||||
private static byte[] BuildKeyBytes(string applicationIdentifier)
|
||||
{
|
||||
using (var sha = SHA256.Create())
|
||||
{
|
||||
var seed = Application.identifier + "|" + SystemInfo.deviceUniqueIdentifier + "|" + SecretSeed;
|
||||
var seed = applicationIdentifier + "|" + SystemInfo.deviceUniqueIdentifier + "|" + SecretSeed;
|
||||
return sha.ComputeHash(Encoding.UTF8.GetBytes(seed));
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryValidateSignature(string payloadBase64, string signature)
|
||||
{
|
||||
var identifierVariants = SaveIdentityUtility.GetApplicationIdentifierVariants();
|
||||
for (int i = 0; i < identifierVariants.Count; i++)
|
||||
{
|
||||
var expectedSignature = ComputeSignature(payloadBase64, identifierVariants[i]);
|
||||
if (string.Equals(expectedSignature, signature, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string ComputeSignature(string payloadBase64, string applicationIdentifier)
|
||||
{
|
||||
var signText = payloadBase64 + "|" + applicationIdentifier + "|" + SecretSeed + "|" + SystemInfo.deviceUniqueIdentifier;
|
||||
using (var sha = SHA256.Create())
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(signText);
|
||||
var hash = sha.ComputeHash(bytes);
|
||||
return Convert.ToBase64String(hash);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] XorTransform(byte[] source, byte[] key)
|
||||
{
|
||||
var result = new byte[source.Length];
|
||||
|
||||
@@ -14,7 +14,7 @@ public static class PlayerEconomyStorage
|
||||
|
||||
private static string VaultDirectoryPath
|
||||
{
|
||||
get { return Path.Combine(Application.persistentDataPath, VaultDirectoryName); }
|
||||
get { return Path.Combine(SaveIdentityUtility.GetCanonicalPersistentRoot(), VaultDirectoryName); }
|
||||
}
|
||||
|
||||
private static string MainFilePath
|
||||
@@ -36,12 +36,18 @@ public static class PlayerEconomyStorage
|
||||
{
|
||||
payload = CreateDefaultPayload();
|
||||
|
||||
if (TryReadPayload(MainFilePath, out payload))
|
||||
if (TryReadPayloadFromVariants(MainFileName, out payload))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (TryReadPayload(BackupFilePath, out payload))
|
||||
if (TryReadPayloadFromVariants(BackupFileName, out payload))
|
||||
{
|
||||
TrySave(payload);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (PlayerProgressBackupService.TryRestoreEconomy(out payload))
|
||||
{
|
||||
TrySave(payload);
|
||||
return true;
|
||||
@@ -70,6 +76,7 @@ public static class PlayerEconomyStorage
|
||||
File.Copy(TempFilePath, MainFilePath, true);
|
||||
TryHidePath(MainFilePath);
|
||||
File.Delete(TempFilePath);
|
||||
PlayerProgressBackupService.SaveEconomy(payload);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -107,17 +114,32 @@ public static class PlayerEconomyStorage
|
||||
return false;
|
||||
}
|
||||
|
||||
var expectedSignature = ComputeSignature(envelope.payload);
|
||||
if (!string.Equals(expectedSignature, envelope.signature, StringComparison.Ordinal))
|
||||
if (!TryValidateSignature(envelope.payload, envelope.signature))
|
||||
{
|
||||
Debug.LogWarning("[PlayerEconomyStorage] Save signature mismatch. Possible tampering detected.");
|
||||
return false;
|
||||
}
|
||||
|
||||
var encryptedBytes = Convert.FromBase64String(envelope.payload);
|
||||
var plainBytes = XorTransform(encryptedBytes, BuildKeyBytes());
|
||||
var payloadJson = Encoding.UTF8.GetString(plainBytes);
|
||||
var loadedPayload = JsonUtility.FromJson<PlayerEconomyPayload>(payloadJson);
|
||||
var identifierVariants = SaveIdentityUtility.GetApplicationIdentifierVariants();
|
||||
PlayerEconomyPayload loadedPayload = null;
|
||||
for (int i = 0; i < identifierVariants.Count; i++)
|
||||
{
|
||||
try
|
||||
{
|
||||
var plainBytes = XorTransform(encryptedBytes, BuildKeyBytes(identifierVariants[i]));
|
||||
var payloadJson = Encoding.UTF8.GetString(plainBytes);
|
||||
loadedPayload = JsonUtility.FromJson<PlayerEconomyPayload>(payloadJson);
|
||||
if (loadedPayload != null)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
if (loadedPayload == null)
|
||||
{
|
||||
return false;
|
||||
@@ -133,6 +155,21 @@ public static class PlayerEconomyStorage
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryReadPayloadFromVariants(string fileName, out PlayerEconomyPayload payload)
|
||||
{
|
||||
payload = CreateDefaultPayload();
|
||||
var candidates = SaveIdentityUtility.GetVaultFilePathVariants(fileName);
|
||||
for (int i = 0; i < candidates.Count; i++)
|
||||
{
|
||||
if (TryReadPayload(candidates[i], out payload))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string BuildEnvelopeJson(PlayerEconomyPayload payload)
|
||||
{
|
||||
payload.lastUpdatedUtcTicks = DateTime.UtcNow.Ticks;
|
||||
@@ -152,7 +189,7 @@ public static class PlayerEconomyStorage
|
||||
|
||||
private static string ComputeSignature(string payloadBase64)
|
||||
{
|
||||
var signText = payloadBase64 + "|" + Application.identifier + "|" + SecretSeed + "|" + SystemInfo.deviceUniqueIdentifier;
|
||||
var signText = payloadBase64 + "|" + SaveIdentityUtility.GetPrimaryApplicationIdentifier() + "|" + SecretSeed + "|" + SystemInfo.deviceUniqueIdentifier;
|
||||
using (var sha = SHA256.Create())
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(signText);
|
||||
@@ -162,14 +199,45 @@ public static class PlayerEconomyStorage
|
||||
}
|
||||
|
||||
private static byte[] BuildKeyBytes()
|
||||
{
|
||||
return BuildKeyBytes(SaveIdentityUtility.GetPrimaryApplicationIdentifier());
|
||||
}
|
||||
|
||||
private static byte[] BuildKeyBytes(string applicationIdentifier)
|
||||
{
|
||||
using (var sha = SHA256.Create())
|
||||
{
|
||||
var seed = Application.identifier + "|" + SystemInfo.deviceUniqueIdentifier + "|" + SecretSeed;
|
||||
var seed = applicationIdentifier + "|" + SystemInfo.deviceUniqueIdentifier + "|" + SecretSeed;
|
||||
return sha.ComputeHash(Encoding.UTF8.GetBytes(seed));
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryValidateSignature(string payloadBase64, string signature)
|
||||
{
|
||||
var identifierVariants = SaveIdentityUtility.GetApplicationIdentifierVariants();
|
||||
for (int i = 0; i < identifierVariants.Count; i++)
|
||||
{
|
||||
var expectedSignature = ComputeSignature(payloadBase64, identifierVariants[i]);
|
||||
if (string.Equals(expectedSignature, signature, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string ComputeSignature(string payloadBase64, string applicationIdentifier)
|
||||
{
|
||||
var signText = payloadBase64 + "|" + applicationIdentifier + "|" + SecretSeed + "|" + SystemInfo.deviceUniqueIdentifier;
|
||||
using (var sha = SHA256.Create())
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(signText);
|
||||
var hash = sha.ComputeHash(bytes);
|
||||
return Convert.ToBase64String(hash);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] XorTransform(byte[] source, byte[] key)
|
||||
{
|
||||
var result = new byte[source.Length];
|
||||
|
||||
@@ -78,7 +78,17 @@ public sealed class PlayerExperienceLedger : MonoBehaviour
|
||||
loadedFromSave = SecureSaveVault.TryLoadJson("player_experience", "runtime", out payload, GetLegacySavePath());
|
||||
if (payload == null)
|
||||
{
|
||||
payload = CreateDefaultPayload();
|
||||
int backupExperience;
|
||||
if (PlayerProgressBackupService.TryRestorePlayerExperience(out backupExperience))
|
||||
{
|
||||
payload = CreateDefaultPayload();
|
||||
payload.playerExp = Mathf.Max(0, backupExperience);
|
||||
loadedFromSave = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
payload = CreateDefaultPayload();
|
||||
}
|
||||
}
|
||||
|
||||
initialized = true;
|
||||
@@ -166,6 +176,7 @@ public sealed class PlayerExperienceLedger : MonoBehaviour
|
||||
|
||||
payload.lastUpdatedUtcTicks = DateTime.UtcNow.Ticks;
|
||||
SecureSaveVault.SaveJson("player_experience", "runtime", payload, GetLegacySavePath());
|
||||
PlayerProgressBackupService.SavePlayerExperience(payload.playerExp);
|
||||
SyncToPlayerData();
|
||||
NotifyExperienceChanged();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,555 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using UnityEngine;
|
||||
|
||||
[Serializable]
|
||||
public class PlayerProgressBackupBundle
|
||||
{
|
||||
public int version = 1;
|
||||
public long savedUtcTicks;
|
||||
public PlayerEconomyPayload economy;
|
||||
public int playerExperience;
|
||||
public float bestOverallRks;
|
||||
public PlayerSkillSaveData playerSkill;
|
||||
public AllyHeroDeployLedgerPayload allyHeroDeploy;
|
||||
public ExpBottleLedgerPayload expBottle;
|
||||
public DushMaterialLedgerPayload dushMaterial;
|
||||
public EquipmentConsumableLedgerPayload equipmentConsumable;
|
||||
public StoreOwnershipPayload storeOwnership;
|
||||
}
|
||||
|
||||
public static class PlayerProgressBackupService
|
||||
{
|
||||
private const string BackupFileName = "player_progress.bbackup";
|
||||
private static PlayerProgressBackupBundle s_cachedBundle;
|
||||
private static bool s_cacheLoaded;
|
||||
private static bool s_isWriting;
|
||||
|
||||
public static bool TryRestoreEconomy(out PlayerEconomyPayload payload)
|
||||
{
|
||||
payload = null;
|
||||
PlayerProgressBackupBundle bundle;
|
||||
if (!TryLoadBundle(out bundle) || bundle == null || bundle.economy == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
payload = CloneEconomy(bundle.economy);
|
||||
return payload != null;
|
||||
}
|
||||
|
||||
public static void SaveEconomy(PlayerEconomyPayload payload)
|
||||
{
|
||||
if (payload == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
UpdateBundle(bundle => bundle.economy = CloneEconomy(payload));
|
||||
}
|
||||
|
||||
public static bool TryRestorePlayerExperience(out int experience)
|
||||
{
|
||||
experience = 0;
|
||||
PlayerProgressBackupBundle bundle;
|
||||
if (!TryLoadBundle(out bundle) || bundle == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (bundle.playerExperience <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
experience = Mathf.Max(0, bundle.playerExperience);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static void SavePlayerExperience(int experience)
|
||||
{
|
||||
UpdateBundle(bundle => bundle.playerExperience = Mathf.Max(0, experience));
|
||||
}
|
||||
|
||||
public static bool TryRestoreRks(out float rks)
|
||||
{
|
||||
rks = 0f;
|
||||
PlayerProgressBackupBundle bundle;
|
||||
if (!TryLoadBundle(out bundle) || bundle == null || bundle.bestOverallRks <= 0f)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
rks = Mathf.Max(0f, bundle.bestOverallRks);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static void SaveRks(float rks)
|
||||
{
|
||||
UpdateBundle(bundle => bundle.bestOverallRks = Mathf.Max(0f, rks));
|
||||
}
|
||||
|
||||
public static bool TryRestorePlayerSkill(out PlayerSkillSaveData saveData)
|
||||
{
|
||||
saveData = null;
|
||||
PlayerProgressBackupBundle bundle;
|
||||
if (!TryLoadBundle(out bundle) || bundle == null || bundle.playerSkill == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
saveData = ClonePlayerSkill(bundle.playerSkill);
|
||||
return saveData != null;
|
||||
}
|
||||
|
||||
public static void SavePlayerSkill(PlayerSkillSaveData saveData)
|
||||
{
|
||||
if (saveData == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
UpdateBundle(bundle => bundle.playerSkill = ClonePlayerSkill(saveData));
|
||||
}
|
||||
|
||||
public static bool TryRestoreAllyHeroDeploy(out AllyHeroDeployLedgerPayload payload)
|
||||
{
|
||||
payload = null;
|
||||
PlayerProgressBackupBundle bundle;
|
||||
if (!TryLoadBundle(out bundle) || bundle == null || bundle.allyHeroDeploy == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
payload = CloneAllyHeroDeploy(bundle.allyHeroDeploy);
|
||||
return payload != null;
|
||||
}
|
||||
|
||||
public static void SaveAllyHeroDeploy(AllyHeroDeployLedgerPayload payload)
|
||||
{
|
||||
if (payload == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
UpdateBundle(bundle => bundle.allyHeroDeploy = CloneAllyHeroDeploy(payload));
|
||||
}
|
||||
|
||||
public static bool TryRestoreExpBottle(out ExpBottleLedgerPayload payload)
|
||||
{
|
||||
payload = null;
|
||||
PlayerProgressBackupBundle bundle;
|
||||
if (!TryLoadBundle(out bundle) || bundle == null || bundle.expBottle == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
payload = CloneExpBottle(bundle.expBottle);
|
||||
return payload != null;
|
||||
}
|
||||
|
||||
public static void SaveExpBottle(ExpBottleLedgerPayload payload)
|
||||
{
|
||||
if (payload == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
UpdateBundle(bundle => bundle.expBottle = CloneExpBottle(payload));
|
||||
}
|
||||
|
||||
public static bool TryRestoreDushMaterial(out DushMaterialLedgerPayload payload)
|
||||
{
|
||||
payload = null;
|
||||
PlayerProgressBackupBundle bundle;
|
||||
if (!TryLoadBundle(out bundle) || bundle == null || bundle.dushMaterial == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
payload = CloneDushMaterial(bundle.dushMaterial);
|
||||
return payload != null;
|
||||
}
|
||||
|
||||
public static void SaveDushMaterial(DushMaterialLedgerPayload payload)
|
||||
{
|
||||
if (payload == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
UpdateBundle(bundle => bundle.dushMaterial = CloneDushMaterial(payload));
|
||||
}
|
||||
|
||||
public static bool TryRestoreEquipmentConsumable(out EquipmentConsumableLedgerPayload payload)
|
||||
{
|
||||
payload = null;
|
||||
PlayerProgressBackupBundle bundle;
|
||||
if (!TryLoadBundle(out bundle) || bundle == null || bundle.equipmentConsumable == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
payload = CloneEquipmentConsumable(bundle.equipmentConsumable);
|
||||
return payload != null;
|
||||
}
|
||||
|
||||
public static void SaveEquipmentConsumable(EquipmentConsumableLedgerPayload payload)
|
||||
{
|
||||
if (payload == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
UpdateBundle(bundle => bundle.equipmentConsumable = CloneEquipmentConsumable(payload));
|
||||
}
|
||||
|
||||
public static bool TryRestoreStoreOwnership(out StoreOwnershipPayload payload)
|
||||
{
|
||||
payload = null;
|
||||
PlayerProgressBackupBundle bundle;
|
||||
if (!TryLoadBundle(out bundle) || bundle == null || bundle.storeOwnership == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
payload = CloneStoreOwnership(bundle.storeOwnership);
|
||||
return payload != null;
|
||||
}
|
||||
|
||||
public static void SaveStoreOwnership(StoreOwnershipPayload payload)
|
||||
{
|
||||
if (payload == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
UpdateBundle(bundle => bundle.storeOwnership = CloneStoreOwnership(payload));
|
||||
}
|
||||
|
||||
private static void UpdateBundle(Action<PlayerProgressBackupBundle> mutator)
|
||||
{
|
||||
if (mutator == null || s_isWriting)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
PlayerProgressBackupBundle bundle;
|
||||
if (!TryLoadBundle(out bundle) || bundle == null)
|
||||
{
|
||||
bundle = CreateDefaultBundle();
|
||||
}
|
||||
|
||||
mutator(bundle);
|
||||
bundle.savedUtcTicks = DateTime.UtcNow.Ticks;
|
||||
SaveBundle(bundle);
|
||||
}
|
||||
|
||||
private static bool TryLoadBundle(out PlayerProgressBackupBundle bundle)
|
||||
{
|
||||
if (s_cacheLoaded)
|
||||
{
|
||||
bundle = s_cachedBundle;
|
||||
return bundle != null;
|
||||
}
|
||||
|
||||
s_cacheLoaded = true;
|
||||
s_cachedBundle = null;
|
||||
|
||||
IReadOnlyList<string> candidates = SaveIdentityUtility.GetPersistentRootVariants();
|
||||
for (int i = 0; i < candidates.Count; i++)
|
||||
{
|
||||
string root = candidates[i];
|
||||
if (string.IsNullOrWhiteSpace(root))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string path = Path.Combine(root, BackupFileName);
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
string json = File.ReadAllText(path, Encoding.UTF8);
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
PlayerProgressBackupBundle loaded = JsonUtility.FromJson<PlayerProgressBackupBundle>(json);
|
||||
if (loaded == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
s_cachedBundle = loaded;
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogWarning("[PlayerProgressBackup] Failed to read backup '" + path + "': " + ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
bundle = s_cachedBundle;
|
||||
return bundle != null;
|
||||
}
|
||||
|
||||
private static void SaveBundle(PlayerProgressBackupBundle bundle)
|
||||
{
|
||||
if (bundle == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string root = SaveIdentityUtility.GetCanonicalPersistentRoot();
|
||||
string path = Path.Combine(root, BackupFileName);
|
||||
try
|
||||
{
|
||||
s_isWriting = true;
|
||||
Directory.CreateDirectory(root);
|
||||
string json = JsonUtility.ToJson(bundle, false);
|
||||
File.WriteAllText(path, json, Encoding.UTF8);
|
||||
s_cachedBundle = bundle;
|
||||
s_cacheLoaded = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogWarning("[PlayerProgressBackup] Failed to save backup '" + path + "': " + ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
s_isWriting = false;
|
||||
}
|
||||
}
|
||||
|
||||
private static PlayerProgressBackupBundle CreateDefaultBundle()
|
||||
{
|
||||
return new PlayerProgressBackupBundle
|
||||
{
|
||||
version = 1,
|
||||
savedUtcTicks = DateTime.UtcNow.Ticks
|
||||
};
|
||||
}
|
||||
|
||||
private static PlayerEconomyPayload CloneEconomy(PlayerEconomyPayload source)
|
||||
{
|
||||
if (source == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new PlayerEconomyPayload
|
||||
{
|
||||
version = source.version,
|
||||
lastUpdatedUtcTicks = source.lastUpdatedUtcTicks,
|
||||
coins = source.coins,
|
||||
material = source.material
|
||||
};
|
||||
}
|
||||
|
||||
private static PlayerSkillSaveData ClonePlayerSkill(PlayerSkillSaveData source)
|
||||
{
|
||||
if (source == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new PlayerSkillSaveData
|
||||
{
|
||||
selectedSkillIndex = source.selectedSkillIndex,
|
||||
postMatchRewardCounter = source.postMatchRewardCounter,
|
||||
skillSwitchCooldownRemainingMatches = source.skillSwitchCooldownRemainingMatches
|
||||
};
|
||||
}
|
||||
|
||||
private static AllyHeroDeployLedgerPayload CloneAllyHeroDeploy(AllyHeroDeployLedgerPayload source)
|
||||
{
|
||||
if (source == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var clone = new AllyHeroDeployLedgerPayload
|
||||
{
|
||||
version = source.version,
|
||||
lastUpdatedUtcTicks = source.lastUpdatedUtcTicks,
|
||||
entries = new List<AllyHeroDeployEntry>()
|
||||
};
|
||||
|
||||
if (source.entries != null)
|
||||
{
|
||||
for (int i = 0; i < source.entries.Count; i++)
|
||||
{
|
||||
AllyHeroDeployEntry entry = source.entries[i];
|
||||
if (entry == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
clone.entries.Add(new AllyHeroDeployEntry
|
||||
{
|
||||
heroId = entry.heroId,
|
||||
currentExp = entry.currentExp,
|
||||
unlockedTierIndex = entry.unlockedTierIndex,
|
||||
levelLock = entry.levelLock,
|
||||
autoBreakthroughEnabled = entry.autoBreakthroughEnabled,
|
||||
deployCount = entry.deployCount,
|
||||
finishCount = entry.finishCount,
|
||||
mvpCount = entry.mvpCount,
|
||||
joinDateUtcTicks = entry.joinDateUtcTicks
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return clone;
|
||||
}
|
||||
|
||||
private static ExpBottleLedgerPayload CloneExpBottle(ExpBottleLedgerPayload source)
|
||||
{
|
||||
if (source == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var clone = new ExpBottleLedgerPayload
|
||||
{
|
||||
version = source.version,
|
||||
lastUpdatedUtcTicks = source.lastUpdatedUtcTicks,
|
||||
entries = new List<ExpBottleEntry>()
|
||||
};
|
||||
|
||||
if (source.entries != null)
|
||||
{
|
||||
for (int i = 0; i < source.entries.Count; i++)
|
||||
{
|
||||
ExpBottleEntry entry = source.entries[i];
|
||||
if (entry == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
clone.entries.Add(new ExpBottleEntry
|
||||
{
|
||||
key = entry.key,
|
||||
count = entry.count
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return clone;
|
||||
}
|
||||
|
||||
private static DushMaterialLedgerPayload CloneDushMaterial(DushMaterialLedgerPayload source)
|
||||
{
|
||||
if (source == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var clone = new DushMaterialLedgerPayload
|
||||
{
|
||||
version = source.version,
|
||||
lastUpdatedUtcTicks = source.lastUpdatedUtcTicks,
|
||||
entries = new List<DushMaterialEntry>()
|
||||
};
|
||||
|
||||
if (source.entries != null)
|
||||
{
|
||||
for (int i = 0; i < source.entries.Count; i++)
|
||||
{
|
||||
DushMaterialEntry entry = source.entries[i];
|
||||
if (entry == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
clone.entries.Add(new DushMaterialEntry
|
||||
{
|
||||
key = entry.key,
|
||||
count = entry.count
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return clone;
|
||||
}
|
||||
|
||||
private static EquipmentConsumableLedgerPayload CloneEquipmentConsumable(EquipmentConsumableLedgerPayload source)
|
||||
{
|
||||
if (source == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var clone = new EquipmentConsumableLedgerPayload
|
||||
{
|
||||
version = source.version,
|
||||
lastUpdatedUtcTicks = source.lastUpdatedUtcTicks,
|
||||
entries = new List<EquipmentConsumableEntry>()
|
||||
};
|
||||
|
||||
if (source.entries != null)
|
||||
{
|
||||
for (int i = 0; i < source.entries.Count; i++)
|
||||
{
|
||||
EquipmentConsumableEntry entry = source.entries[i];
|
||||
if (entry == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
clone.entries.Add(new EquipmentConsumableEntry
|
||||
{
|
||||
key = entry.key,
|
||||
count = entry.count
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return clone;
|
||||
}
|
||||
|
||||
private static StoreOwnershipPayload CloneStoreOwnership(StoreOwnershipPayload source)
|
||||
{
|
||||
if (source == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var clone = new StoreOwnershipPayload
|
||||
{
|
||||
version = source.version,
|
||||
lastUpdatedUtcTicks = source.lastUpdatedUtcTicks,
|
||||
entries = new List<StoreOwnershipEntry>()
|
||||
};
|
||||
|
||||
if (source.entries != null)
|
||||
{
|
||||
for (int i = 0; i < source.entries.Count; i++)
|
||||
{
|
||||
StoreOwnershipEntry entry = source.entries[i];
|
||||
if (entry == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
clone.entries.Add(new StoreOwnershipEntry
|
||||
{
|
||||
storeItemId = entry.storeItemId,
|
||||
owned = entry.owned,
|
||||
unlockedStorySonIds = entry.unlockedStorySonIds != null
|
||||
? new List<int>(entry.unlockedStorySonIds)
|
||||
: new List<int>()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return clone;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c620f726faf81ae4aa12a68cd611cb81
|
||||
@@ -36,6 +36,10 @@ public static class PlayerRksService
|
||||
{
|
||||
bestOverallRks = Mathf.Max(0f, saveData.bestOverallRks);
|
||||
}
|
||||
else if (PlayerProgressBackupService.TryRestoreRks(out float backupRks))
|
||||
{
|
||||
bestOverallRks = Mathf.Max(0f, backupRks);
|
||||
}
|
||||
else if (player != null)
|
||||
{
|
||||
bestOverallRks = Mathf.Max(0f, player.URankingScore);
|
||||
@@ -66,6 +70,7 @@ public static class PlayerRksService
|
||||
{
|
||||
bestOverallRks = calculated;
|
||||
SecureSaveVault.SaveJson(SaveCategory, SaveKey, new PlayerRksSaveData { bestOverallRks = bestOverallRks });
|
||||
PlayerProgressBackupService.SaveRks(bestOverallRks);
|
||||
SyncPlayerSo(player);
|
||||
OnRksChanged?.Invoke(bestOverallRks);
|
||||
}
|
||||
|
||||
@@ -111,6 +111,11 @@ public sealed class PlayerSkillService : MonoBehaviour
|
||||
|
||||
public static void NotifySettlementCompleted(int idolScore)
|
||||
{
|
||||
if (GameConfig.autoPlayEnabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
EnsureInstance().HandleSettlementCompletedInternal(idolScore);
|
||||
}
|
||||
|
||||
@@ -185,7 +190,10 @@ public sealed class PlayerSkillService : MonoBehaviour
|
||||
initialized = true;
|
||||
if (!SecureSaveVault.TryLoadJson(SaveCategory, SaveKey, out saveData) || saveData == null)
|
||||
{
|
||||
saveData = new PlayerSkillSaveData();
|
||||
if (!PlayerProgressBackupService.TryRestorePlayerSkill(out saveData) || saveData == null)
|
||||
{
|
||||
saveData = new PlayerSkillSaveData();
|
||||
}
|
||||
}
|
||||
|
||||
ResolveSkillAssetIfNeeded();
|
||||
@@ -692,5 +700,6 @@ public sealed class PlayerSkillService : MonoBehaviour
|
||||
}
|
||||
|
||||
SecureSaveVault.SaveJson(SaveCategory, SaveKey, saveData);
|
||||
PlayerProgressBackupService.SavePlayerSkill(saveData);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using UnityEngine;
|
||||
|
||||
public static class SaveIdentityUtility
|
||||
{
|
||||
public const string StableCompanyName = "FloatGamingStudio";
|
||||
public const string StableProductName = "Bansonic";
|
||||
public const string StableApplicationIdentifier = "com.FloatGamingStudio.Bansonic";
|
||||
|
||||
private static readonly string[] LegacyProductAliases =
|
||||
{
|
||||
"Bansonic",
|
||||
"ban_total",
|
||||
"EaseOut_Main_FreshNew"
|
||||
};
|
||||
|
||||
private static readonly string[] LegacyCompanyAliases =
|
||||
{
|
||||
"FloatGamingStudio",
|
||||
"DefaultCompany"
|
||||
};
|
||||
|
||||
private static readonly string[] LegacyIdentifierAliases =
|
||||
{
|
||||
"com.DefaultCompany.Bansonic",
|
||||
"com.FloatGamingStudio.ban_total",
|
||||
"com.DefaultCompany.ban_total",
|
||||
"com.FloatGamingStudio.EaseOut_Main_FreshNew",
|
||||
"com.DefaultCompany.EaseOut_Main_FreshNew"
|
||||
};
|
||||
|
||||
public static string GetPrimaryApplicationIdentifier()
|
||||
{
|
||||
return StableApplicationIdentifier;
|
||||
}
|
||||
|
||||
public static string GetCanonicalPersistentRoot()
|
||||
{
|
||||
if (Application.platform == RuntimePlatform.WindowsEditor || Application.platform == RuntimePlatform.WindowsPlayer)
|
||||
{
|
||||
string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
string localLow = Path.GetFullPath(Path.Combine(localAppData, "..", "LocalLow"));
|
||||
return Path.Combine(localLow, StableCompanyName, StableProductName);
|
||||
}
|
||||
|
||||
return Application.persistentDataPath;
|
||||
}
|
||||
|
||||
public static IReadOnlyList<string> GetApplicationIdentifierVariants()
|
||||
{
|
||||
var result = new List<string>();
|
||||
AddDistinct(result, StableApplicationIdentifier);
|
||||
AddDistinct(result, Application.identifier);
|
||||
|
||||
string currentCompany = SafeTrim(Application.companyName);
|
||||
string currentProduct = SafeTrim(Application.productName);
|
||||
if (!string.IsNullOrEmpty(currentCompany) && !string.IsNullOrEmpty(currentProduct))
|
||||
{
|
||||
AddDistinct(result, $"com.{currentCompany}.{currentProduct}");
|
||||
}
|
||||
|
||||
for (int i = 0; i < LegacyIdentifierAliases.Length; i++)
|
||||
{
|
||||
AddDistinct(result, LegacyIdentifierAliases[i]);
|
||||
}
|
||||
|
||||
for (int companyIndex = 0; companyIndex < LegacyCompanyAliases.Length; companyIndex++)
|
||||
{
|
||||
for (int productIndex = 0; productIndex < LegacyProductAliases.Length; productIndex++)
|
||||
{
|
||||
AddDistinct(result, $"com.{LegacyCompanyAliases[companyIndex]}.{LegacyProductAliases[productIndex]}");
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public static IReadOnlyList<string> GetPersistentRootVariants(string extraLegacyPath = null)
|
||||
{
|
||||
var result = new List<string>();
|
||||
|
||||
AddDistinct(result, GetCanonicalPersistentRoot());
|
||||
AddDistinct(result, Application.persistentDataPath);
|
||||
|
||||
string extraDirectory = string.IsNullOrEmpty(extraLegacyPath) ? null : Path.GetDirectoryName(extraLegacyPath);
|
||||
AddDistinct(result, extraDirectory);
|
||||
|
||||
if (Application.platform == RuntimePlatform.WindowsEditor || Application.platform == RuntimePlatform.WindowsPlayer)
|
||||
{
|
||||
string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
string localLow = Path.GetFullPath(Path.Combine(localAppData, "..", "LocalLow"));
|
||||
|
||||
string currentCompany = SafeTrim(Application.companyName);
|
||||
string currentProduct = SafeTrim(Application.productName);
|
||||
if (!string.IsNullOrEmpty(currentCompany) && !string.IsNullOrEmpty(currentProduct))
|
||||
{
|
||||
AddDistinct(result, Path.Combine(localLow, currentCompany, currentProduct));
|
||||
}
|
||||
|
||||
for (int companyIndex = 0; companyIndex < LegacyCompanyAliases.Length; companyIndex++)
|
||||
{
|
||||
for (int productIndex = 0; productIndex < LegacyProductAliases.Length; productIndex++)
|
||||
{
|
||||
AddDistinct(result, Path.Combine(localLow, LegacyCompanyAliases[companyIndex], LegacyProductAliases[productIndex]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public static IReadOnlyList<string> GetVaultFilePathVariants(string fileName)
|
||||
{
|
||||
var result = new List<string>();
|
||||
if (string.IsNullOrWhiteSpace(fileName))
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
IReadOnlyList<string> roots = GetPersistentRootVariants();
|
||||
for (int i = 0; i < roots.Count; i++)
|
||||
{
|
||||
string root = roots[i];
|
||||
if (string.IsNullOrWhiteSpace(root))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
AddDistinct(result, Path.Combine(root, ".cache_bridge", fileName));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void AddDistinct(List<string> target, string value)
|
||||
{
|
||||
if (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 string SafeTrim(string value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: acd513b6738225b46ab31a779eb308fa
|
||||
@@ -27,7 +27,7 @@ public static class SecureSaveVault
|
||||
|
||||
private static string VaultDirectoryPath
|
||||
{
|
||||
get { return Path.Combine(Application.persistentDataPath, VaultDirectoryName); }
|
||||
get { return Path.Combine(SaveIdentityUtility.GetCanonicalPersistentRoot(), VaultDirectoryName); }
|
||||
}
|
||||
|
||||
public static bool SaveJson<T>(string category, string key, T data, string legacyPlainPath = null)
|
||||
@@ -121,20 +121,11 @@ public static class SecureSaveVault
|
||||
return false;
|
||||
}
|
||||
|
||||
string mainPath = GetFilePath(category, key, ".dat");
|
||||
string backupPath = GetFilePath(category, key, ".bak");
|
||||
|
||||
if (TryReadEncryptedFile(category, key, mainPath, out json))
|
||||
if (TryLoadFromCurrentOrLegacyEncryptedFiles(category, key, legacyPlainPath, 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
|
||||
@@ -262,8 +253,7 @@ public static class SecureSaveVault
|
||||
return false;
|
||||
}
|
||||
|
||||
string expectedSignature = ComputeSignature(category, envelope.payload);
|
||||
if (!string.Equals(expectedSignature, envelope.signature, StringComparison.Ordinal))
|
||||
if (!TryValidateSignature(category, envelope.payload, envelope.signature))
|
||||
{
|
||||
Debug.LogWarning($"[SecureSaveVault] Signature mismatch ({category}/{key}). Possible tampering detected.");
|
||||
return false;
|
||||
@@ -329,7 +319,7 @@ public static class SecureSaveVault
|
||||
|
||||
private static string ComputeSignature(string category, string payloadBase64)
|
||||
{
|
||||
string signText = payloadBase64 + "|" + category + "|" + Application.identifier + "|" + SecretSeed + "|" + SystemInfo.deviceUniqueIdentifier;
|
||||
string signText = payloadBase64 + "|" + category + "|" + SaveIdentityUtility.GetPrimaryApplicationIdentifier() + "|" + SecretSeed + "|" + SystemInfo.deviceUniqueIdentifier;
|
||||
using (var sha = SHA256.Create())
|
||||
{
|
||||
byte[] hash = sha.ComputeHash(Encoding.UTF8.GetBytes(signText));
|
||||
@@ -350,7 +340,7 @@ public static class SecureSaveVault
|
||||
{
|
||||
aes.Mode = CipherMode.CBC;
|
||||
aes.Padding = PaddingMode.PKCS7;
|
||||
aes.Key = BuildAesKey(category, key);
|
||||
aes.Key = BuildAesKey(category, key, SaveIdentityUtility.GetPrimaryApplicationIdentifier());
|
||||
aes.GenerateIV();
|
||||
using (var encryptor = aes.CreateEncryptor())
|
||||
{
|
||||
@@ -374,28 +364,44 @@ public static class SecureSaveVault
|
||||
return plainBytes != null;
|
||||
}
|
||||
#endif
|
||||
using (var aes = Aes.Create())
|
||||
IReadOnlyList<string> identifierVariants = SaveIdentityUtility.GetApplicationIdentifierVariants();
|
||||
for (int i = 0; i < identifierVariants.Count; i++)
|
||||
{
|
||||
aes.Mode = CipherMode.CBC;
|
||||
aes.Padding = PaddingMode.PKCS7;
|
||||
aes.Key = BuildAesKey(category, key);
|
||||
int ivLength = aes.BlockSize / 8;
|
||||
if (protectedBytes == null || protectedBytes.Length <= ivLength)
|
||||
using (var aes = Aes.Create())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
aes.Mode = CipherMode.CBC;
|
||||
aes.Padding = PaddingMode.PKCS7;
|
||||
aes.Key = BuildAesKey(category, key, identifierVariants[i]);
|
||||
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;
|
||||
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)
|
||||
{
|
||||
@@ -416,7 +422,7 @@ public static class SecureSaveVault
|
||||
|
||||
try
|
||||
{
|
||||
protectedBytes = s_dpapiProtectMethod.Invoke(null, new object[] { plainBytes, BuildEntropy(category), s_dpapiCurrentUserScope }) as byte[];
|
||||
protectedBytes = s_dpapiProtectMethod.Invoke(null, new object[] { plainBytes, BuildEntropy(category, SaveIdentityUtility.GetPrimaryApplicationIdentifier()), s_dpapiCurrentUserScope }) as byte[];
|
||||
return protectedBytes != null && protectedBytes.Length > 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -435,16 +441,24 @@ public static class SecureSaveVault
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
IReadOnlyList<string> identifierVariants = SaveIdentityUtility.GetApplicationIdentifierVariants();
|
||||
for (int i = 0; i < identifierVariants.Count; i++)
|
||||
{
|
||||
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;
|
||||
try
|
||||
{
|
||||
plainBytes = s_dpapiUnprotectMethod.Invoke(null, new object[] { protectedBytes, BuildEntropy(category, identifierVariants[i]), s_dpapiCurrentUserScope }) as byte[];
|
||||
if (plainBytes != null && plainBytes.Length > 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
plainBytes = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool EnsureDpapi()
|
||||
@@ -492,29 +506,116 @@ public static class SecureSaveVault
|
||||
}
|
||||
#endif
|
||||
|
||||
private static byte[] BuildEntropy(string category)
|
||||
private static byte[] BuildEntropy(string category, string applicationIdentifier)
|
||||
{
|
||||
string seed = Application.identifier + "|" + SystemInfo.deviceUniqueIdentifier + "|" + SecretSeed + "|" + category;
|
||||
string seed = applicationIdentifier + "|" + SystemInfo.deviceUniqueIdentifier + "|" + SecretSeed + "|" + category;
|
||||
using (var sha = SHA256.Create())
|
||||
{
|
||||
return sha.ComputeHash(Encoding.UTF8.GetBytes(seed));
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] BuildAesKey(string category, string key)
|
||||
private static byte[] BuildAesKey(string category, string key, string applicationIdentifier)
|
||||
{
|
||||
return BuildEntropy(category);
|
||||
using (var sha = SHA256.Create())
|
||||
{
|
||||
string seed = applicationIdentifier + "|" + SystemInfo.deviceUniqueIdentifier + "|" + 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 + "|" + Application.identifier + "|" + SecretSeed));
|
||||
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();
|
||||
for (int i = 0; i < identifierVariants.Count; i++)
|
||||
{
|
||||
string expectedSignature = ComputeSignature(category, payloadBase64, identifierVariants[i]);
|
||||
if (string.Equals(expectedSignature, signature, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string ComputeSignature(string category, string payloadBase64, string applicationIdentifier)
|
||||
{
|
||||
string signText = payloadBase64 + "|" + category + "|" + applicationIdentifier + "|" + SecretSeed + "|" + SystemInfo.deviceUniqueIdentifier;
|
||||
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 void DeleteLegacyPlainFile(string legacyPlainPath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(legacyPlainPath) || !File.Exists(legacyPlainPath))
|
||||
|
||||
@@ -14,7 +14,7 @@ public static class StoreOwnershipStorage
|
||||
|
||||
private static string VaultDirectoryPath
|
||||
{
|
||||
get { return Path.Combine(Application.persistentDataPath, VaultDirectoryName); }
|
||||
get { return Path.Combine(SaveIdentityUtility.GetCanonicalPersistentRoot(), VaultDirectoryName); }
|
||||
}
|
||||
|
||||
private static string MainFilePath
|
||||
@@ -36,12 +36,18 @@ public static class StoreOwnershipStorage
|
||||
{
|
||||
payload = CreateDefaultPayload();
|
||||
|
||||
if (TryReadPayload(MainFilePath, out payload))
|
||||
if (TryReadPayloadFromVariants(MainFileName, out payload))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (TryReadPayload(BackupFilePath, out payload))
|
||||
if (TryReadPayloadFromVariants(BackupFileName, out payload))
|
||||
{
|
||||
TrySave(payload);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (PlayerProgressBackupService.TryRestoreStoreOwnership(out payload))
|
||||
{
|
||||
TrySave(payload);
|
||||
return true;
|
||||
@@ -70,6 +76,7 @@ public static class StoreOwnershipStorage
|
||||
File.Copy(TempFilePath, MainFilePath, true);
|
||||
TryHidePath(MainFilePath);
|
||||
File.Delete(TempFilePath);
|
||||
PlayerProgressBackupService.SaveStoreOwnership(payload);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -106,17 +113,32 @@ public static class StoreOwnershipStorage
|
||||
return false;
|
||||
}
|
||||
|
||||
var expectedSignature = ComputeSignature(envelope.payload);
|
||||
if (!string.Equals(expectedSignature, envelope.signature, StringComparison.Ordinal))
|
||||
if (!TryValidateSignature(envelope.payload, envelope.signature))
|
||||
{
|
||||
Debug.LogWarning("[StoreOwnershipStorage] Save signature mismatch. Possible tampering detected.");
|
||||
return false;
|
||||
}
|
||||
|
||||
var encryptedBytes = Convert.FromBase64String(envelope.payload);
|
||||
var plainBytes = XorTransform(encryptedBytes, BuildKeyBytes());
|
||||
var payloadJson = Encoding.UTF8.GetString(plainBytes);
|
||||
var loadedPayload = JsonUtility.FromJson<StoreOwnershipPayload>(payloadJson);
|
||||
StoreOwnershipPayload loadedPayload = null;
|
||||
var identifierVariants = SaveIdentityUtility.GetApplicationIdentifierVariants();
|
||||
for (int i = 0; i < identifierVariants.Count; i++)
|
||||
{
|
||||
try
|
||||
{
|
||||
var plainBytes = XorTransform(encryptedBytes, BuildKeyBytes(identifierVariants[i]));
|
||||
var payloadJson = Encoding.UTF8.GetString(plainBytes);
|
||||
loadedPayload = JsonUtility.FromJson<StoreOwnershipPayload>(payloadJson);
|
||||
if (loadedPayload != null)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
if (loadedPayload == null)
|
||||
{
|
||||
return false;
|
||||
@@ -132,6 +154,21 @@ public static class StoreOwnershipStorage
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryReadPayloadFromVariants(string fileName, out StoreOwnershipPayload payload)
|
||||
{
|
||||
payload = CreateDefaultPayload();
|
||||
var candidates = SaveIdentityUtility.GetVaultFilePathVariants(fileName);
|
||||
for (int i = 0; i < candidates.Count; i++)
|
||||
{
|
||||
if (TryReadPayload(candidates[i], out payload))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string BuildEnvelopeJson(StoreOwnershipPayload payload)
|
||||
{
|
||||
payload.lastUpdatedUtcTicks = DateTime.UtcNow.Ticks;
|
||||
@@ -151,7 +188,7 @@ public static class StoreOwnershipStorage
|
||||
|
||||
private static string ComputeSignature(string payloadBase64)
|
||||
{
|
||||
var signText = payloadBase64 + "|" + Application.identifier + "|" + SecretSeed + "|" + SystemInfo.deviceUniqueIdentifier;
|
||||
var signText = payloadBase64 + "|" + SaveIdentityUtility.GetPrimaryApplicationIdentifier() + "|" + SecretSeed + "|" + SystemInfo.deviceUniqueIdentifier;
|
||||
using (var sha = SHA256.Create())
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(signText);
|
||||
@@ -161,14 +198,45 @@ public static class StoreOwnershipStorage
|
||||
}
|
||||
|
||||
private static byte[] BuildKeyBytes()
|
||||
{
|
||||
return BuildKeyBytes(SaveIdentityUtility.GetPrimaryApplicationIdentifier());
|
||||
}
|
||||
|
||||
private static byte[] BuildKeyBytes(string applicationIdentifier)
|
||||
{
|
||||
using (var sha = SHA256.Create())
|
||||
{
|
||||
var seed = Application.identifier + "|" + SystemInfo.deviceUniqueIdentifier + "|" + SecretSeed;
|
||||
var seed = applicationIdentifier + "|" + SystemInfo.deviceUniqueIdentifier + "|" + SecretSeed;
|
||||
return sha.ComputeHash(Encoding.UTF8.GetBytes(seed));
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryValidateSignature(string payloadBase64, string signature)
|
||||
{
|
||||
var identifierVariants = SaveIdentityUtility.GetApplicationIdentifierVariants();
|
||||
for (int i = 0; i < identifierVariants.Count; i++)
|
||||
{
|
||||
var expectedSignature = ComputeSignature(payloadBase64, identifierVariants[i]);
|
||||
if (string.Equals(expectedSignature, signature, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string ComputeSignature(string payloadBase64, string applicationIdentifier)
|
||||
{
|
||||
var signText = payloadBase64 + "|" + applicationIdentifier + "|" + SecretSeed + "|" + SystemInfo.deviceUniqueIdentifier;
|
||||
using (var sha = SHA256.Create())
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(signText);
|
||||
var hash = sha.ComputeHash(bytes);
|
||||
return Convert.ToBase64String(hash);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] XorTransform(byte[] source, byte[] key)
|
||||
{
|
||||
var result = new byte[source.Length];
|
||||
|
||||
Reference in New Issue
Block a user