Image Steganography — C# source
Hide a secret message inside a PNG image or extract a hidden message from one. Uses least-significant-bit encoding with optional AES encryption.
This is the C# implementation — the same logic the interactive tool runs, in a shareable, citable form.
// Steganography — least-significant-bit (LSB) message hiding on RGBA pixel
// data, with optional AES-256-GCM encryption.
//
// Language: C# (.NET 8+, standard library only)
// Source: CosmoDev polyglot showcase port of the Steganography tool, ported
// from src/lib/steganography.ts (the canonical TypeScript
// implementation).
// License: display source — part of CosmoDev's polyglot tool pages.
//
// Pure logic - no UI, no bitmap decode/encode; it operates on raw RGBA bytes
// in raster order (the caller supplies the pixels, e.g. from a Bitmap or an
// ImageSharp buffer). The LSB math is the part worth porting; the Canvas/bitmap
// plumbing of the web tool has no C# equivalent here.
//
// Wire format (the "payload" hidden in the pixels):
// 4-byte big-endian header, then the body. The header's top bit is an
// encryption flag (1 = body is salt+IV+AES-GCM ciphertext, 0 = body is raw
// UTF-8); the low 31 bits are the body length in bytes.
//
// Payload bits are written MSB-first, one per R/G/B channel in raster order
// (Alpha is never touched): bit i lands in pixel floor(i/3), channel i%3.
// Capacity = floor(width * height * 3 / 8) payload bytes.
using System;
using System.Buffers.Binary;
using System.Security.Cryptography;
using System.Text;
/// <summary>Minimal structural type satisfied by any RGBA pixel buffer.</summary>
public sealed record StegoImageData(int Width, int Height, byte[] Data);
public static class Steganography
{
private const int Pbkdf2Iterations = 100_000;
private const int SaltBytes = 16;
private const int IvBytes = 12;
private const int GcmTagBytes = 16;
/// <summary>Salt + IV + GCM tag overhead added to the body when a password is used.</summary>
public const int EncryptionOverheadBytes = SaltBytes + IvBytes + GcmTagBytes;
/// <summary>The 4-byte length header is also stored in the pixels, so it consumes capacity.</summary>
public const int HeaderBytes = 4;
/// <summary>Max payload bytes (header + body) an image of this size can carry.</summary>
public static int CalculateCapacity(int width, int height)
{
if (width <= 0 || height <= 0)
{
throw new ArgumentException("Width and height must be positive integers");
}
return width * height * 3 / 8;
}
private static byte[] DeriveKey(string password, byte[] salt) =>
Rfc2898DeriveBytes.Pbkdf2(
Encoding.UTF8.GetBytes(password), salt, Pbkdf2Iterations, HashAlgorithmName.SHA256, 32);
/// <summary>AES-256-GCM encrypt bytes → packed salt + IV + ciphertext (+ tag).</summary>
private static byte[] EncryptBytes(byte[] plain, string password)
{
var salt = new byte[SaltBytes];
var iv = new byte[IvBytes];
RandomNumberGenerator.Fill(salt);
RandomNumberGenerator.Fill(iv);
var key = DeriveKey(password, salt);
var cipher = new byte[plain.Length + GcmTagBytes];
try
{
using var aes = new AesGcm(key, GcmTagBytes);
aes.Encrypt(iv, plain, cipher[..plain.Length], cipher[plain.Length..]);
}
finally
{
CryptographicOperations.ZeroMemory(key);
}
var packed = new byte[SaltBytes + IvBytes + cipher.Length];
Buffer.BlockCopy(salt, 0, packed, 0, SaltBytes);
Buffer.BlockCopy(iv, 0, packed, SaltBytes, IvBytes);
Buffer.BlockCopy(cipher, 0, packed, SaltBytes + IvBytes, cipher.Length);
return packed;
}
/// <summary>Unpack and AES-256-GCM decrypt a salt + IV + ciphertext payload.</summary>
private static byte[] DecryptBytes(byte[] packed, string password)
{
var salt = packed[..SaltBytes];
var iv = packed[SaltBytes..(SaltBytes + IvBytes)];
var data = packed[(SaltBytes + IvBytes)..];
var plain = new byte[data.Length - GcmTagBytes];
var key = DeriveKey(password, salt);
try
{
using var aes = new AesGcm(key, GcmTagBytes);
aes.Decrypt(iv, data[..plain.Length], data[plain.Length..], plain);
}
catch (CryptographicException)
{
throw new CryptographicException("Decryption failed - wrong password or corrupted data");
}
finally
{
CryptographicOperations.ZeroMemory(key);
}
return plain;
}
/// <summary>Write <paramref name="payload"/> into the LSBs of the R/G/B channels; returns copied pixels.</summary>
private static byte[] EmbedBits(byte[] data, byte[] payload)
{
var output = (byte[])data.Clone(); // copy - the input is never mutated
var totalBits = payload.Length * 8;
for (var i = 0; i < totalBits; i++)
{
var bit = (payload[i >> 3] >> (7 - (i & 7))) & 1;
var px = i / 3;
var channel = i % 3;
var idx = px * 4 + channel;
output[idx] = (byte)((output[idx] & 0xfe) | bit);
}
return output;
}
/// <summary>Read <paramref name="count"/> payload bytes back out of the R/G/B LSBs.</summary>
private static byte[] ExtractBits(byte[] data, int offsetBytes, int count)
{
var output = new byte[count];
var startBit = offsetBytes * 8;
for (var i = 0; i < count * 8; i++)
{
var bitIndex = startBit + i;
var px = bitIndex / 3;
var channel = bitIndex % 3;
var bit = data[px * 4 + channel] & 1;
output[i >> 3] |= (byte)(bit << (7 - (i & 7)));
}
return output;
}
/// <summary>
/// Hide <paramref name="message"/> inside a copy of
/// <paramref name="image"/>'s pixels (LSB of R/G/B). With
/// <paramref name="password"/>, the message body is AES-256-GCM encrypted
/// first. Throws if the message (including header and encryption overhead)
/// exceeds the image capacity, or on an empty password.
/// </summary>
public static StegoImageData HideMessage(StegoImageData image, string message, string? password = null)
{
if (password == string.Empty)
{
throw new ArgumentException("Password must not be empty");
}
var capacity = CalculateCapacity(image.Width, image.Height);
var plain = Encoding.UTF8.GetBytes(message);
var body = password is null ? plain : EncryptBytes(plain, password);
var payload = new byte[HeaderBytes + body.Length];
BinaryPrimitives.WriteUInt32BigEndian(payload, (uint)body.Length | (password is null ? 0u : 0x8000_0000u));
Buffer.BlockCopy(body, 0, payload, HeaderBytes, body.Length);
if (payload.Length > capacity)
{
var maxBody = capacity - HeaderBytes;
throw new ArgumentException(
$"Message too long: {body.Length} bytes with overhead, but this image can hold at most {maxBody} bytes of message");
}
return image with { Data = EmbedBits(image.Data, payload) };
}
/// <summary>
/// Read the hidden message out of <paramref name="image"/>'s pixels.
/// Throws when the pixels carry no valid payload, when the payload is
/// encrypted but no password is given, when a password is given but the
/// payload is plaintext, and on a wrong password (GCM failure).
/// </summary>
public static string ExtractMessage(StegoImageData image, string? password = null)
{
if (password == string.Empty)
{
throw new ArgumentException("Password must not be empty");
}
var capacity = CalculateCapacity(image.Width, image.Height);
var header = BinaryPrimitives.ReadUInt32BigEndian(ExtractBits(image.Data, 0, HeaderBytes));
var encrypted = (header & 0x8000_0000u) != 0;
var length = (int)(header & 0x7fff_ffffu);
if (length == 0 && !encrypted) return string.Empty;
if (HeaderBytes + length > capacity || length < (encrypted ? SaltBytes + IvBytes + GcmTagBytes : 1))
{
throw new InvalidOperationException("No hidden message found in this image");
}
var body = ExtractBits(image.Data, HeaderBytes, length);
if (!encrypted)
{
if (password is not null)
{
throw new InvalidOperationException(
"This message is not password-protected - extract without a password");
}
try
{
return new UTF8Encoding(false, true).GetString(body);
}
catch (DecoderFallbackException)
{
throw new InvalidOperationException("No hidden message found in this image");
}
}
if (password is null)
{
throw new InvalidOperationException("This image contains an encrypted message - a password is required");
}
var plain = DecryptBytes(body, password);
return Encoding.UTF8.GetString(plain);
}
}
Also available in 9 other languages
Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →