Encrypting Game Data with Unity

It’s a matter of trust. In any context where a game can open, use, or save files where players can also access them, there is always the chance a user, system, or process may change a file. On PC and Mac platforms, there is also a chance a player may simply skip over editing files and change values directly in the RAM itself, subverting any attempt to protect files. Depending on the resources and time available to a player, they could also attempt to decompile parts of the game and figure out where any secret keys are stored within the code or how everything is arranged within its internal data structures.

While some compilation processes can help protect the game’s running memory, and there are approaches to better protecting files such as encrypting them, the root issue is still one of trust. If, as a developer, you can trust your players to not change the game’s files, a more simple solution of reading and writing text files might be the best option. If you anticipate some users might attempt to change values in something like a save file, using something like encryption for sensitive data might be a good option. If you feel you cannot trust your players at all, remember the only perfect solution for complete safety is for no players to ever play your game. All digital interactions come with some level of information risk.

Problems With Existing Game Data File Operations

All of the common solutions for working with game data when using Unity all also come with the risk the player might change the files holding the data. This is true of working with BinaryFormatter and JsonUtility, where game objects are serialized into an easier format to save the data, and with using PlayerPrefs as well. Through using local files on the local system, they all risk a player, or some other user, system, or process, accidently or purposely changing the data files.

One approach to solving part of these issues is to use encryption. This makes the files harder to understand for systems, process, and users who hope to overwrite them with malicious values. However, it will not protect against accidental corruption. There is still the possibility of a file getting corrupted for any numbers of reasons.

Using encryption also comes with greater complexity for the developer. As part of using the data, it must be encrypted before being written to a file and unencrypted before using the values. This introduces extra processing time, and the need for more code as part of the saving and loading operations.

Symmetric and Asymmetric Encryption

The terms “symmetric” and “asymmetric,” as used with encryption, answer the question of “Who is trusted?” In symmetric encryption, at least two parties have the “secret key” used to create the encrypted data and can use it to read and write the data. The use of the “secret key” gives them access. This is different from asymmetric encryption, which is also called public-key encryption. In this case, there are two keys used to generate the encrypted data: a public key and a private key. The public key is “known” and can be used to generate the encrypted data. The private key, when used with the public key, can be used to decrypt the data.

In general, asymmetric encryption is far stronger than symmetric encryption. However, it is also more complex and much slower relative to the data size. Put in very general terms:

  • Symmetric: Faster, but less secure.
  • Asymmetric: Slower, but more secure.

Generally, because asymmetric encryption depends on a known public key, it is not as useful for game data. It could be used in situations where a server was involved and the public key accessed, but most games install to a local device without access to a public key. This makes symmetric encryption a “good” choice for most use cases where encryption might be needed, but there is not the ability to more widely share a public key when using the game.

DES (Data Encryption Standard) and AES (Advanced Encryption Standard)

The acronyms DES and AES refer to “Standards,” but are also algorithms following the standard on which they are based. Because of some known attacks on DES, many developers use AES instead of DES. However, DES can still be used for many situations.

While C# supports both DES and AES, AES will be used in this post as part of example code.

Understanding AES

The class Aes is part of the namespace System.Security.Cryptography.

Aes aes = Aes.Create();

Unlike many other classes, Aes has a special Create() method for creating a new object and setting some initial values.

// Create new AES instance.
Aes iAes = Aes.Create();

// Save the generated key
byte[] savedKey = iAes.Key;

// Save the generated IV
byte[] inputIV = iAes.IV;

Because Aes works on data itself, all of of its operations work on byte arrays, reading or writing data without specific data types.

What is key and IV?

AES encryption works through using two pieces of data: a key and an IV. The first is the secret key used as part of the encryption and decryption process. Whomever has the key can access the data. The second, the IV, is the initialization vector. This tells the algorithm where to “start” as it encrypts the data. Based on different combinations of key and IV values, the resulting encrypted output will be different.

