
All code is illustrative and unexecuted.
The original article linked to a passphrase-encryption example and reproduced its code. The historical Stack Overflow source is retained for attribution, not as a recommended implementation. That code uses unauthenticated CBC and a 256-bit Rijndael block size. AES has a 128-bit block size; selecting a 256-bit key does not change that. The old 1,000-iteration password derivation and single CryptoStream read are also unsuitable foundations for a new format.
For new data, this illustrative replacement uses AES-256-GCM, which authenticates ciphertext before the caller receives plaintext. PBKDF2-HMAC-SHA-256 derives a 32-byte key from a passphrase and a fresh random 16-byte salt. The fixed work factor is 600,000 iterations. This borrows a current OWASP PBKDF2 baseline; benchmark and review it for your own workload. It is not a universal performance guarantee or a FIPS certification claim. Strong passphrases are still necessary because an attacker with the ciphertext can try guesses offline.
A versioned authenticated envelope
The format is version (1) | iterations (4, big-endian) | salt (16) | nonce (12) | tag (16) | ciphertext, measured in bytes. The header and a fixed application-context string are authenticated as associated data. Decryption rejects other versions or iteration counts before deriving a key, so untrusted input cannot demand an arbitrary work factor. The example is bounded to 1 MiB of UTF-8 plaintext and passphrases up to 1,024 UTF-16 code units.
This is illustrative C# targeting the .NET 8-or-later AES-GCM API with an explicit tag size. It has been inspected against the API documentation, but it has not been compiled, executed or tested here. It is not a substitute for a reviewed application cryptography component.
using System;
using System.Buffers.Binary;
using System.Security.Cryptography;
using System.Text;
public static class PassphraseCipher
{
private const byte Version = 1;
private const int Iterations = 600_000;
private const int SaltSize = 16;
private const int NonceSize = 12;
private const int TagSize = 16;
private const int HeaderSize = 1 + 4 + SaltSize + NonceSize;
private const int MaxPlaintextBytes = 1_048_576;
private const int MaxEncodedChars = ((HeaderSize + TagSize + MaxPlaintextBytes + 2) / 3) * 4;
private static readonly UTF8Encoding Utf8 = new(false, true);
private static readonly byte[] Context = Encoding.ASCII.GetBytes("SysSeder.StringCipher.v1");
private static void CheckPassphrase(string passphrase)
{
if (string.IsNullOrEmpty(passphrase) || passphrase.Length > 1024)
throw new ArgumentException("Passphrase is empty or too long.");
if (!AesGcm.IsSupported)
throw new PlatformNotSupportedException("AES-GCM is unavailable.");
}
private static byte[] AssociatedData(ReadOnlySpan<byte> header)
{
var result = new byte[Context.Length + HeaderSize];
Context.CopyTo(result, 0);
header.CopyTo(result.AsSpan(Context.Length));
return result;
}
public static string Encrypt(string plaintext, string passphrase)
{
CheckPassphrase(passphrase);
ArgumentNullException.ThrowIfNull(plaintext);
if (plaintext.Length > MaxPlaintextBytes || Utf8.GetByteCount(plaintext) > MaxPlaintextBytes)
throw new ArgumentException("Plaintext exceeds the example's 1 MiB limit.");
byte[] plain = Utf8.GetBytes(plaintext);
byte[] envelope = new byte[HeaderSize + TagSize + plain.Length];
Span<byte> header = envelope.AsSpan(0, HeaderSize);
header[0] = Version;
BinaryPrimitives.WriteInt32BigEndian(header.Slice(1, 4), Iterations);
Span<byte> salt = header.Slice(5, SaltSize);
Span<byte> nonce = header.Slice(5 + SaltSize, NonceSize);
RandomNumberGenerator.Fill(salt);
RandomNumberGenerator.Fill(nonce);
byte[] key = Rfc2898DeriveBytes.Pbkdf2(passphrase, salt.ToArray(),
Iterations, HashAlgorithmName.SHA256, 32);
try
{
using var aes = new AesGcm(key, TagSize);
aes.Encrypt(nonce, plain,
envelope.AsSpan(HeaderSize + TagSize),
envelope.AsSpan(HeaderSize, TagSize), AssociatedData(header));
return Convert.ToBase64String(envelope);
}
finally
{
CryptographicOperations.ZeroMemory(key);
CryptographicOperations.ZeroMemory(plain);
}
}
public static string Decrypt(string encoded, string passphrase)
{
CheckPassphrase(passphrase);
if (string.IsNullOrEmpty(encoded) || encoded.Length > MaxEncodedChars)
throw new CryptographicException("Invalid encrypted value.");
byte[] envelope;
try { envelope = Convert.FromBase64String(encoded); }
catch (FormatException) { throw new CryptographicException("Invalid encrypted value."); }
if (envelope.Length < HeaderSize + TagSize
|| envelope.Length > HeaderSize + TagSize + MaxPlaintextBytes)
throw new CryptographicException("Invalid encrypted value.");
ReadOnlySpan<byte> header = envelope.AsSpan(0, HeaderSize);
if (header[0] != Version
|| BinaryPrimitives.ReadInt32BigEndian(header.Slice(1, 4)) != Iterations)
throw new CryptographicException("Unsupported encrypted-value format.");
ReadOnlySpan<byte> salt = header.Slice(5, SaltSize);
ReadOnlySpan<byte> nonce = header.Slice(5 + SaltSize, NonceSize);
byte[] key = Rfc2898DeriveBytes.Pbkdf2(passphrase, salt.ToArray(),
Iterations, HashAlgorithmName.SHA256, 32);
byte[] plain = new byte[envelope.Length - HeaderSize - TagSize];
try
{
using var aes = new AesGcm(key, TagSize);
aes.Decrypt(nonce,
envelope.AsSpan(HeaderSize + TagSize),
envelope.AsSpan(HeaderSize, TagSize), plain, AssociatedData(header));
return Utf8.GetString(plain);
}
catch (CryptographicException)
{
throw new CryptographicException("Unable to decrypt encrypted value.");
}
finally
{
CryptographicOperations.ZeroMemory(key);
CryptographicOperations.ZeroMemory(plain);
}
}
}Nonce handling, limits and migration
Every encryption generates a fresh salt and a fresh 96-bit nonce. GCM requires nonce uniqueness for a given key; do not replace this with a fixed nonce or reuse a salt/key/nonce combination. At very high message volumes, use a reviewed construction with explicit per-key usage limits rather than treating random generation as an unlimited guarantee. Add rate limits around any remotely reachable passphrase operation because expensive key derivation can be abused for denial of service.
A wrong passphrase or modified ciphertext must fail authentication; no unauthenticated plaintext should be returned. The implementation clears temporary byte buffers where practical, but immutable .NET strings cannot be reliably erased. Authentication does not itself prevent replay, conceal ciphertext length or solve password distribution.
This format is intentionally incompatible with the old Rijndael-CBC output. Do not silently fall back to legacy CBC when GCM validation fails. A separate, controlled migration must identify trusted old records, decrypt them with the legacy format only in that migration context, and re-encrypt them under the new format. Do not expose the legacy decryptor as a network oracle. Verification before deployment should include round trips, empty/Unicode inputs, wrong passphrases, truncation, header/tag/ciphertext mutations and resource limits. Those tests are recommendations, not reported results.
Primary sources
- Microsoft RijndaelManaged
- Microsoft CBC-mode vulnerabilities
- Microsoft AesGcm
- Microsoft Rfc2898DeriveBytes.Pbkdf2
- OWASP Password Storage Cheat Sheet
- NIST SP 800-38D
- AesGcm.Encrypt API
Original English article: String encryption with passphrase and AES in CBC mode. Original publication date: 2022-05-29T08:04:00.006+03:00.