Saving System Upgrade: Replacing BinaryFormatter with Json.NET

So far, we’ve seen how to capture and restore the JToken version of object, and how to serialize and deserialize in both human readable Json and quasi human readable Bson. We’ve also seen how to set up a strategy pattern to allow you to choose at compile time which of these formats we wish to use.

This last section will extend that strategy pattern concept to give us a couple more options when it comes to saving/restoring while still making the file more difficult (but never impossible) to alter.

A quick note on encryption: Aside from implementing NSA level encryption, there is no such thing as a file that cannot be decrypted. Very smart eggheads have literally spent decades coming up with new ways to encrypt messages, and more to the point, coming up with ways to decrypt them. It’s a bit like locking your front door. It will stop honest people from breaking into your home, but it’s not a guarantee.

I’m going to present the simplest of cyphers, the XOR Cypher. This cypher takes a simple key and character by character applies an XOR with the corresponding letter of the key and the text to encrypt… For this example, I’m using a modified version of an example from Codingame.com

using System.IO;
using System.Text;
using Newtonsoft.Json.Linq;
using UnityEngine;

namespace GameDevTV.Saving
{
    public class XorStrategy : SavingStrategy
    {
        private const string key = "TheQuickBrownFoxJumpedOverTheLazyDog"; //This is a TERRIBLE KEY
        
        public string EncryptDecrypt(string szPlainText, string szEncryptionKey)  
        {  
            StringBuilder szInputStringBuild = new StringBuilder(szPlainText);  
            StringBuilder szOutStringBuild = new StringBuilder(szPlainText.Length);  
            char Textch;  
            for (int iCount = 0; iCount < szPlainText.Length; iCount++)
            {
                int stringIndex = iCount % szEncryptionKey.Length;
                Textch = szInputStringBuild[iCount];  
                Textch = (char)(Textch ^ szEncryptionKey[stringIndex]);  
                szOutStringBuild.Append(Textch);  
            }  
            return szOutStringBuild.ToString();  
        } 
        
        public override void SaveToFile(string saveFile, JObject state)
        {
            string path = GetPathFromSaveFile(saveFile);
            Debug.Log($"Saving to {path} ");
            using (var textWriter = File.CreateText(path))
            {
                string json = state.ToString();
                string encoded = EncryptDecrypt(json, key);
                textWriter.Write(encoded);
            }
        }

        public override JObject LoadFromFile(string saveFile)
        {
            string path = GetPathFromSaveFile(saveFile);
            if (!File.Exists(path))
            {
                return new JObject();
            }
            
            using (var textReader = File.OpenText(path))
            {
                string encoded = textReader.ReadToEnd();
                string json = EncryptDecrypt(encoded, key);
                return (JObject)JToken.Parse(json);
            }
        }
        public override string GetExtension()
        {
            return ".xor";
        }
    }
}

So there’s a bit to unpack here, as we can’t serialize and deserialize directly to the filestreams. The first method is our modified encryption cypher. Because this particular cypher uses the key to both encode and decode the file, the same method can be used to flip the bits on the file. It’s imperative that once you’ve chosen a key, you never change it, as it will render any future files unreadable.
If you use a text editor to read the encoded xor file, you’ll note some deficiencies in the algorythm… you’ll find places where you can see sections of the key… even if I were not to have shown you the key, it might not take you very long to figure out that it is the phrase used to teach young typists how to use every key in the alphabet. You’ll see fragments of the phrase in places due to the nature of the algorithm when certain complementary values are encountered. You can minimize this risk by using keys with more random characters. Try copying a UUID from one of your characters and using that as the key. The important thing is once you’ve set a key, you can’t change it or old save files will be unreadable.

The final strategy I’m going to leave you with is what I’m going to call the XorTextStrategy. Base64 encoding is a way of using only “readable” text characters to represent a binary file. It allows sending the file via email or over http as plain text (doing this with the results of Bson or the XorStrategy could cause accidental control characters to be interpreted, so transferring the files has to be done as binary files). While transferring binary files isn’t ordinarily a problem, some 3rd party cloud solutions will only accept pure text files. The XorTextStrategy will add an additional layer of encoding by converting the final product into Base64 encoding.

using System;
using System.IO;
using System.Text;
using Newtonsoft.Json.Linq;
using UnityEngine;

namespace GameDevTV.Saving
{
    public class XorTextStrategy : SavingStrategy
    {
        private const string key = "TheQuickBrownFoxJumpedOverTheLazyDog"; //This is a TERRIBLE KEY
       
        
        public string EncryptDecrypt(string szPlainText, string szEncryptionKey)  
        {  
            StringBuilder szInputStringBuild = new StringBuilder(szPlainText);  
            StringBuilder szOutStringBuild = new StringBuilder(szPlainText.Length);  
            char Textch;  
            for (int iCount = 0; iCount < szPlainText.Length; iCount++)
            {
                int stringIndex = iCount % szEncryptionKey.Length;
                Textch = szInputStringBuild[iCount];  
                Textch = (char)(Textch ^ szEncryptionKey[stringIndex]);  
                szOutStringBuild.Append(Textch);  
            }  
            return szOutStringBuild.ToString();  
        } 

        string EncodeAsBase64String(string source)
        {
            byte[] sourceArray = new byte[source.Length];
            for (int i = 0; i < source.Length; i++)
            {
                sourceArray[i] = (byte)source[i];
            }
            return Convert.ToBase64String(sourceArray);
        }

        string DecodeFromBase64String(string source)
        {
            byte[] sourceArray = Convert.FromBase64String(source);
            StringBuilder builder = new StringBuilder();
            for (int i = 0; i < sourceArray.Length; i++)
            {
                builder.Append((char) sourceArray[i]);
            }

            return builder.ToString();
        }
        
        public override void SaveToFile(string saveFile, JObject state)
        {
            string path = GetPathFromSaveFile(saveFile);
            Debug.Log($"Saving to {path} ");
            using (var textWriter = File.CreateText(path))
            {
                string json = state.ToString();
                string encoded = EncryptDecrypt(json, key);
                string base64 = EncodeAsBase64String(encoded);
                textWriter.Write(base64);
            }
        }

        public override JObject LoadFromFile(string saveFile)
        {
            string path = GetPathFromSaveFile(saveFile);
            if (!File.Exists(path))
            {
                return new JObject();
            }
            
            using (var textReader = File.OpenText(path))
            {
                string encoded = textReader.ReadToEnd();
                string decoded = DecodeFromBase64String(encoded);
                string json = EncryptDecrypt(decoded, key);
                return (JObject)JToken.Parse(json);
            }
        }
        public override string GetExtension()
        {
            return ".xortext";
        }
    }
}

While this will add an extra layer of obscurity to the save file (Base64 encoding uses 6 bits per character meaning that an invididual byte of the original save spans 2 characters, but in different ways depending on it’s position in the file), anybody familiar with Base64 encoding will be able to decode the file back to it’s original format. This is why I’m using it combined with the XOR, to allow an extra layer of security.

This concludes the Converting to Json tutorial. Hopefully, you’ve learned how to adapt your game’s Captures and Restores to be JTokens, and how to save and load your game data to and from Json files.

This tutorial’s final commit

3 Likes