Crypto Utils

30 min read

Authenticated encryption (AES-GCM, AES-CBC-HMAC, ChaCha20-Poly1305) with PBKDF2 key derivation, so your save files, leaderboard payloads, and stored player data resist tampering rather than just casual snooping.

Summary

The Scylla crypto utilities provide a single facade that picks the right algorithm for the platform, derives keys safely from passwords, and produces a self-describing ciphertext that decrypts without any out-of-band metadata. The entry point is ScyllaCrypto. Pass it bytes and a password (or a raw key) and get authenticated ciphertext as a result. Pass that ciphertext back later, get the original bytes (or a clear failure that says exactly what went wrong).

Three authenticated algorithms are included:

  • AES (Advanced Encryption Standard) in GCM (Galois/Counter Mode) – the modern default, hardware-accelerated on every desktop and most mobile CPUs.
  • AES-CBC (Cipher Block Chaining) with HMAC (Hash-based Message Authentication Code) – the fallback when GCM is unavailable, notably on some WebGL configurations.
  • ChaCha20-Poly1305 – an explicit opt-in that excels on devices without AES hardware acceleration.

The facade auto-detects platform support and picks AES-GCM where available, AES-CBC-HMAC otherwise. ChaCha20-Poly1305 is opt-in only. Every option is AEAD (Authenticated Encryption with Associated Data): the ciphertext carries an authentication tag, and a single bit of tampering causes decryption to fail loudly rather than return corrupted plaintext.

The reason this matters is correctness. Hand-rolled symmetric encryption is the single most common source of “secure-looking” code that is in fact broken: missing authentication, reused nonces, padding oracles, salts derived from the password, and so on. The crypto utilities handle every one of those traps internally. The salt and nonce are randomly generated per operation. The iteration count and algorithm identifier are embedded in the ciphertext so decryption needs only the password (or key) you already have. Sensitive buffers are zeroed in finally blocks. Authentication failures are reported with a structured CryptoErrorCode so the calling code can distinguish “wrong password” from “platform missing algorithm” without parsing exception messages.

Use the crypto utilities for:

  • Save-game data and any other player-modifiable file that should not be casually editable on disk.
  • Network traffic where TLS is not available or sufficient, such as room-to-room state sync or peer-to-peer payloads inside a custom protocol.
  • Securing offline secrets (license keys, account refresh tokens, anti-cheat session payloads) against trivial scraping.
  • Authenticated wrapping of game-server messages where integrity matters as much as confidentiality.
  • Anywhere you need deterministic decryption (the format embeds every parameter required to decrypt; only your password or key is required at runtime).

Features

  • Single ScyllaCrypto facade. One static class covers encrypt, decrypt, key derivation, and platform introspection across every algorithm. No primitive wiring; the facade picks the right pieces in the right order.
  • Three authenticated algorithms. AES-GCM (modern default, hardware-accelerated on every desktop and most mobile CPUs), AES-CBC-HMAC (automatic fallback for platforms without GCM), and ChaCha20-Poly1305 (opt-in, excels on devices without AES hardware acceleration).
  • Auto-detected platform default. ScyllaCrypto.DefaultAlgorithm is initialized at startup from GetBestAvailableAlgorithm(). AES-GCM on every normal desktop and mobile build; AES-CBC-HMAC on WebGL configurations that lack GCM. No configuration step required.
  • Two modes per algorithm. Password-based for the “store-and-ship” case (salt and PBKDF2 iterations handled internally); key-based for hot paths where the key is derived once and cached. Pick by use case; both produce the same self-describing ciphertext format.
  • AEAD by construction. Every algorithm authenticates the ciphertext with a tag (GCM tag, HMAC, or Poly1305). One bit of tampering causes decryption to fail loudly with a structured error code rather than return corrupted plaintext.
  • PBKDF2 key derivation. DeriveKey(password, salt, ...) runs PBKDF2 (Password-Based Key Derivation Function 2) with SHA-256 at the 2024 NIST recommended iteration count (600,000) by default. The iteration count is embedded in the ciphertext so encryption and decryption never need to agree out of band.
  • Self-describing ciphertext. The format header records the algorithm that ran (after any automatic fallback), the key size, the iteration count, and the salt size. Decryption needs only the password or key you already have; out-of-band metadata is never required.
  • Random salt and nonce per call. Every password-based encryption generates a fresh salt; every encryption (password or key mode) generates a fresh nonce. The same plaintext encrypted twice produces different ciphertexts by design.
  • Async support with cancellation. Long-running operations (large files, high-iteration key derivation) have Async counterparts accepting CancellationToken. Keeps the main thread responsive during heavy crypto work.
  • Structured error contract. CryptoException carries a CryptoErrorCode (WrongPassword, TamperedData, UnsupportedAlgorithm, …) so error handlers branch on a stable enum rather than parse exception messages.
  • Sensitive-buffer hygiene. Internal key and intermediate buffers are zeroed in finally blocks. Password hashing uses the constant-time PBKDF2 implementation from System.Security.Cryptography.
  • Runtime algorithm introspection. IsAlgorithmSupported(algorithm), GetBestAvailableAlgorithm(), and GetCurrentDefaultAlgorithm() make the platform-specific decision discoverable when you need it.
  • Crypto bridge from compression. Paired with Compression Utils, the framework supplies combined compress-then-encrypt and decrypt-then-decompress paths so the canonical “encrypted save” wire format is one call.

Modes and algorithms

Two design choices set the shape of any encryption call: the mode (how the key is obtained) and the algorithm (which cipher does the actual encryption). The facade lets you mix and match.

Three terms in the tables below deserve a quick definition if you are new to symmetric crypto:

  • Salt is a short block of random bytes mixed with the password before key derivation. It ensures that two players with the same password do not produce the same key, and it stops attackers from precomputing hashes for common passwords. Scylla generates a fresh salt for every password-based encryption and embeds it in the ciphertext header.
  • Nonce (also called IV in CBC mode) is a unique value used once per encryption to randomize the cipher output. Reusing a nonce with the same key catastrophically breaks AES-GCM and ChaCha20-Poly1305. Scylla generates a fresh nonce per call automatically; you never need to manage one.
  • Iterations is the PBKDF2 work factor: how many rounds of hashing the key derivation runs. Higher values make brute-forcing the password proportionally more expensive. Scylla defaults to 600,000 (the 2024 NIST recommendation for SHA-256-based PBKDF2) and embeds the value in the ciphertext so encryption and decryption do not need to agree out of band.

Modes

ModeWhen to use
Password-basedYou have a password and want a single value (the ciphertext) to store or transmit. Best for save files, exported data, encrypted assets, and any case where the recipient has only the password. Salt is generated and embedded automatically; PBKDF2 iterations are applied per operation.
Key-basedYou have a pre-derived 16/24/32-byte key (typically derived once via DeriveKey and cached, or generated for a session). Skips key derivation entirely, so this is significantly faster on every call. Best for hot paths, frequent encryption with a shared session key, or any case where the key has been derived once and reused many times. You are responsible for storing the key securely.

Algorithms

AlgorithmWire format on diskNotes
AESGCM[nonce (12B)] [tag (16B)] [ciphertext]Default and recommended. Hardware-accelerated on virtually every modern CPU (x86-64 AES-NI, ARM64 crypto extensions). 128/192/256-bit keys; the same plaintext encrypted twice always produces different ciphertext thanks to the random nonce. Falls back automatically to AESCBCHMAC on platforms without GCM support.
AESCBCHMAC[IV (16B)] [HMAC (32B)] [ciphertext (PKCS7-padded)]Encrypt-then-MAC construction with HMAC-SHA256 authentication. Used as the automatic fallback when AES-GCM is unavailable (some WebGL configurations). Output is larger and encryption is slower because of PKCS7 padding plus a separate HMAC pass; prefer AESGCM wherever it is supported. 128/192/256-bit keys; internally generates a separate HMAC key alongside the cipher key.
CHACHA20POLY1305[nonce (12B)] [tag (16B)] [ciphertext]RFC 8439 stream cipher with Poly1305 authentication. Often faster than AES-GCM on devices without AES hardware acceleration (older mobile SoCs, some embedded targets). 256-bit keys only; the facade automatically promotes a 128/192-bit KeySizeBits setting to 256 when this algorithm is selected. No automatic fallback: requesting ChaCha20-Poly1305 on a platform that does not support it raises CryptoException(UnsupportedAlgorithm).

ScyllaCrypto.GetBestAvailableAlgorithm() returns the best algorithm the current platform supports; ScyllaCrypto.IsAlgorithmSupported(algorithm) checks one specific algorithm. The facade calls GetBestAvailableAlgorithm() once at startup to set ScyllaCrypto.DefaultAlgorithm, so on a normal desktop or mobile build the default is AES-GCM with no extra wiring.

Every ciphertext is self-describing: the format header records the algorithm that actually ran (after any automatic fallback), the key size, the PBKDF2 iteration count (password mode), and the salt size. Decryption needs only the original password or key. There is no “wrong configuration” outcome: the only failure modes are wrong password, tampered data, or unsupported algorithm on the decryption platform.

Recipes

These recipes cover every public surface in the crypto subsystem. Each one is a self-contained answer to a single game-development problem, so jump to the one that matches what you are building. A few recipes reference the basic password-based round-trip shown in the first one; if you are new to the API, start there.

Encrypt a save file from a player password

Problem. You’re shipping a single-player game and you want save files on disk to resist casual inspection and editing. You don’t need key management infrastructure; the player’s password is the only thing you want to key the data against. Any save editor that opens the binary file should see opaque bytes, and a tampered save should fail loudly rather than load corrupted state.

Solution. Pass your plaintext bytes and a password to Encrypt. Salt generation, PBKDF2 key derivation, nonce generation, and authenticated encryption all happen internally. Pass the returned bytes and the same password to Decrypt to get the original data back. The same plaintext encrypted twice with the same password produces different ciphertexts because the salt and nonce are fresh per call.

/* Plain bytes to protect (any binary content works; this is just a string example). */
byte[] plaintext = Encoding.UTF8.GetBytes("Player save data goes here.");

/* Encrypt using the default algorithm (AES-GCM on most platforms, AES-CBC-HMAC on
 * WebGL). The output is a self-describing byte array: format header, salt, then
 * ciphertext. Store or transmit the whole thing as one unit. */
byte[] ciphertext = ScyllaCrypto.Encrypt(plaintext, password: "correct horse battery staple");

/* Decrypt by passing the same ciphertext and password back. The header tells the
 * facade which algorithm and iteration count were used, so you do not need to
 * remember those values separately. */
byte[] decrypted = ScyllaCrypto.Decrypt(ciphertext, password: "correct horse battery staple");

/* Zero the plaintext buffer when finished. SecureClear runs in O(N) and writes
 * zeros into the array; ordinary garbage collection does NOT guarantee that the
 * sensitive bytes are scrubbed from memory. */
ScyllaCrypto.SecureClear(decrypted);

Tune encryption strength per data sensitivity

Problem. You have multiple categories of data with different security requirements: a daily quest cache that only needs light protection, a standard player save, and a high-value account credential. Applying the same PBKDF2 iteration count to all three either over-spends CPU on low-value data or under-protects high-value data.

Solution. CryptoSettings controls algorithm, key size, PBKDF2 iteration count, salt size, and the underlying hash function. Three pre-built presets cover the common cases; build a custom instance only when the presets do not fit. Always call Validate() on a custom instance before using it to surface bad settings early with a descriptive error.

/* Three ready-made presets. They are shared, immutable instances; do not mutate. */
var fast    = CryptoSettings.Fast;     /* 100,000 PBKDF2 iterations; lowest latency  */
var stdSet  = CryptoSettings.Default;  /* 600,000 PBKDF2 iterations; the recommended */
var secure  = CryptoSettings.Secure;   /* 1,000,000 PBKDF2 iterations; high security */

byte[] data = Encoding.UTF8.GetBytes("Secret data.");

/* Pick the preset for the data sensitivity. */
byte[] fastEnc   = ScyllaCrypto.Encrypt(data, "pwd", fast);
byte[] normalEnc = ScyllaCrypto.Encrypt(data, "pwd", stdSet);
byte[] secureEnc = ScyllaCrypto.Encrypt(data, "pwd", secure);

/* Custom settings. Useful when the presets do not fit (e.g. forcing ChaCha20
 * for a known software-only target, or a non-standard hash). */
var custom = new CryptoSettings
{
    Algorithm     = CryptoAlgorithm.CHACHA20POLY1305,
    KeySizeBits   = 256,                       /* ChaCha20 requires 256 */
    Iterations    = 500_000,
    SaltSizeBytes = 32,
    HashAlgorithm = KeyDerivationHash.SHA512,  /* faster than SHA256 on 64-bit */
};

/* Validate up front. Invalid combinations throw a CryptoException with a
 * descriptive ErrorCode (InvalidKeySize, InvalidIterationCount, etc.) rather
 * than failing deep inside the provider layer. */
custom.Validate();

byte[] customEnc = ScyllaCrypto.Encrypt(data, "pwd", custom);

/* Clone a preset when a one-off tweak is needed. Mutating the shared preset
 * directly would affect every other caller. */
var withMoreIterations = CryptoSettings.Default.Clone();
withMoreIterations.Iterations = 800_000;

Speed up network payload encryption with a session key

Problem. You’re encrypting room-to-room state sync packets or leaderboard submission payloads over a custom protocol. Each packet needs to be encrypted before it leaves the client, and the full PBKDF2 cost paid per packet would be unaffordable – a single derivation at 600,000 iterations on low-end mobile can take several hundred milliseconds. You need the overhead to drop to the cost of the cipher alone, not the key derivation.

Solution. Derive the key once at session start and cache it for the lifetime of the connection. Key-based encryption skips PBKDF2 entirely on every subsequent call, so each packet pays only the AES-GCM cost. The key length determines the AES key size: 16 bytes for AES-128, 24 bytes for AES-192, 32 bytes for AES-256 or ChaCha20-Poly1305.

/* Generate a fresh random key for the session (32 bytes = 256 bits). Store this
 * key wherever session keys belong: secrets manager, encrypted with a master
 * key, hardware-backed storage, or kept in memory only for the session. */
byte[] sessionKey = ScyllaCrypto.GenerateKey(sizeBytes: 32);

byte[] message1 = Encoding.UTF8.GetBytes("First message.");
byte[] message2 = Encoding.UTF8.GetBytes("Second message.");

/* Each call encrypts with the supplied key and a fresh random nonce. Output is
 * a self-describing byte array: [version, algorithm, keySizeBytes][payload]. */
byte[] c1 = ScyllaCrypto.EncryptWithKey(message1, sessionKey);
byte[] c2 = ScyllaCrypto.EncryptWithKey(message2, sessionKey);

byte[] p1 = ScyllaCrypto.DecryptWithKey(c1, sessionKey);
byte[] p2 = ScyllaCrypto.DecryptWithKey(c2, sessionKey);

