Persistence & Encryption
The KeenEyes.Persistence library adds password-based AES-256 encryption on top of the World's built-in save-slot system, so save files on disk cannot be read or tampered with without the correct password.
Overview
World already exposes save-slot functionality directly (world.SaveToSlot, world.LoadFromSlot, world.SaveDirectory) built on the snapshot and serialization mechanism described in Serialization and Snapshots. KeenEyes.Persistence does not replace that mechanism — it wraps it with an encryption step:
- A
WorldSnapshotis created and serialized (binary or JSON), exactly as it would be for an unencrypted save. - The serialized bytes are passed through an
IEncryptionProviderbefore being written to the.ksavecontainer. - On load, the container is read, the payload is decrypted, and the result is handed back to the same snapshot deserialization path.
Because the encryption step sits around the existing snapshot pipeline, everything documented in Serialization and Snapshots about what gets captured, entity ID mapping, and AOT-compatible serializers still applies unchanged.
Quick Start
Installation
using KeenEyes.Persistence;
using var world = new World();
// Install with no encryption (pass-through)
world.InstallPlugin(new PersistencePlugin());
Installing PersistencePlugin registers an EncryptedPersistenceApi extension on the world. Retrieve it with world.GetExtension<EncryptedPersistenceApi>().
Enabling Encryption
Use PersistenceConfig.WithEncryption to configure AES-256 encryption with a password:
using KeenEyes.Persistence;
var config = PersistenceConfig.WithEncryption("myPassword");
world.InstallPlugin(new PersistencePlugin(config));
var persistence = world.GetExtension<EncryptedPersistenceApi>();
// Save (creates a snapshot, serializes it, then encrypts it before writing)
var serializer = new ComponentSerializer(); // generated serializer
var slotInfo = persistence.SaveToSlot("slot1", serializer);
// Load (reads the file, decrypts it, then restores the snapshot into the world)
var (info, entityMap) = persistence.LoadFromSlot("slot1", serializer);
PersistenceConfig is a record, so the same result can be built explicitly:
using KeenEyes.Persistence;
using KeenEyes.Persistence.Encryption;
var config = new PersistenceConfig
{
EncryptionProvider = new AesEncryptionProvider("myPassword"),
SaveDirectory = "encrypted_saves"
};
world.InstallPlugin(new PersistencePlugin(config));
Note: When using a custom IWorld implementation (not the standard World class), PersistenceConfig.SaveDirectory must be set explicitly. EncryptedPersistenceApi falls back to World.SaveDirectory only when the world is a concrete World instance; otherwise it throws an ArgumentException during construction.
Core Concepts
PersistencePlugin
PersistencePlugin is an IWorldPlugin. On Install, it:
- Applies
PersistenceConfig.SaveDirectoryto the world via theIPersistenceCapabilitycapability, if one is available and a directory was configured. - Constructs an
EncryptedPersistenceApifor the world and registers it withcontext.SetExtension(api).
On Uninstall, it removes the extension with context.RemoveExtension<EncryptedPersistenceApi>().
public sealed class PersistencePlugin(PersistenceConfig? config = null) : IWorldPlugin
{
public string Name => "Persistence";
// Install / Uninstall as described above
}
PersistenceConfig
PersistenceConfig is an immutable record with two settings:
| Property | Description |
|---|---|
EncryptionProvider |
An IEncryptionProvider. Defaults to NoEncryptionProvider.Instance (no encryption). |
SaveDirectory |
Optional override for where .ksave files are stored. Falls back to World.SaveDirectory when null (concrete World only). |
PersistenceConfig.Default returns the no-encryption configuration. PersistenceConfig.WithEncryption(password, saveDirectory?) is a convenience factory that sets EncryptionProvider to a new AesEncryptionProvider(password).
EncryptedPersistenceApi
EncryptedPersistenceApi is the world extension installed by PersistencePlugin. It mirrors the shape of World's own save-slot methods, but routes the serialized snapshot bytes through the configured IEncryptionProvider before writing, and through decryption before restoring:
| Member | Description |
|---|---|
IsEncryptionEnabled |
true when the configured provider actually encrypts (IEncryptionProvider.IsEncrypted). |
EncryptionProviderName |
The provider's Name ("AES-256" or "None"). |
SaveDirectory |
The resolved save directory for this API instance. |
SaveToSlot<TSerializer>(slotName, serializer, options?) |
Creates a snapshot, serializes it, encrypts it, and writes it to a .ksave file. Returns a SaveSlotInfo. |
LoadFromSlot<TSerializer>(slotName, serializer, validateChecksum = true) |
Reads a .ksave file, decrypts it, deserializes the snapshot, and restores it into the world. Returns the SaveSlotInfo and the old-ID-to-new-Entity map. |
SaveToSlotAsync / LoadFromSlotAsync |
Async equivalents that use the provider's EncryptAsync/DecryptAsync. |
GetSlotInfo(slotName) |
Reads only the SaveSlotInfo metadata, without decrypting the payload. |
ListSlots() |
Enumerates SaveSlotInfo for every .ksave file in the save directory. |
SlotExists(slotName) / DeleteSlot(slotName) |
Slot existence check and deletion. |
GetSlotFilePath(slotName) |
Resolves the full path for a slot name, validated by SlotNameValidator. |
TSerializer must implement both IComponentSerializer and IBinaryComponentSerializer — the same constraint used by World.SaveToSlot/World.LoadFromSlot and by SnapshotManager.ToBinary/FromBinary.
The returned SaveSlotInfo (and the SaveSlotOptions accepted by SaveToSlot) are the same types used by the World's unencrypted save API — see Serialization and Snapshots for the full snapshot lifecycle they describe. GetSlotInfo and ListSlots can still enumerate and inspect encrypted saves without a password, because the encryption only covers the snapshot payload, not the SaveSlotInfo metadata stored alongside it in the .ksave container.
Encryption Providers
IEncryptionProvider is the extension point for encryption. It exposes Name, IsEncrypted, and sync/async Encrypt/Decrypt pairs operating on byte[].
Two implementations ship with the library:
NoEncryptionProvider— a pass-through singleton (NoEncryptionProvider.Instance) withIsEncrypted => false. This isPersistenceConfig.Default's provider.AesEncryptionProvider— AES-256 in CBC mode with PKCS7 padding. The key is derived from the supplied password with PBKDF2 (SHA-256, 100,000 iterations) and a random 16-byte salt generated per encrypt call. A random 16-byte IV is also generated per call. The output layout is[Salt (16 bytes)][IV (16 bytes)][Ciphertext], so each encrypted payload is fully self-describing and needs only the password to decrypt.
using KeenEyes.Persistence.Encryption;
var provider = new AesEncryptionProvider("MySecretPassword");
byte[] encrypted = provider.Encrypt(plaintextBytes);
byte[] decrypted = provider.Decrypt(encrypted);
Because the salt and IV are regenerated on every Encrypt call, encrypting the same snapshot twice produces different ciphertext — this is expected and does not affect round-tripping through Decrypt.
A custom IEncryptionProvider can be supplied instead of AesEncryptionProvider (for example, to integrate with a platform key vault) by setting PersistenceConfig.EncryptionProvider directly.
Performance Considerations
SaveToSlotencrypts the serialized snapshot bytes before handing them toSaveFileFormat.Write, which performs the actual GZip/Brotli compression configured viaSaveSlotOptions.Compression. Because encrypted data is high-entropy, it compresses far less effectively than the original plaintext snapshot — expect encrypted.ksavefiles to be noticeably larger than unencrypted ones using the sameSaveSlotOptions.AesEncryptionProviderderives its key with 100,000 PBKDF2 iterations on everyEncrypt/Decryptcall. This is deliberate (it makes brute-forcing the password expensive) but means each save/load pays a fixed key-derivation cost in addition to serialization and compression time.- Use
SaveToSlotAsync/LoadFromSlotAsyncto keep encryption and file I/O off the main thread;AesEncryptionProvider's async methods currently wrap the synchronous implementation inTask.FromResult, so the CPU-bound work still runs on the calling thread unless the caller offloads it (e.g. withTask.Run).
Next Steps
- Serialization and Snapshots — the snapshot creation, binary/JSON serialization, and restore mechanism that
EncryptedPersistenceApibuilds on. - Plugins — how
IWorldPlugin,IPluginContext, extensions, and capabilities work in general. - Change Tracking — for scenarios that need incremental save data rather than full snapshots.