Skip to content

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 →