/* Always clear the key when it is no longer needed. */
ScyllaCrypto.SecureClear(sessionKey);

Encrypt a token or JSON blob to a Base64 string

Problem. Your game stores authentication tokens in PlayerPrefs, or embeds encrypted data inside a JSON response from a game server. The rest of your pipeline works in strings, not byte arrays, and you need the ciphertext to be safe to embed in a URL, store in a text column, or paste into JSON without escaping issues.

Solution. The string variants apply Base64 encoding around the binary encrypt/decrypt path. UTF-8 is used for the plaintext conversion. The output is a standard Base64 string safe for every text context.

/* Encrypt a string to a Base64-encoded string. */
string ciphertext = ScyllaCrypto.EncryptString("secret data", password: "pwd");

/* Decrypt back to the original string. */
string plaintext  = ScyllaCrypto.DecryptString(ciphertext, password: "pwd");

/* Safe variants that return false on auth failure instead of throwing. Useful
 * when the input comes from an untrusted source or the password may be wrong. */
if (ScyllaCrypto.TryDecryptString(ciphertext, "pwd", out var result))
{
    Log.Info($"Decrypted: {result}", LogCategory.Core);
}
else
{
    Log.Warning("Decryption failed: wrong password or tampered ciphertext.", LogCategory.Core);
}

Handle wrong passwords without crashing

Problem. Anyone who has shipped a save system that accepts a player-typed password knows the wrong-password path is the common case, not the exceptional one. Wrapping every load in try/catch on CryptoException is noisy and puts authentication failure on the exceptional-control-flow path, which is the wrong shape for something that happens regularly.

Solution. The TryDecrypt and TryDecryptString overloads return false on authentication failure instead of throwing. Reserve the throwing overloads for paths where decryption failure represents a programming error, not user input.

byte[] ciphertext = LoadFromDisk();

if (ScyllaCrypto.TryDecrypt(ciphertext, userEnteredPassword, out var plaintext))
{
    /* Success path: use plaintext, then clear it. */
    UseDecryptedData(plaintext);
    ScyllaCrypto.SecureClear(plaintext);
}
else
{
    /* Wrong password, tampered data, or unrelated bytes. The method does not
     * distinguish these cases by design (timing channels). Prompt the player
     * to retry. */
    ShowMessage("Wrong password.");
}

/* Settings overloads work the same way. */
bool ok = ScyllaCrypto.TryDecrypt(ciphertext, password, CryptoSettings.Default, out var alt);

Keep the main thread responsive during save and load

Problem. PBKDF2 (Password-Based Key Derivation Function 2) at the recommended iteration count is intentionally slow – that is what makes it expensive for an attacker trying every password. On the main thread of an interactive game, that same slowness shows up as a frame hitch every time a save is loaded. The player sees a freeze of several hundred milliseconds right when they hit “Load Game.”

Solution. Every encrypt, decrypt, and key-derivation method has an Async variant that runs the work on a background thread via Task.Run and supports cooperative cancellation through CancellationToken. The main thread stays free while the derivation runs.

public async UniTask EncryptSaveAsync(byte[] save, string password, CancellationToken ct)
{
    /* PBKDF2 key derivation is offloaded to a thread pool worker; the main
     * thread stays responsive during the (potentially long) computation. */
    byte[] ciphertext = await ScyllaCrypto.EncryptAsync(save, password, ct);

    await File.WriteAllBytesAsync(SavePath, ciphertext, ct);
}

public async UniTask<byte[]> DecryptSaveAsync(string password, CancellationToken ct)
{
    byte[] ciphertext = await File.ReadAllBytesAsync(SavePath, ct);
    return await ScyllaCrypto.DecryptAsync(ciphertext, password, ct);
}

/* String, key-based, and derivation variants exist with the same shape. */
string token = await ScyllaCrypto.EncryptStringAsync("payload", "pwd", ct);
byte[] enc   = await ScyllaCrypto.EncryptWithKeyAsync(plaintext, sessionKey, ct);
byte[] dec   = await ScyllaCrypto.DecryptWithKeyAsync(enc, sessionKey, ct);
byte[] key   = await ScyllaCrypto.DeriveKeyAsync("pwd", salt, keySizeBytes: 32, ct);

Derive and cache a session key from a password

Problem. You want to derive an encryption key once from the player’s password at login, then reuse it across many EncryptWithKey and DecryptWithKey calls for the session, without triggering the full PBKDF2 cost each time. You might also need a separately generated salt for a custom protocol, or a raw random key for a key exchange.

Solution. DeriveKey runs PBKDF2 and returns the raw key bytes. GenerateSalt and GenerateKey produce cryptographically random byte arrays. Call DeriveKey once at session start, cache the result in memory, and pass it to the key-based encrypt/decrypt methods throughout the session.

/* Generate a fresh random salt. Default is 32 bytes (256 bits). */
byte[] salt = ScyllaCrypto.GenerateSalt();

/* Default PBKDF2 derivation: 256-bit key, default iteration count, SHA-256. */
byte[] key = ScyllaCrypto.DeriveKey(password: "pwd", salt: salt, keySizeBytes: 32);

/* Full control via KeyDerivationSettings. Use this when iteration count, hash,
 * or key size need to be set together. */
var derivSettings = new KeyDerivationSettings
{
    Iterations    = 800_000,
    HashAlgorithm = KeyDerivationHash.SHA512,
    KeySizeBytes  = 32,
};
derivSettings.Validate();

byte[] strongKey = ScyllaCrypto.DeriveKey("pwd", salt, derivSettings);

/* Async variant for the same expensive operation. */
byte[] keyAsync = await ScyllaCrypto.DeriveKeyAsync("pwd", salt, derivSettings, ct);

/* Generate a key directly when no password is involved (e.g. a session key
 * derived from a key exchange or just a one-shot random key). */
byte[] sessionKey = ScyllaCrypto.GenerateKey(sizeBytes: 32);

/* Clear derived material when it is no longer needed. */
ScyllaCrypto.SecureClear(key);
ScyllaCrypto.SecureClear(strongKey);
ScyllaCrypto.SecureClear(sessionKey);

Chain encryption inline with existing byte buffers

Problem. Your existing code already holds the payload as a byte[] local variable or constructs it as a string inline, and calling a static method on ScyllaCrypto reads awkwardly in context. You want the call to chain off the buffer itself, the way other extension methods in the project do.

Solution. CryptoExtensions mirrors the static facade as extension methods on byte[] and string. There is no behavioral difference; the extensions exist to make call sites read more naturally when crypto is sprinkled throughout other code paths.

using Scylla.Core.Util.Crypto;

/* byte[] extensions. */
byte[] data = Encoding.UTF8.GetBytes("payload");

byte[] enc   = data.Encrypt("pwd");
byte[] dec   = enc.Decrypt("pwd");
byte[] encWk = data.EncryptWithKey(sessionKey);
byte[] decWk = encWk.DecryptWithKey(sessionKey);

/* string extensions. */
string cipher = "payload".EncryptString("pwd");
string plain  = cipher.DecryptString("pwd");

/* Async variants are also on the extension class. */
byte[] encAsync = await data.EncryptAsync("pwd", ct);
byte[] decAsync = await encAsync.DecryptAsync("pwd", ct);

Check which algorithm runs on the current build target

Problem. You’re building for multiple platforms – desktop, mobile, and WebGL – and you want to know at runtime which cipher the facade will actually use. On some WebGL configurations, AES-GCM is absent and the facade falls back to AES-CBC-HMAC automatically; on older Mono runtimes, ChaCha20-Poly1305 is not available at all. You need this information to log it at startup, or to branch on it before requesting a specific algorithm.

Solution. IsAlgorithmSupported checks one specific algorithm at runtime; GetBestAvailableAlgorithm returns one that is guaranteed to work on the current target.

/* Check a specific algorithm before committing to it. */
if (!ScyllaCrypto.IsAlgorithmSupported(CryptoAlgorithm.CHACHA20POLY1305))
{
    Log.Warning("ChaCha20-Poly1305 is not available on this build target.", LogCategory.Core);
}

/* Let the framework choose. Always returns a supported algorithm. */
CryptoAlgorithm best = ScyllaCrypto.GetBestAvailableAlgorithm();

/* Override the default algorithm globally at startup. */
ScyllaCrypto.DefaultAlgorithm = best;

/* Tune the default key size or iteration count once at startup if defaults do
 * not fit. These are plain static properties without synchronization; set them
 * before launching threads that call the encryption methods. */
ScyllaCrypto.DefaultKeySize    = 256;
ScyllaCrypto.DefaultIterations = 600_000;

Zero sensitive buffers and compare authentication tags safely

Problem. Two operations come up repeatedly outside the encrypt/decrypt cycle. First: after you’ve used a decrypted save or a derived key, you want to make sure the raw bytes don’t linger in the managed heap where a memory dump could recover them – but GC.Collect doesn’t guarantee zeroing. Second: your game receives a MAC (Message Authentication Code) or license signature over the wire and needs to compare it to a locally computed value, but a naive loop or SequenceEqual leaks which byte differs first, which lets an attacker binary-search the correct value one byte at a time.

Solution. SecureClear zeros a buffer in place regardless of GC behavior. ConstantTimeEquals compares two byte arrays without branching on content, so the execution time is independent of where they first differ.

/* Zero a sensitive buffer in place. Use this on plaintext, derived keys, and
 * any temporary buffer that held secret material. Standard garbage collection
 * does NOT guarantee that the bytes are scrubbed from the managed heap. */
byte[] secret = ScyllaCrypto.DeriveKey("pwd", salt);
try
{
    UseSecret(secret);
}
finally
{
    ScyllaCrypto.SecureClear(secret);
}

/* Compare two byte arrays in constant time. Always use this for comparing MACs,
 * authentication tags, fingerprints, license signatures, and any other value
 * where leaking the position of the first difference would let an attacker
 * recover the secret one byte at a time. Returns false on length mismatch
 * (still in constant time relative to length). */
byte[] computed = ComputeMAC(message);
byte[] received = ReadMACFromWire();

bool valid = ScyllaCrypto.ConstantTimeEquals(computed, received);
if (!valid)
{
    Log.Warning("MAC mismatch; rejecting message.", LogCategory.Core);
}

Branch on structured error codes after a failed decrypt

Problem. Anything that decrypts can fail, and that is by design – the AEAD authentication check is the whole point of authenticated encryption. But not all failures are equal: a wrong password should prompt a retry; a corrupted save file might prompt a cloud restore; an unsupported algorithm on a new platform needs a migration message. You need to distinguish these cases without parsing exception message strings.

Solution. Every failure path raises CryptoException with a structured CryptoErrorCode. Inspect the code to branch; the message text is for logging only and is not stable across releases. The TryDecrypt variants in “Handle wrong passwords without crashing” cover most use cases without try/catch; reserve this pattern for infrastructure paths where failure is genuinely unexpected.

try
{
    byte[] plaintext = ScyllaCrypto.Decrypt(ciphertext, password);
    UseDecryptedData(plaintext);
    ScyllaCrypto.SecureClear(plaintext);
}
catch (CryptoException ex) when (ex.ErrorCode == CryptoErrorCode.AuthenticationFailed)
{
    /* Wrong password OR tampered ciphertext. The two are deliberately
     * indistinguishable to prevent timing-based password probing. */
    Log.Warning("Decryption failed: wrong password or tampered data.", LogCategory.Core);
}
catch (CryptoException ex) when (ex.ErrorCode == CryptoErrorCode.UnsupportedAlgorithm)
{
    /* Platform does not support the algorithm in the ciphertext header. Falls
     * out of cold storage when moving a save file between platforms with
     * different crypto support. */
    Log.Error($"Algorithm not available on this build: {ex.Message}", LogCategory.Core);
}
catch (CryptoException ex) when (ex.ErrorCode == CryptoErrorCode.CorruptedData)
{
    /* Truncated buffer, unrelated bytes, or a format-version mismatch. */
    Log.Error("Encrypted blob is not a valid ScyllaCrypto ciphertext.", LogCategory.Core);
}
catch (CryptoException ex)
{
    /* Any other crypto failure. ex.ErrorCode pinpoints the cause. */
    Log.Error($"Decryption error ({ex.ErrorCode}): {ex.Message}", LogCategory.Core);
}

API Sketch

The crypto surface has one facade (ScyllaCrypto), one extension class (CryptoExtensions), two settings types (CryptoSettings and the narrower KeyDerivationSettings), one algorithm enum, one error-code enum, and one exception type. Every encryption-side method has a matching decryption method and an async variant; key-derivation has a sync and async pair. The facade itself is allocation-light: the only persistent allocations are the input plaintext, the output ciphertext, and a small internal salt or nonce buffer that is cleared in finally.

public static class ScyllaCrypto
{
    /* Static configuration applied when no per-call CryptoSettings is supplied.
     * Set once at startup before spawning threads. */
    public static CryptoAlgorithm DefaultAlgorithm   { get; set; }   // best available at startup
    public static int             DefaultKeySize     { get; set; }   // bits; default 256
    public static int             DefaultIterations  { get; set; }   // PBKDF2; default 600_000

    /* Password-based, synchronous. */
    public static byte[] Encrypt(byte[] plaintext, string password);
    public static byte[] Encrypt(byte[] plaintext, string password, CryptoSettings settings);
    public static byte[] Decrypt(byte[] ciphertext, string password);
    public static byte[] Decrypt(byte[] ciphertext, string password, CryptoSettings settings);
    public static bool   TryDecrypt(byte[] ciphertext, string password, out byte[] plaintext);
    public static bool   TryDecrypt(byte[] ciphertext, string password, CryptoSettings settings, out byte[] plaintext);

    /* Password-based, string convenience (Base64 in/out). */
    public static string EncryptString(string plaintext, string password);
    public static string EncryptString(string plaintext, string password, CryptoSettings settings);
    public static string DecryptString(string ciphertext, string password);
    public static string DecryptString(string ciphertext, string password, CryptoSettings settings);
    public static bool   TryDecryptString(string ciphertext, string password, out string plaintext);
    public static bool   TryDecryptString(string ciphertext, string password, CryptoSettings settings, out string plaintext);

    /* Key-based, synchronous. */
    public static byte[] EncryptWithKey(byte[] plaintext, byte[] key);
    public static byte[] EncryptWithKey(byte[] plaintext, byte[] key, CryptoSettings settings);
    public static byte[] DecryptWithKey(byte[] ciphertext, byte[] key);
    public static byte[] DecryptWithKey(byte[] ciphertext, byte[] key, CryptoSettings settings);