Because the IV is not protected data, it will often be stored with or as part of the file itself. As long as at least one part of the pair, usually the key itself, is private, parties can encrypt and decrypt the data.

In C#, unless explicitly overwritten, the use of the Create() method of the Aes class will generate new random key and IV values. For the purposes of decryption, both should be saved. As this example code will show, the key can be saved as part of the application and the IV saved as part of the encrypted game data file.

Streams Wrapping Streams Wrapping Streams

C# uses streams for all input and output operations. This also includes working with encryption and decryption as part of a special class called CryptoStream. This stream can often “wrap” other streams classes like FileStream, providing a way to encrypt or decrypt data as it passes from one stream to the next. Often, to help the process of working with streams themselves, a third stream class is used, StreamReader or StreamWriter.

The effect of having three different streams is each “wrapping” the other. Data is read or written to a StreamReader or StreamWriter, which reads or writes data to a CryptoStream, encrypting or decrypting the data as asked, and then, finally, working with a FileStream to read or write the data to a file.

Code Fragment of Multiple Streams

// Update Unity persistent path
string saveFile = Application.persistentDataPath + "/gamedata.json";

// Create FileStream for writing
FileStream dataStream = new FileStream(saveFile, FileMode.Create);

// Create (wrap) the FileStream in a CryptoStream for writing
CryptoStream iStream = new CryptoStream(
                dataStream,
                iAes.CreateEncryptor(iAes.Key, iAes.IV),
                CryptoStreamMode.Write);

// Create (wrap) the CryptoStream in a StreamWriter
StreamWriter sWriter = new StreamWriter(iStream);

// Write to the innermost stream (which will encrypt).
sWriter.Write("Hello World!");

// Close innermost.
sWriter.Close();

// Close crytostream
iStream.Close();

// Close FileStream.
dataStream.Close();

With three different streams, they should be used in a specific order:

  • Writing: FileStream –> CryptoStream –> StreamWriter.
  • Reading: FileStream –> CryptoStream –> StreamReader

Working with GameData and GameDataManager

According to the concept of encapsulation in object-oriented programming, similar values should be kept as a single unit. This means having one class, GameData, holding the values to be used as part of working with the application and a separate class, GameDataManager, to work with the data operations, “managing” the game data.

System.Serializable

C# uses the concept of attributes to let the run-time know extra information about some data. In Unity, the attribute Serializable or System.Serializable signals to the C# run-time used within Unity some code, usually a class, can be “serialized” (converted from run-time data to another format). It is frequently used as part of other data saving techniques such as working with the JsonUtility class.

[System.Serializable]
public class GameData
{
    // Public lives
    public int Lives;

    // Public highScore
    public int HighScore;
}

In the above example, the class GameData has two fields, Lives and HighScore. Because these are public, they can be accessed by other code.

Where are files in Unity?

While a Unity project is running, it provides a field called Application.persistentDataPath set with the current “persistent” directory. This is set when the project starts and can only be used as part of the Initialization System within Unity as part of messages such as the method Awake() or Start() within a Scripting Component.

JSON File Example

void Awake()
{
  // Update the path once the persistent path exists.
  saveFile = Application.persistentDataPath + "/gamedata.json";
}

The field Application.persistentDataPath only contains a directory. To use a file, an additional filename is needed such as the above example where a “/” and the name of the file is used.

Writing and Reading IV

Working with Aes adds the extra complication of needing to save the IV used to create the encrypted data in order to read it. As this is not private data, it can be added to the start of a file and then read from the same place.

Code Fragment for Writing IV

// Create new AES instance.
Aes iAes = Aes.Create();


// Create a FileStream
dataStream = new FileStream(saveFile, FileMode.Create);

// Save the generated IV
byte[] inputIV = iAes.IV;
// Write it to the data stream
dataStream.Write(inputIV, 0, inputIV.Length);

Code Fragment for Reading IV

