String/data encryption and decryption using AES-CTR extension

I’m currently working on a project that saves game data stored in a Lua table to a file. Saving the data itself was fairly straightforward, but it got me thinking about how I could better protect the file from being easily read or modified.

That led me down a bit of a rabbit hole, resulting in an extension for Defold that encrypts and decrypts Lua strings using AES-CTR algorithm, built on top of the tiny-AES implementation.

I hope it may be of use to others.

https://github.com/alfowler1976/defold_aes_ctr

Please read the important notes at the bottom of the page if you are interested in using this.

Overview

The current version exposes four functions to Defold, designed to work in pairs.

The encryption functions take a string/data variable, encrypts it, and return a string containing the encrypted data, an automatically generated IV (Initialization Vector), and a checksum to facilitate tamper detection. Their corresponding decryption functions reverse this process, check for tampering, and return an error if verification fails.

Key-Based Functions

  • aes_ctr.encrypt_using_key(data, key)

  • aes_ctr.decrypt_using_key(data, key)

These two functions encrypt and decrypt using a supplied key. The key is a table containing 32 integer numbers ranging between 0 and 255. Tables are used instead of strings to help mitigate risks associated with Lua string interning.

Seed-Based Functions

  • aes_ctr.encrypt_using_seed(data, seed)

  • aes_ctr.decrypt_using_seed(data, seed)

These require a seed supplied as a string, which the extension uses to generate a key internally. The advantage with these is that the key is never exposed to lua

Example Usage

Here is a truncated example (table based) of how my save and load functions look using the extension:

Lua

local function save_game_data(game_data_table, filename, seed)
	-- Serialize the data
	local data = sys.serialize(game_data_table)

	-- Compress the data 
	local compressed_data = zlib.deflate(data)

	-- Encrypt after compressing (compressing encrypted data yields poor results)
	local encrypted_data = aes_ctr.encrypt_using_seed(compressed_data, seed)

	-- Open file for saving	
	local file = io.open(filename, "wb")

	if file then
		file:write(encrypted_data)
		file:close()
		pprint("Save successful!")
        return true
	else
		pprint("Save failed")
        return false
	end
end

local function load_game_data(self, filename, seed)
    local data, error

    -- Open file
	local file = io.open(filename, "rb")
	
    if file then
		data = file:read("*a")
		file:close()
	else
		error = "Could not load file from disk at " .. filename
	end
	
	if not data then
		pprint(error)
		return false
	end

	-- Decrypt data
	local decrypted_data, err2 = aes_ctr.decrypt_using_seed(data, seed)
	
	if not decrypted_data then
		pprint(err2)
		return false
	end

	-- Inflate data
	local uncompressed_data = zlib.inflate(decrypted_data)

	-- Deserialise
	local level_data = sys.deserialize(uncompressed_data)

    -- ...
    -- ...
    -- ...
    return true
end

Just as a side note, while you could use json.encode and json.decode to convert tables to strings, I’ve found them to be much more temperamental. The JSON encoder does not handle Defold’s native engine data types, such as hashes.

Other ideas…Encrypted Bundled Assets

Instead of relying on standard io functions, you could use the sys.load_resource() function to load encrypted custom resource files packaged directly inside your game’s archive - useful for level data.

Important Notes

  • Client-Side Security: LuaJIT is relatively easy to decompile, so storing keys directly in client-side code will make them easy to extract. The seed-based variants offer more protection because the key is generated internally—meaning it never appears in Lua. Even if an attacker discovers the seed, they would have to decompile and reverse-engineer the C++ code to obtain the key, which is significantly harder. Using Prometheus | Defold would in theory help protect against decompiling lua code

  • Customization: If you want even more protection, you can download the code and include it directly in your project rather than using it as a remote dependency. This allows you to customize internal elements, such as modifying the generate_key_from_seed function to create a completely unique implementation.

  • Testing & Data Backups (Disclaimer): While this extension works perfectly well for my own project, it has not been exhaustively tested across every possible situation or environment. Always keep a backup of your raw, unencrypted data before running it through encryption functions, just in case you run into any unexpected issues

I would like to stress that this is not a bullet proof solution . The goal of this extension is simply to make tampering significantly more difficult.

8 Likes