I'm developing a game using Unity engine. Currently, I'm on my very first journey to creating a secure and universal game data saving/loading. My game is able to succesfully save its data (game progress) and metadata (custom savable types, encapsulating the data, and necessary for succesfull data deserialization) into two files, but when it comes to loading the data, a weird error occurs upon decoding. It appears a really weird one to me because I googled similar error topics but wasn't able to find a satisfying answer.
The error and its stacktrace are:
CryptographicException: Bad PKCS7 padding. Invalid length 0. Mono.Security.Cryptography.SymmetricTransform.ThrowBadPaddingException (System.Security.Cryptography.PaddingMode padding, System.Int32 length, System.Int32 position) (at <9aad1b3a47484d63ba2b3985692d80e9>:0) Mono.Security.Cryptography.SymmetricTransform.FinalDecrypt (System.Byte[] inputBuffer, System.Int32 inputOffset, System.Int32 inputCount) (at <9aad1b3a47484d63ba2b3985692d80e9>:0) Mono.Security.Cryptography.SymmetricTransform.TransformFinalBlock (System.Byte[] inputBuffer, System.Int32 inputOffset, System.Int32 inputCount) (at <9aad1b3a47484d63ba2b3985692d80e9>:0) System.Security.Cryptography.CryptoStream.FlushFinalBlock () (at <9aad1b3a47484d63ba2b3985692d80e9>:0) System.Security.Cryptography.CryptoStream.Dispose (System.Boolean disposing) (at <9aad1b3a47484d63ba2b3985692d80e9>:0) System.IO.Stream.Close () (at <9aad1b3a47484d63ba2b3985692d80e9>:0) System.IO.StreamReader.Dispose (System.Boolean disposing) (at <9aad1b3a47484d63ba2b3985692d80e9>:0) System.IO.TextReader.Dispose () (at <9aad1b3a47484d63ba2b3985692d80e9>:0) AuxMath.Decode (System.String input, System.Security.Cryptography.Aes decoder, System.Text.Encoding encoding) (at Assets/Scripts/Misc/AuxMath.cs:191) SavingSystem.TryLoadMetadata (System.Security.Cryptography.Aes decoder, System.Text.Encoding encoding) (at Assets/Scripts/Saving System/SavingSystem.cs:164) Rethrow as Exception: Metadata loading failed! SavingSystem.TryLoadMetadata (System.Security.Cryptography.Aes decoder, System.Text.Encoding encoding) (at Assets/Scripts/Saving System/SavingSystem.cs:180) SavingSystem.Load () (at Assets/Scripts/Saving System/SavingSystem.cs:82) SavingSystem.Awake () (at Assets/Scripts/Saving System/SavingSystem.cs:43)
My saving/loading.
private void Save()
{
Aes encoder = Aes.Create();
encoder.Key = _keyContainer.Key;
PrepareSavableData();
SaveGameData(encoder, Encoding.UTF8);
SaveMetadata(encoder, Encoding.UTF8);
SavegameCompleted?.Invoke(this, EventArgs.Empty);
}
private bool Load()
{
Aes decoder = Aes.Create();
decoder.Key = _keyContainer.Key;
if (TryLoadMetadata(decoder, Encoding.UTF8) && TryLoadGameData(decoder, Encoding.UTF8))
{
return true;
}
return false;
}
The key for encryption is created randomly using default Aes settings and stored inside a KeyContainer ScriptableObject.
Here is the actual saving.
private void PrepareSavableData()
{
foreach (var entity in _registeredEntities)
{
_storedStates[entity.ID] = entity.GetState();
}
}
private void SaveMetadata(Aes encoder, Encoding encoding)
{
using FileStream fileStream = new(MetadataPath, FileMode.Create, FileAccess.Write);
using StreamWriter writer = new(fileStream, encoding);
List<string> knownTypesNames = new(_knownSavableDataCustomTypes.Count);
foreach (var type in _knownSavableDataCustomTypes)
{
knownTypesNames.Add(type.ToString());
}
string data = AuxMath.SerializeObjectToString(knownTypesNames, encoding);
string encodedData = AuxMath.Encode(data, encoder, encoding);
writer.Write(encodedData);
writer.Close();
}
private bool TryLoadMetadata(Aes decoder, Encoding encoding)
{
if (File.Exists(MetadataPath))
{
try
{
using FileStream fileStream = new(MetadataPath, FileMode.Open, FileAccess.Read);
using StreamReader reader = new(fileStream, encoding);
string encodedData = reader.ReadToEnd();
string decodedData = AuxMath.Decode(encodedData, decoder, encoding);
var knownTypesNames = AuxMath.DeserializeStringToObject<List<string>>(decodedData, encoding, _knownSavableDataCustomTypes);
HashSet<Type> knownTypes = new(knownTypesNames.Count);
foreach (var typeName in knownTypesNames)
{
knownTypes.Add(Type.GetType(typeName));
}
_knownSavableDataCustomTypes.UnionWith(knownTypes);
return true;
}
catch (Exception e)
{
throw new Exception("Metadata loading failed!", e);
}
}
return false;
}
private void SaveGameData(Aes encoder, Encoding encoding)
{
using FileStream fileStream = new(SavegamePath, FileMode.Create, FileAccess.Write);
using StreamWriter writer = new(fileStream, encoding);
string data = AuxMath.SerializeObjectToString(_storedStates, encoding);
string encodedData = AuxMath.Encode(data, encoder, encoding);
writer.Write(encodedData);
writer.Close();
}
private bool TryLoadGameData(Aes decoder, Encoding encoding)
{
if (File.Exists(SavegamePath))
{
try
{
using FileStream fileStream = new(SavegamePath, FileMode.Open, FileAccess.Read);
using StreamReader reader = new(fileStream, encoding);
string encodedData = reader.ReadToEnd();
string decodedData = AuxMath.Decode(encodedData, decoder, encoding);
_storedStates = AuxMath.DeserializeStringToObject<Dictionary<string, IEnumerable<object>>>(decodedData, encoding, _knownSavableDataCustomTypes);
return true;
}
catch (Exception e)
{
throw new Exception("Game data loading failed!", e);
}
}
return false;
}
I'm using DataContractSerializer to convert custom object types with a valuable game data to XML string representation in preparation for encoding/decoding.
public static string SerializeObjectToString(object obj, Encoding encoding)
{
if (obj is null)
{
throw new ArgumentNullException($"{nameof(obj)}", "Cannot serialize a null object!");
}
using MemoryStream memoryStream = new();
using StreamReader reader = new(memoryStream, encoding);
DataContractSerializer serializer = new(obj.GetType());
serializer.WriteObject(memoryStream, obj);
memoryStream.Position = 0;
return reader.ReadToEnd();
}
public static T DeserializeStringToObject<T>(string objectAsXml, Encoding encoding, IEnumerable<Type> knownTypes)
{
if (string.IsNullOrEmpty(objectAsXml))
{
throw new ArgumentNullException($"{nameof(objectAsXml)}", "Data is empty!");
}
if (knownTypes is null)
{
throw new ArgumentException("Known types are not supplied! Deserialization will fail!", $"{nameof(knownTypes)}");
}
using MemoryStream memoryStream = new();
byte[] xmlAsBytes = encoding.GetBytes(objectAsXml);
DataContractSerializer deserializer = new(typeof(T), knownTypes);
memoryStream.Write(xmlAsBytes, 0, xmlAsBytes.Length);
memoryStream.Position = 0;
if (deserializer.ReadObject(memoryStream) is T value)
{
return value;
}
else
{
throw new Exception("Passed data is invalid or corrupted and cannot be restored!");
}
}
Finally, encoding and decoding. Encryption algorithm gets a new initialization vector on every encoding. It gets written unencryptedly directly into the stream, before the encrypted stream writes the secured data. Upon decryption it is necessary to read 16 bytes first from the stream, as they represent the decryption initialization vector.
public static string Encode(string input, Aes encoder, Encoding encoding)
{
if (string.IsNullOrEmpty(input))
{
throw new ArgumentNullException($"{nameof(input)}", "Attempted to encode an empty input!");
}
if (encoder is null)
{
throw new ArgumentNullException($"{nameof(encoder)}", "Encoder is not set!");
}
encoder.GenerateIV();
using MemoryStream memoryStream = new();
using CryptoStream encodingStream = new(memoryStream, encoder.CreateEncryptor(), CryptoStreamMode.Write);
using StreamWriter encodedWriter = new(encodingStream, encoding);
memoryStream.Write(encoder.IV);
encodedWriter.Write(input);
memoryStream.Position = 0;
encodedWriter.Close();
return encoding.GetString(memoryStream.ToArray());
}
public static string Decode(string input, Aes decoder, Encoding encoding)
{
if (string.IsNullOrEmpty(input))
{
throw new ArgumentNullException($"{nameof(input)}", "Attempted to decode an empty input!");
}
if (decoder is null)
{
throw new ArgumentNullException($"{nameof(decoder)}", "Decoder is not set!");
}
using MemoryStream memoryStream = new();
memoryStream.Write(encoding.GetBytes(input));
byte[] iv = new byte[decoder.IV.Length];
memoryStream.Read(iv, 0, decoder.IV.Length);
decoder.IV = iv;
using CryptoStream decodingStream = new(memoryStream, decoder.CreateDecryptor(), CryptoStreamMode.Read);
using StreamReader decodedReader = new(decodingStream, encoding);
return decodedReader.ReadToEnd();
}