// Create a FileStream
dataStream = new FileStream(saveFile, FileMode.Open);

// Create new AES instance.
Aes oAes = Aes.Create();

// Crete an array of correct size
byte[] outputIV = new byte[oAes.IV.Length];
// Read its length
dataStream.Read(outputIV, 0, outputIV.Length);

Working with JsonUtility

The use of the Aes class provides a way encrypt or decrypt data. When working with streams, data can be read or written using “layers” of streams. However, none of these operations provide a way to serialize and deserialize data from files into game data. For that purpose, the JsonUtility class can be used.

As the JsonUtility class works on String values, this makes it ideal for use with reading and writing values using the existing StreamReader and StreamWriter classes.

Updated GameDataManager Code

// Add System.IO to work with files!
using System.IO;
// Add System.Security.Crytography to use Encryption!
using System.Security.Cryptography;
using UnityEngine;

public class GameDataManager : MonoBehaviour
{
    // Create a field for the save file.
    string saveFile;

    // Create a GameData field.
    public GameData gameData = new GameData();

    // FileStream used for reading and writing files.
    FileStream dataStream;

    // Key for reading and writing encrypted data.
    byte[] savedKey;

    void Awake()
    {
        // Update the path once the persistent path exists.
        saveFile = Application.persistentDataPath + "/gamedata.json";
    }

    public void readFile()
    {
        // Does the file exist?
        if (File.Exists(saveFile))
        {
            // Create FileStream for opening files.
            dataStream = new FileStream(saveFile, FileMode.Open);

            // Create new AES instance.
            Aes oAes = Aes.Create();

            // Create an array of correct size based on AES IV.
            byte[] outputIV = new byte[oAes.IV.Length];
            
            // Read the IV from the file.
            dataStream.Read(outputIV, 0, outputIV.Length);

            // Create CryptoStream, wrapping FileStream
            CryptoStream oStream = new CryptoStream(
                   dataStream,
                   oAes.CreateDecryptor(savedKey, outputIV),
                   CryptoStreamMode.Read);

            // Create a StreamReader, wrapping CryptoStream
            StreamReader reader = new StreamReader(oStream);
            
            // Read the entire file into a String value.
            string text = reader.ReadToEnd();

            // Deserialize the JSON data 
            //  into a pattern matching the GameData class.
            gameData = JsonUtility.FromJson<GameData>(text);
        }
    }

    public void writeFile()
    {
        // Create new AES instance.
        Aes iAes = Aes.Create();

        // Update the internal key.
        savedKey = iAes.Key;

        // Create a FileStream for creating files.
        dataStream = new FileStream(saveFile, FileMode.Create);

        // Save the new generated IV.
        byte[] inputIV = iAes.IV;
        
        // Write the IV to the FileStream unencrypted.
        dataStream.Write(inputIV, 0, inputIV.Length);

        // Create CryptoStream, wrapping FileStream.
        CryptoStream iStream = new CryptoStream(
                dataStream,
                iAes.CreateEncryptor(iAes.Key, iAes.IV),
                CryptoStreamMode.Write);

        // Create StreamWriter, wrapping CryptoStream.
        StreamWriter sWriter = new StreamWriter(iStream);

        // Serialize the object into JSON and save string.
        string jsonString = JsonUtility.ToJson(gameData);

        // Write to the innermost stream (which will encrypt).
        sWriter.Write(jsonString);

        // Close StreamWriter.
        sWriter.Close();

        // Close CryptoStream.
        iStream.Close();

        // Close FileStream.
        dataStream.Close();
    }
}

In the new version of the GameDataManager code, the JsonUtility class and its methods are used to serialize the GameData class and its fields into and out of a String format. This is also combined with the existing stream “wrapping” usages to decrypt and deserialize, for reading a file, and serialize and encrypting, for writing to a file.

What do we do with the secret key?