    /* Async variants. All accept an optional CancellationToken. */
    public static Task<byte[]> EncryptAsync         (byte[] plaintext, string password, CancellationToken ct = default);
    public static Task<byte[]> DecryptAsync         (byte[] ciphertext, string password, CancellationToken ct = default);
    public static Task<byte[]> EncryptWithKeyAsync  (byte[] plaintext, byte[] key,       CancellationToken ct = default);
    public static Task<byte[]> DecryptWithKeyAsync  (byte[] ciphertext, byte[] key,       CancellationToken ct = default);
    public static Task<string> EncryptStringAsync   (string plaintext, string password, CancellationToken ct = default);
    public static Task<string> DecryptStringAsync   (string ciphertext, string password, CancellationToken ct = default);

    /* Key derivation. */
    public static byte[]       DeriveKey      (string password, byte[] salt, int keySizeBytes = 32);
    public static byte[]       DeriveKey      (string password, byte[] salt, KeyDerivationSettings settings);
    public static Task<byte[]> DeriveKeyAsync (string password, byte[] salt, int keySizeBytes = 32, CancellationToken ct = default);
    public static Task<byte[]> DeriveKeyAsync (string password, byte[] salt, KeyDerivationSettings settings, CancellationToken ct = default);

    /* Random generation helpers. */
    public static byte[] GenerateSalt(int sizeBytes = 32);
    public static byte[] GenerateKey (int sizeBytes = 32);

    /* Utilities. */
    public static void              SecureClear         (byte[] data);
    public static bool              ConstantTimeEquals  (byte[] a, byte[] b);
    public static bool              IsAlgorithmSupported(CryptoAlgorithm algorithm);
    public static CryptoAlgorithm   GetBestAvailableAlgorithm();
}

public static class CryptoExtensions
{
    /* Fluent extension methods on byte[] and string for every Encrypt/Decrypt
     * variant above (sync and async), plus Base64 helpers. Pure pass-through
     * to ScyllaCrypto; behavior is identical to the static API. */
}

public sealed class CryptoSettings
{
    public CryptoAlgorithm    Algorithm     { get; set; }   // default: AESGCM
    public int                KeySizeBits   { get; set; }   // 128/192/256; default 256
    public int                Iterations    { get; set; }   // >= 10000; default 600000
    public int                SaltSizeBytes { get; set; }   // >= 16;   default 32
    public KeyDerivationHash  HashAlgorithm { get; set; }   // default SHA256

    public static CryptoSettings Default { get; }   // 600000 iterations
    public static CryptoSettings Fast    { get; }   // 100000 iterations
    public static CryptoSettings Secure  { get; }   // 1000000 iterations

    public CryptoSettings Clone();
    public void           Validate();   // throws CryptoException on out-of-range values
}

public sealed class KeyDerivationSettings
{
    public int                Iterations    { get; set; }
    public KeyDerivationHash  HashAlgorithm { get; set; }
    public int                KeySizeBytes  { get; set; }

    public static KeyDerivationSettings Default { get; }

    public KeyDerivationSettings Clone();
    public void                  Validate();
}

public enum CryptoAlgorithm     { AESGCM, AESCBCHMAC, CHACHA20POLY1305 }
public enum KeyDerivationHash   { SHA256, SHA512 }

public sealed class CryptoException : Exception
{
    public CryptoErrorCode ErrorCode { get; }
}

public enum CryptoErrorCode
{
    Unknown, InvalidKey, InvalidKeySize, InvalidPassword,
    AuthenticationFailed, DecryptionFailed, CorruptedData,
    UnsupportedAlgorithm, NullData, InvalidIterationCount,
    OperationCancelled,
}

See the API reference for full signatures, remarks, and exception contracts on every member.

Settings reference

CryptoSettings

CryptoSettings bundles every parameter the facade needs to encrypt: which algorithm, what key size, how many PBKDF2 iterations, how much salt, and which hash function. The three pre-built presets (Default, Fast, Secure) cover the common cases.

PropertyDefaultRange / Accepted valuesEffect
AlgorithmCryptoAlgorithm.AESGCMAESGCM, AESCBCHMAC, CHACHA20POLY1305Picks the cipher. AESGCM is the recommended default; AESCBCHMAC is the automatic WebGL fallback; CHACHA20POLY1305 is an explicit opt-in with no automatic fallback.
KeySizeBits256128, 192, or 256AES key length. ChaCha20-Poly1305 ignores anything other than 256 (smaller values are auto-promoted by the facade). Larger keys do not measurably impact throughput on hardware-accelerated AES; prefer 256 unless interop requires otherwise.
Iterations600,000>= 10,000 (MIN_ITERATIONS); presets define 100k / 600k / 1MPBKDF2 work factor. Higher values increase brute-force cost linearly with derivation time. The value is embedded in the ciphertext, so encryption and decryption never need to agree out of band.
SaltSizeBytes32>= 16Length of the random salt prepended to the ciphertext. 32 bytes is recommended; larger sizes have negligible performance impact and slightly more entropy.
HashAlgorithmKeyDerivationHash.SHA256SHA256, SHA512PBKDF2 internal hash function. SHA-512 may be marginally faster on 64-bit native targets but slower on 32-bit and WebGL builds. Security is equivalent at the recommended iteration counts.

Presets:

