Introduction
If you are considering not using an established distribution platform (Steam, Itch, Google Play Store, and so on), or in addition to using one, also making your project available on your website (Patreon, GitHub, forum, mailing list, ad infinitum), you’re probably looking to use the resource pack feature that Godot makes available for patches and DLC.
This guide won’t be that useful to you, if you’re only going with trusted distribution platforms, as each have their own methods of delivering updates to players (and signing them). For example:
- Steam uses SteamPipe
- Itch uses butler
- GOG uses Build Creator/Pipeline Builder
You’ll have to get familiar with how they work, but most of them are probably compatible with Godot’s resource pack workflow. What workflow? Well, the idea is that you use additional files, loaded at runtime, in order to modify the base game (instead of an installer that modifies the existing game files directly, for example).
So you start creating your game, and you know you’ll get the patches to your users somehow (perhaps via carrier pidgeon), so you don’t worry too much about it. All you know for sure is that every patch will eventually be found in the user://patches dir.
Naturally, you then go and write in your main scene:
func _ready() -> void:
for file: String in DirAccess.get_files_at("user://patches/"):
ProjectSettings.load_resource_pack("user://patches/" + file)
The Potential Problem
We’ll leave aside the fact that you’ve no control over the loading order in the extraordinarily complex example above. Concerning this tutorial is the fact that you don’t know that whatever you’re loading:
- Comes from a trusted source
- Has not been tampered with
- Has not been corrupted
This has nothing to do with encryption, protecting your assets, or save game security. Digital signatures have been around for a long time, as a method of verifying authenticity. You can sign files, emails, and even your git commits.
If you want to learn more about how they work, here’s a short video from Computerphile, and here’s the FAQ from GnuPG. You don’t need to know a lot in order to follow this, but I will assume you have at least basic knowledge of terms such as public/private key, file hash, SHA and RSA.
The Proposed Solution
Why, obviously, we sign the resource packs before uploading them, and we verify the signature before loading them. The documentation for everything we need can be found here:
- Crypto — Godot Engine (stable) documentation in English
- CryptoKey — Godot Engine (stable) documentation in English
- HashingContext — Godot Engine (stable) documentation in English
You are probably familiar with the endless discussions about using resources as save files, and how it should, or should not be, the developer’s responsibility if the user decides to download who knows what, from who knows where. I’m sure there’s an argument along the same lines that can be made regarding this. I will refrain from commenting on it.
The more interesting aspect, should you choose this option, is how mod creation fits into this. Should you only ever allow verified pck files to be loaded? Because if so, that rules out mods completely (in theory, since modders will always find a way). Should you ask modders to send you the pck, so that you can verify it and sign it yourself? You can see why that’s not really feasible.
Perhaps, then, you can have the default behaviour be to only load signed files, and have a toggleable option, behind a disclaimer, that will allow loading unsigned ones. But then, some might argue, why even bother implementing all of this in the first place?
Whatever your considerations, if you think this will be a good fit for your project, you at least know what to expect. With that out of the way…
Getting started
I’ve decided to split this into two categories. The signing and the verification will be done in two separate scenes, but there will be some overlap. The reason for this is simple. Your finished project only needs the logic for verification, since it won’t be doing any signing (it will only include the public key).
Signing
Unlike gpg, Godot keys don’t contain creation/expiration dates, emails, names, and so on, which means you won’t be able to use your existing private key for this. Instead, we’ll have to generate a new key pair.
We’ll start by creating a new project, and a new folder called key inside the file system. After that, we set up the _ready() function and generate the keys:
# Make sure the directory exists, or change the path
const _PRIVATE_KEY_PATH := "res://key/private_key.key"
const _PUBLIC_KEY_PATH := "res://key/public_key.pub"
func _ready() -> void:
var icon_path := "res://icon.svg"
var signature_path := "res://icon.sig" # Any extension would do
_generate_rsa_key_pair()
func _generate_rsa_key_pair(overwrite: bool = false) -> void:
# Check we don't overwrite existing keys by accident
for file: String in [_PRIVATE_KEY_PATH, _PUBLIC_KEY_PATH]:
if FileAccess.file_exists(file) and not overwrite:
return
var crypto := Crypto.new() # Won't be doing any mining
var rsa_key := crypto.generate_rsa(4096) # Key size here
rsa_key.save(_PRIVATE_KEY_PATH)
rsa_key.save(_PUBLIC_KEY_PATH, true)
Note that creating a new key pair might take some time. If Godot appears to be frozen for a few seconds, that’s because it is. When it comes to key size, there’s really no reason to go above 4096 bits, although you’ll find interesting discussions about what to choose between this and 2048 bits. We’ll go with the docs example here.
If all went well, you’ll find the keys inside the folder we made earlier. You won’t be able to open them in the editor, but feel free to check them out using your preferred text editor. It goes without saying that you should never share your private key, and you should always have a backup stored somewhere. The public key can be retrieved from the private one, so you don’t need to worry about that.
Hashing the file
In case you’re not familiar, here’s a Tom Scott video explaining hashing, and here’s Dr. Mike explaining SHA. Note that we’ll not be using SHA-1 for this, but SHA-256:
func _get_sha256_hash(file_path: String) -> PackedByteArray:
# Standard file checks
if not FileAccess.file_exists(file_path):
return PackedByteArray()
var file := FileAccess.open(file_path, FileAccess.READ)
if not file:
return PackedByteArray()
# Starting the hash
var hashing_context := HashingContext.new()
if hashing_context.start(HashingContext.HASH_SHA256) != OK:
return PackedByteArray()
# Looping through the file, updating the hash
const MAX_CHUNK_SIZE_BYTES: int = 1024 * 64
while file.get_position() < file.get_length():
var remaining_bytes := file.get_length() - file.get_position()
var next_chunk_size := mini(remaining_bytes, MAX_CHUNK_SIZE_BYTES)
if hashing_context.update(file.get_buffer(next_chunk_size)) != OK:
return PackedByteArray()
return hashing_context.finish()
The code is basically taken from the docs. I’ve renamed the variables to make what’s happening a bit more obvious. I’ve set the chunk size to 64 KiB, although you might want to change that. The docs example is only using 1 KiB, and you’re probably fine with going up to 1 MiB. There’s this interesting post on StackOverflow about the best chunk size for MD-5, using Python. That doesn’t mean much without testing how Godot handles hashing SHA-256, but I just went with it. It really won’t matter much what chunk size you choose, as long as it’s not too low or too high.
Hashing large files is slow. Since this whole process will probably take place when booting the game, you might want to look into multithreading this function, and hiding it behind a loading screen, but that is outside the scope of this tutorial.
As a side note, strings also have built-in methods for hashing, such as String.sha256_text() and String.sha256_buffer(), which means you could do FileAccess.get_file_as_string(path).sha256_buffer(), but that would mean loading the entire file into memory at once. You can imagine why that’s a bad idea for large files.
Creating the signature
Once we have the file hash, we’ll need to sign it using the private key:
func _get_sha256_signature(file_hash: PackedByteArray) -> PackedByteArray:
# Somewhat redundant checks
const SHA256_SIZE_BYTES: int = 32
if file_hash.size() != SHA256_SIZE_BYTES:
return PackedByteArray()
# Loading the key
var private_key := CryptoKey.new()
if private_key.load(_PRIVATE_KEY_PATH) != OK:
return PackedByteArray()
# Signing
var crypto := Crypto.new()
return crypto.sign(HashingContext.HASH_SHA256, file_hash, private_key)
This is not terribly complicated, as you can see. The checks I’ve added are somewhat redundant, since if the methods fail, they will push errors. Nevertheless, it saves creating new objects if everything is not as expected.
The file hash will always be 256 bits (32 bytes), hence the name. The resulting signature size will depend on the private key size. For our 4096 bits key, the signature will be 512 bytes.
Saving the signature to file
func _save_signature_to_file(file_path: String, signature: PackedByteArray) -> bool:
const SIGNATURE_SIZE_4096_BIT_KEY: int = 512
if signature.size() != SIGNATURE_SIZE_4096_BIT_KEY:
return false
var file := FileAccess.open(file_path, FileAccess.WRITE)
if not file:
return false
return file.store_buffer(signature)
Since the signature is a PackedByteArray, all we have to do is store the buffer to a file. No surprises here.
Tying everything together
We’ll now come back to the ready function, and check to see if everything works as expected:
func _ready() -> void:
var icon_path := "res://icon.svg"
var signature_path := "res://icon.sig"
_generate_rsa_key_pair()
var file_hash := _get_sha256_hash(icon_path)
var signature := _get_sha256_signature(file_hash)
if _save_signature_to_file(signature_path, signature):
print("Successfully signed: %s\nSignature path: %s" % [icon_path, signature_path])
That should be it. The signature file will not be visible in the editor’s filesystem, but you can have a look at it by opening the project directory. It should look something like this:
^òÍÝ3 ÍØ½ºÌu©h½ÚM5²¢¦g5ıóh>).KôùùÒâ,þC>:ÛÕ(÷J^=ÄEM:zIæÕ¸(ÎÜÛ ýfù£½}ËâãÜ6%¬ÒêR£~³ìáD%Ý3UwwøÔeIÆhùhzѪ>Sæý/ÔWß
Yes, I know. It’s so much better than what you or I can manage on a contract. Anyway, this file now contains the icon hash, signed by your private key. We’ll verify this using the public key in the next step.
What this means is that if the icon is ever changed, even one bit (see what I did there?), even by accident, the signature will no longer match (because the hash will no longer be the same). You’ll have to package this signature with the files you wish to distribute.
Verifying
You’ll have to include the public key in your exported project if you wish to verify signatures. In this case, we’ll just create a new scene, since we already have the key there from when we generated it.
const _PUBLIC_KEY_PATH := "res://key/public_key.pub"
func _ready() -> void:
var icon_path := "res://icon.svg"
var signature_path := "res://icon.sig"
if _verify_signature(icon_path, signature_path):
print("Successfully verified: %s\nGood signature: %s" % [icon_path, signature_path])
else:
printerr("Signature mismatch")
func _verify_signature(file_path: String, signature_path: String) -> bool:
# Checking the public key
var public_key := CryptoKey.new()
if public_key.load(_PUBLIC_KEY_PATH, true) != OK:
return false
# Checking the signature size
const SIGNATURE_SIZE_4096_BIT_KEY: int = 512
var signature := FileAccess.get_file_as_bytes(signature_path)
if signature.size() != SIGNATURE_SIZE_4096_BIT_KEY:
return false
# Checking we correctly hashed the file
const SHA256_SIZE_BYTES: int = 32
var file_hash := _get_sha256_hash(file_path)
if file_hash.size() != SHA256_SIZE_BYTES:
return false
# Verifying
var crypto := Crypto.new()
return crypto.verify(HashingContext.HASH_SHA256, file_hash, signature, public_key)
The constants, as well as the hashing function, are the same from before. They should be familiar by now.
You can now check that everything works by opening either file with a text editor, and modifying it. Be aware that sometimes even opening the file and saving it without modifying anything will cause a mismatch, since some text editors add a newline at the end of the file automatically.
Also, don’t forget to run the current scene (F6) when verifying, if you’ve followed along and created two separate scenes.
A note on security
What happens if, let’s say, you verify the file and it’s good, but then between the time you verify it and the time it gets loaded, somebody modifies it? That’s called a TOCTOU attack, and it’s essentially impossible to stop.
You’d ideally want some way to ensure that whatever file you are currently verifying is the same file you’ll be using later. You could load it all into memory, but it’s not like there aren’t any ways to exploit memory, not to mention the down side that is… loading the entire file into memory.
When it comes to pck files, the way the patch is “loaded” works by essentially telling the game to replace references to files in res:// with references to files in the pck. For example, instead of looking for res://levels/level_one.tscn, look for user://patch/patch1.pck → res://levels/level_one.tscn. This means that in theory, somebody could modify the pck during runtime (after you verified it), and therefore ignore this entire signature business. Of course, if they modify the file, the next time you run the game, it won’t get loaded since the signature will not match, but by that point it’s too late.
So should you even bother? Well, if some malicious actor can manage to swap files between you verifying them and the engine loading them (or modify resources during runtime), that means the system was already compromised through no fault of your own, and the user has bigger problems to worry about than playing your game.
No security measure is foolproof. That being said, as far as they go, cryptographically signing files is pretty good.
Conclusion
Now that we’ve been through this, you can modify the logic that the you-from-my-example has written at the start, to look something like this:
func _ready() -> void:
for file: String in DirAccess.get_files_at("user://patches/"):
if _verify_signature("user://patches/" + file): # This is new!
ProjectSettings.load_resource_pack("user://patches/" + file)
You can have a _verify_signature function that looks to see if a file of the same name, but with a .sig extension exists, and check to see if it’s valid. If it is, we know we can trust it, and we can safely load it.
If you’ve got suggestions or fixes, I’d love to hear them. Thanks.
k bro but where full code?
Signing
extends Node
const _PRIVATE_KEY_PATH := "res://key/private_key.key"
const _PUBLIC_KEY_PATH := "res://key/public_key.pub"
func _ready() -> void:
var icon_path := "res://icon.svg"
var signature_path := "res://icon.sig"
_generate_rsa_key_pair()
var file_hash := _get_sha256_hash(icon_path)
var signature := _get_sha256_signature(file_hash)
if _save_signature_to_file(signature_path, signature):
print("Successfully signed: %s\nSignature path: %s" % [icon_path, signature_path])
func _generate_rsa_key_pair(overwrite: bool = false) -> void:
for file: String in [_PRIVATE_KEY_PATH, _PUBLIC_KEY_PATH]:
if FileAccess.file_exists(file) and not overwrite:
return
var crypto := Crypto.new()
var rsa_key := crypto.generate_rsa(4096)
rsa_key.save(_PRIVATE_KEY_PATH)
rsa_key.save(_PUBLIC_KEY_PATH, true)
func _get_sha256_hash(file_path: String) -> PackedByteArray:
if not FileAccess.file_exists(file_path):
return PackedByteArray()
var file := FileAccess.open(file_path, FileAccess.READ)
if not file:
return PackedByteArray()
var hashing_context := HashingContext.new()
if hashing_context.start(HashingContext.HASH_SHA256) != OK:
return PackedByteArray()
const MAX_CHUNK_SIZE_BYTES: int = 1024 * 64
while file.get_position() < file.get_length():
var remaining_bytes := file.get_length() - file.get_position()
var next_chunk_size := mini(remaining_bytes, MAX_CHUNK_SIZE_BYTES)
if hashing_context.update(file.get_buffer(next_chunk_size)) != OK:
return PackedByteArray()
return hashing_context.finish()
func _get_sha256_signature(file_hash: PackedByteArray) -> PackedByteArray:
const SHA256_SIZE_BYTES: int = 32
if file_hash.size() != SHA256_SIZE_BYTES:
return PackedByteArray()
var private_key := CryptoKey.new()
if private_key.load(_PRIVATE_KEY_PATH) != OK:
return PackedByteArray()
var crypto := Crypto.new()
return crypto.sign(HashingContext.HASH_SHA256, file_hash, private_key)
func _save_signature_to_file(file_path: String, signature: PackedByteArray) -> bool:
const SIGNATURE_SIZE_4096_BIT_KEY: int = 512
if signature.size() != SIGNATURE_SIZE_4096_BIT_KEY:
return false
var file := FileAccess.open(file_path, FileAccess.WRITE)
if not file:
return false
return file.store_buffer(signature)
Verifying
extends Node
const _PUBLIC_KEY_PATH := "res://key/public_key.pub"
func _ready() -> void:
var icon_path := "res://icon.svg"
var signature_path := "res://icon.sig"
if _verify_signature(icon_path, signature_path):
print("Successfully verified: %s\nGood signature: %s" % [icon_path, signature_path])
else:
printerr("Signature mismatch")
func _verify_signature(file_path: String, signature_path: String) -> bool:
var public_key := CryptoKey.new()
if public_key.load(_PUBLIC_KEY_PATH, true) != OK:
return false
const SIGNATURE_SIZE_4096_BIT_KEY: int = 512
var signature := FileAccess.get_file_as_bytes(signature_path)
if signature.size() != SIGNATURE_SIZE_4096_BIT_KEY:
return false
const SHA256_SIZE_BYTES: int = 32
var file_hash := _get_sha256_hash(file_path)
if file_hash.size() != SHA256_SIZE_BYTES:
return false
var crypto := Crypto.new()
return crypto.verify(HashingContext.HASH_SHA256, file_hash, signature, public_key)
func _get_sha256_hash(file_path: String) -> PackedByteArray:
if not FileAccess.file_exists(file_path):
return PackedByteArray()
var file := FileAccess.open(file_path, FileAccess.READ)
if not file:
return PackedByteArray()
var hashing_context := HashingContext.new()
if hashing_context.start(HashingContext.HASH_SHA256) != OK:
return PackedByteArray()
const MAX_CHUNK_SIZE_BYTES: int = 1024 * 64
while file.get_position() < file.get_length():
var remaining_bytes := file.get_length() - file.get_position()
var next_chunk_size := mini(remaining_bytes, MAX_CHUNK_SIZE_BYTES)
if hashing_context.update(file.get_buffer(next_chunk_size)) != OK:
return PackedByteArray()
return hashing_context.finish()