The existing example code shared in this post will encrypt and decrypt game data files using AES. It will also handle all of the streams and work to serialize or deserialize data from a class as needed. However, there is one remaining problem: how should the secret key be handled? Within the current code, the key is generated by the Create() method and then saved. As long as the code is running, this value would exist within the application. It can easily use the value for all encryption related tasks. However, as soon as the application is closed, any attempt to read the files will fail. The key is not saved across sessions.

It’s a matter of trust. The starting issue of this post remains. The central question in any encryption usage centers around issues of trust. If players can be trusted to not change files or the use of their values can be closely checked, encryption may not be needed. If game data should be protected from players, encrypting the data might be a useful step. However, as with the beginning concerns, any use of symmetric encryption means parties need a key to access the data. It has to be saved somewhere.

Saving Symmetric Key in PlayerPrefs

The PlayerPrefs class gives access to the “preferences” data as saved by Unity for the player. This is usually data such as resolution, accessibility, or other settings. It could also be used as a place to save the key as generated by the Aes class.

Saving Key (using Base64 String and Byte[] Conversion) as “Key” Example

// Add System.IO to work with files!
using System.IO;
// Add System.Security.Crytography to use Encryption!
using System.Security.Cryptography;
using UnityEngine;

public class GameDataManager : MonoBehaviour
{
    // Create a field for the save file.
    string saveFile;

    // Create a GameData field.
    public GameData gameData = new GameData();

    // FileStream used for reading and writing files.
    FileStream dataStream;

    void Awake()
    {
        // Update the path once the persistent path exists.
        saveFile = Application.persistentDataPath + "/gamedata.json";
    }

    public void readFile()
    {
        // Does the file exist AND does the "key" preference exist?
        if (File.Exists(saveFile) && PlayerPrefs.HasKey("key"))
        {
            // Update key based on PlayerPrefs
            // (Convert the String into a Base64 byte[] array.)
            byte[] savedKey = System.Convert.FromBase64String(PlayerPrefs.GetString("key") );

            // Create FileStream for opening files.
            dataStream = new FileStream(saveFile, FileMode.Open);

            // Create new AES instance.
            Aes oAes = Aes.Create();

            // Create an array of correct size based on AES IV.
            byte[] outputIV = new byte[oAes.IV.Length];
            
            // Read the IV from the file.
            dataStream.Read(outputIV, 0, outputIV.Length);

            // Create CryptoStream, wrapping FileStream
            CryptoStream oStream = new CryptoStream(
                   dataStream,
                   oAes.CreateDecryptor(savedKey, outputIV),
                   CryptoStreamMode.Read);

            // Create a StreamReader, wrapping CryptoStream
            StreamReader reader = new StreamReader(oStream);
            
            // Read the entire file into a String value.
            string text = reader.ReadToEnd();

            // Deserialize the JSON data 
            //  into a pattern matching the GameData class.
            gameData = JsonUtility.FromJson<GameData>(text);
        }
    }

    public void writeFile()
    {
        // Create new AES instance.
        Aes iAes = Aes.Create();

        // Update the internal key.
        byte[] savedKey = iAes.Key;

        // Convert the byte[] into a Base64 String.
        string key = System.Convert.ToBase64String(savedKey);

        // Update the PlayerPrefs
        PlayerPrefs.SetString("key", key);

        // Create a FileStream for creating files.
        dataStream = new FileStream(saveFile, FileMode.Create);

        // Save the new generated IV.
        byte[] inputIV = iAes.IV;
        
        // Write the IV to the FileStream unencrypted.
        dataStream.Write(inputIV, 0, inputIV.Length);

        // Create CryptoStream, wrapping FileStream.
        CryptoStream iStream = new CryptoStream(
                dataStream,
                iAes.CreateEncryptor(iAes.Key, iAes.IV),
                CryptoStreamMode.Write);

        // Create StreamWriter, wrapping CryptoStream.
        StreamWriter sWriter = new StreamWriter(iStream);

        // Serialize the object into JSON and save string.
        string jsonString = JsonUtility.ToJson(gameData);

        // Write to the innermost stream (which will encrypt).
        sWriter.Write(jsonString);

        // Close StreamWriter.
        sWriter.Close();

        // Close CryptoStream.
        iStream.Close();

        // Close FileStream.
        dataStream.Close();
    }
}