PresetAlgorithmIterationsBest for
DefaultAES-GCM600,000The recommended choice for most game data: save files, exported settings, encrypted assets. Matches 2024 NIST guidance for SHA-256-based PBKDF2.
FastAES-GCM100,000Lower-value, latency-sensitive data: cached assets, ephemeral session state, hot paths where the protected payload has limited value. Not for passwords or long-term secrets.
SecureAES-GCM1,000,000High-value secrets: account credentials, license keys, long-lived tokens. Significantly slower on mobile; prefer the async API when using this preset.

KeyDerivationSettings

A narrower configuration object for cases where only PBKDF2 key derivation is needed (typically deriving a session key once for later use with EncryptWithKey).

PropertyDefaultRange / Accepted valuesEffect
Iterations600,000>= 10,000PBKDF2 work factor; same trade-off as in CryptoSettings.
HashAlgorithmKeyDerivationHash.SHA256SHA256, SHA512PBKDF2 internal hash function; same trade-off as in CryptoSettings.
KeySizeBytes32typically 16, 24, or 32Output key length. Match this to the target cipher: 16 for AES-128, 24 for AES-192, 32 for AES-256 or ChaCha20.

Error codes

CryptoException.ErrorCode is the canonical way to programmatically distinguish failures. The codes below cover every documented path.

CodeWhen it appears
UnknownFallback for an unspecified failure with no more specific code. Inspect the message and InnerException for context.
InvalidKeyThe supplied key is null or its byte length does not match the requested key-size parameter (e.g. 24 bytes supplied for a 256-bit operation).
InvalidKeySizeThe requested key size in bits is not 128, 192, or 256, or the configured salt is shorter than 16 bytes.
InvalidPasswordA password-based method received a null or empty string. No default password is implied.
AuthenticationFailedThe AEAD tag did not verify. Most commonly: wrong password, wrong key, or the ciphertext was modified after encryption. The two cases are deliberately indistinguishable.
DecryptionFailedInternal decryption error not attributable to auth failure or corruption. Inspect InnerException.
CorruptedDataThe ciphertext is too short to contain the expected header, or the format version byte is unrecognised. Often means the bytes are not a Scylla ciphertext at all.
UnsupportedAlgorithmThe requested algorithm is not available on the current platform. Check via IsAlgorithmSupported before requesting a specific algorithm, or use GetBestAvailableAlgorithm.
NullDataA required byte-array parameter (plaintext, ciphertext, key, salt) was null where non-null was required.
InvalidIterationCountThe PBKDF2 iteration count is below 10,000. Increase to at least the framework minimum.
OperationCancelledAn async operation was cancelled via its CancellationToken before completion. Partially derived key material has been cleared before this is thrown.

Best Practices

  • Default to password-based mode for stored data; key-based mode for hot paths. Password-based mode is simpler and handles salts and iteration counts automatically. Key-based mode skips key derivation, so it is significantly faster when the same key encrypts many payloads.
  • Use the presets unless there is a specific reason not to. CryptoSettings.Default is the right answer for almost every gameplay use; Fast for low-value or latency-sensitive data; Secure for high-value secrets.
  • Always call Validate() on a custom CryptoSettings instance before passing it to an encryption method. Validation catches bad iteration counts, key sizes, and salt sizes at the call site rather than deep in the provider.
  • Call SecureClear on plaintext, derived keys, and any temporary buffer that held secret material as soon as it is no longer needed. Garbage collection does not guarantee the bytes are zeroed from the managed heap.
  • Use ConstantTimeEquals for every comparison of authentication-sensitive byte arrays. MACs, tags, fingerprints, license signatures: never use SequenceEqual, Array.Equals, or a hand-rolled loop. A timing-distinguishable compare leaks one byte at a time.
  • Prefer the async APIs for any user-visible PBKDF2 work. A 600,000-iteration derivation on a mobile device can take several hundred milliseconds; running it on the main thread produces a visible hitch.
  • Reach for TryDecrypt and TryDecryptString when the input may be wrong. User-typed passwords, possibly corrupted save files, and untrusted network input all fail in the expected path; reserve the throwing overloads for code paths where decryption failure represents a programming error.
  • Inspect CryptoException.ErrorCode rather than parsing exception messages. Codes are stable across releases; messages are not.
  • Set ScyllaCrypto.DefaultAlgorithm once at startup if a non-default algorithm is needed. Do not toggle it between calls; the static properties are not synchronized.
  • Never store the password. Store the random salt embedded in the ciphertext header and the iteration count (also embedded), nothing else. Re-deriving the key on every load is intentional.
  • For data that crosses platforms, prefer algorithms supported everywhere. AES-GCM is available on virtually every Unity build target except WebGL; AES-CBC-HMAC is the safe lowest common denominator; ChaCha20-Poly1305 is not portable to older runtimes.
  • Use GenerateKey for per-session keys and rotate them aggressively. Long-lived keys widen the blast radius of any compromise; short-lived session keys cap it.
  • Combine encryption with a checksum only when the threat model needs it. Authenticated encryption already detects tampering; an extra outer checksum is occasionally useful for early rejection of obviously broken transfers but is not a security boundary. See Checksum Utils.

Pitfalls