In the above example, each time the writeFile() method is used, it generates a new AES key and saves it in the PlayerPrefs (overwriting the previous key). Each new save operation, as a side effect, changes the key used and allows, as long as the key exists as part of PlayerPrefs, for the code to read the encrypted file.

Using Hardcoded Secret Key

It is also possible to “hardcode” the secret in the GameDataManager class, using the same key for all operations as saved within the class itself as a private field.

Hardcoded Secret Key

// Add System.IO to work with files!
using System.IO;
// Add System.Security.Crytography to use Encryption!
using System.Security.Cryptography;
using UnityEngine;

public class GameDataManager : MonoBehaviour
{
    // Create a field for the save file.
    string saveFile;

    // Create a GameData field.
    public GameData gameData = new GameData();

    // FileStream used for reading and writing files.
    FileStream dataStream;

    // Key for reading and writing encrypted data.
    // (This is a "hardcoded" secret key. )
    byte[] savedKey = { 0x16, 0x15, 0x16, 0x15, 0x16, 0x15, 0x16, 0x15, 0x16, 0x15, 0x16, 0x15, 0x16, 0x15, 0x16, 0x15 };

    void Awake()
    {
        // Update the path once the persistent path exists.
        saveFile = Application.persistentDataPath + "/gamedata.json";
    }

    public void readFile()
    {
        // Does the file exist?
        if (File.Exists(saveFile))
        {
            // Create FileStream for opening files.
            dataStream = new FileStream(saveFile, FileMode.Open);

            // Create new AES instance.
            Aes oAes = Aes.Create();

            // Create an array of correct size based on AES IV.
            byte[] outputIV = new byte[oAes.IV.Length];

            // Read the IV from the file.
            dataStream.Read(outputIV, 0, outputIV.Length);

            // Create CryptoStream, wrapping FileStream
            CryptoStream oStream = new CryptoStream(
                   dataStream,
                   oAes.CreateDecryptor(savedKey, outputIV),
                   CryptoStreamMode.Read);

            // Create a StreamReader, wrapping CryptoStream
            StreamReader reader = new StreamReader(oStream);

            // Read the entire file into a String value.
            string text = reader.ReadToEnd();

            // Deserialize the JSON data 
            //  into a pattern matching the GameData class.
            gameData = JsonUtility.FromJson<GameData>(text);
        }
    }

    public void writeFile()
    {
        // Create new AES instance.
        Aes iAes = Aes.Create();

        // Create a FileStream for creating files.
        dataStream = new FileStream(saveFile, FileMode.Create);

        // Save the new generated IV.
        byte[] inputIV = iAes.IV;

        // Write the IV to the FileStream unencrypted.
        dataStream.Write(inputIV, 0, inputIV.Length);

        // Create CryptoStream, wrapping FileStream.
        CryptoStream iStream = new CryptoStream(
                dataStream,
                iAes.CreateEncryptor(savedKey, iAes.IV),
                CryptoStreamMode.Write);

        // Create StreamWriter, wrapping CryptoStream.
        StreamWriter sWriter = new StreamWriter(iStream);

        // Serialize the object into JSON and save string.
        string jsonString = JsonUtility.ToJson(gameData);

        // Write to the innermost stream (which will encrypt).
        sWriter.Write(jsonString);

        // Close StreamWriter.
        sWriter.Close();

        // Close CryptoStream.
        iStream.Close();

        // Close FileStream.
        dataStream.Close();
    }
}

In the above code, the secretKey field is used for both encryption and decryption processes, referencing the key as part of the class itself.