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 — hide and recover a message in the least-significant bits of
* RGBA pixel data (LSB steganography).
*
* Language: C (C11, 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.
*
* Scope: this snippet is the LSB encode/decode math — the part that is pure
* arithmetic over a pixel buffer. The reference's two browser-bound halves are
* deliberately out of scope: Canvas/ImageData acquisition (the caller here
* hands in a plain RGBA byte array) and the optional AES-256-GCM body
* encryption (SubtleCrypto). The wire format's encryption flag is still parsed
* and reported, so an encrypted image is diagnosed rather than mis-decoded —
* see STEGO_ERR_ENCRYPTION_UNSUPPORTED for where a crypto backend would plug in.
*
* 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. The flag is what
* makes the "password required" / "not password-protected" diagnoses exact.
*
* Payload bits are written MSB-first, one per R/G/B channel in raster order
* (Alpha is never touched): bit i lands in pixel i/3, channel i%3.
* Capacity = width * height * 3 / 8 payload bytes, header included.
*
* C has no exceptions, so every path the reference throws on returns a
* StegoStatus instead; stego_status_message() maps each one to the reference's
* exact user-facing string.
*
* Build: cc -std=c11 steganography.c
*/
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* ---------------------------------------------------------------- constants --- */
#define SALT_BYTES 16
#define IV_BYTES 12
#define GCM_TAG_BYTES 16
/** Salt + IV + GCM tag overhead added to the body when a password is used. */
#define ENCRYPTION_OVERHEAD_BYTES (SALT_BYTES + IV_BYTES + GCM_TAG_BYTES)
/** The 4-byte length header is also stored in the pixels, so it costs capacity. */
#define HEADER_BYTES 4
/** The header's top bit: set when the body is encrypted. */
#define ENCRYPTED_FLAG 0x80000000u
#define LENGTH_MASK 0x7fffffffu
/* ------------------------------------------------------------------- types --- */
/**
* Minimal RGBA pixel buffer — the C stand-in for the browser's ImageData.
* `data` holds width*height*4 bytes in R,G,B,A order, raster order.
*/
typedef struct {
size_t width;
size_t height;
uint8_t *data;
size_t len;
} StegoImage;
/** Every failure the reference throws on, as a return code. */
typedef enum {
STEGO_OK = 0,
STEGO_ERR_DIMENSIONS,
STEGO_ERR_EMPTY_PASSWORD,
STEGO_ERR_TOO_LONG,
STEGO_ERR_NOT_FOUND,
STEGO_ERR_NOT_ENCRYPTED,
STEGO_ERR_PASSWORD_REQUIRED,
STEGO_ERR_ENCRYPTION_UNSUPPORTED,
STEGO_ERR_BUFFER_TOO_SMALL
} StegoStatus;
/** The reference's error strings, verbatim where they are not parameterised. */
const char *stego_status_message(StegoStatus s)
{
switch (s) {
case STEGO_OK:
return "ok";
case STEGO_ERR_DIMENSIONS:
return "Width and height must be positive integers";
case STEGO_ERR_EMPTY_PASSWORD:
return "Password must not be empty";
case STEGO_ERR_TOO_LONG:
return "Message too long for this image";
case STEGO_ERR_NOT_FOUND:
return "No hidden message found in this image";
case STEGO_ERR_NOT_ENCRYPTED:
return "This message is not password-protected - extract without a password";
case STEGO_ERR_PASSWORD_REQUIRED:
return "This image contains an encrypted message - a password is required";
case STEGO_ERR_ENCRYPTION_UNSUPPORTED:
return "Encrypted payloads need an AES-256-GCM backend (out of scope here)";
case STEGO_ERR_BUFFER_TOO_SMALL:
return "Output buffer too small";
default:
return "Unknown error";
}
}
/* ---------------------------------------------------------------- capacity --- */
/**
* Max payload bytes (header + body) an image of this size can carry.
* Fails on a degenerate size, matching the reference's throw.
*/
StegoStatus calculate_capacity(size_t width, size_t height, size_t *out)
{
if (width == 0 || height == 0) return STEGO_ERR_DIMENSIONS;
*out = (width * height * 3) / 8;
return STEGO_OK;
}
/** Largest message body an image of this size can hold, in bytes. */
size_t max_message_bytes(size_t width, size_t height, bool encrypted)
{
size_t capacity = 0;
if (calculate_capacity(width, height, &capacity) != STEGO_OK) return 0;
size_t overhead = HEADER_BYTES + (encrypted ? ENCRYPTION_OVERHEAD_BYTES : 0);
return capacity > overhead ? capacity - overhead : 0;
}
/* -------------------------------------------------------------- bit plumbing --- */
/**
* Write `payload` into the LSBs of the R/G/B channels of `data`, in place.
* Bit i of the payload (MSB-first) lands in pixel i/3, channel i%3; the alpha
* channel is never touched.
*/
static void embed_bits(uint8_t *data, size_t data_len,
const uint8_t *payload, size_t payload_len)
{
size_t total_bits = payload_len * 8;
for (size_t i = 0; i < total_bits; i++) {
uint8_t byte = payload[i >> 3];
uint8_t bit = (uint8_t)((byte >> (7 - (i & 7))) & 1);
size_t idx = (i / 3) * 4 + (i % 3);
if (idx >= data_len) return; /* capacity was checked by the caller */
data[idx] = (uint8_t)((data[idx] & 0xfe) | bit);
}
}
/** Read `count` payload bytes back out of the R/G/B LSBs, from `offset_bytes`. */
static void extract_bits(const uint8_t *data, size_t data_len,
size_t offset_bytes, uint8_t *out, size_t count)
{
memset(out, 0, count);
size_t start_bit = offset_bytes * 8;
for (size_t i = 0; i < count * 8; i++) {
size_t bit_index = start_bit + i;
size_t idx = (bit_index / 3) * 4 + (bit_index % 3);
if (idx >= data_len) return;
uint8_t bit = (uint8_t)(data[idx] & 1);
out[i >> 3] = (uint8_t)(out[i >> 3] | (bit << (7 - (i & 7))));
}
}
/* ------------------------------------------------------------ UTF-8 checking --- */
/*
* The reference decodes a plaintext body with TextDecoder({ fatal: true }) and
* reports "no hidden message" when that throws — i.e. random pixels that happen
* to parse as a plausible header still fail here. This is that same check:
* strict UTF-8, rejecting overlong forms, surrogates and out-of-range scalars.
*/
static bool is_valid_utf8(const uint8_t *s, size_t n)
{
size_t i = 0;
while (i < n) {
uint8_t c = s[i];
size_t extra;
uint32_t cp;
if (c < 0x80) { i++; continue; }
else if ((c & 0xe0) == 0xc0) { extra = 1; cp = c & 0x1fu; }
else if ((c & 0xf0) == 0xe0) { extra = 2; cp = c & 0x0fu; }
else if ((c & 0xf8) == 0xf0) { extra = 3; cp = c & 0x07u; }
else return false;
if (i + extra >= n + 0 && i + extra > n - 1) return false;
for (size_t k = 1; k <= extra; k++) {
uint8_t cc = s[i + k];
if ((cc & 0xc0) != 0x80) return false;
cp = (cp << 6) | (cc & 0x3fu);
}
/* Reject overlong encodings, UTF-16 surrogates and > U+10FFFF. */
if (extra == 1 && cp < 0x80) return false;
if (extra == 2 && cp < 0x800) return false;
if (extra == 3 && cp < 0x10000) return false;
if (cp > 0x10ffff) return false;
if (cp >= 0xd800 && cp <= 0xdfff) return false;
i += extra + 1;
}
return true;
}
/* --------------------------------------------------------------- public API --- */
/**
* Hide `message` in the LSBs of `image`'s pixels, in place. `image->data` is
* modified; copy it first if the original must survive (the TS version returns a
* fresh buffer because JS callers expect immutability).
*
* The body is stored as raw UTF-8 — this snippet writes plaintext payloads only,
* so the header's encryption flag is always 0.
*/
StegoStatus stego_hide_message(StegoImage *image, const char *message)
{
if (image == NULL || message == NULL) return STEGO_ERR_DIMENSIONS;
size_t capacity = 0;
StegoStatus st = calculate_capacity(image->width, image->height, &capacity);
if (st != STEGO_OK) return st;
size_t body_len = strlen(message);
size_t payload_len = HEADER_BYTES + body_len;
if (payload_len > capacity) return STEGO_ERR_TOO_LONG;
uint8_t *payload = malloc(payload_len);
if (payload == NULL) return STEGO_ERR_BUFFER_TOO_SMALL;
uint32_t header = (uint32_t)body_len & LENGTH_MASK; /* flag clear: plaintext */
payload[0] = (uint8_t)(header >> 24);
payload[1] = (uint8_t)(header >> 16);
payload[2] = (uint8_t)(header >> 8);
payload[3] = (uint8_t)header;
memcpy(payload + HEADER_BYTES, message, body_len);
embed_bits(image->data, image->len, payload, payload_len);
free(payload);
return STEGO_OK;
}
/**
* Read the hidden message out of `image`'s pixels into `out` (NUL-terminated).
* `have_password` reports whether the caller supplied one, so the encrypted /
* plaintext mismatch cases return the same diagnosis as the reference.
*/
StegoStatus stego_extract_message(const StegoImage *image, bool have_password,
char *out, size_t cap, size_t *out_len)
{
if (image == NULL || out == NULL || cap == 0) return STEGO_ERR_BUFFER_TOO_SMALL;
size_t capacity = 0;
StegoStatus st = calculate_capacity(image->width, image->height, &capacity);
if (st != STEGO_OK) return st;
uint8_t head[HEADER_BYTES];
extract_bits(image->data, image->len, 0, head, HEADER_BYTES);
uint32_t header = ((uint32_t)head[0] << 24) | ((uint32_t)head[1] << 16) |
((uint32_t)head[2] << 8) | (uint32_t)head[3];
bool encrypted = (header & ENCRYPTED_FLAG) != 0;
size_t length = header & LENGTH_MASK;
if (length == 0 && !encrypted) {
out[0] = '\0';
if (out_len != NULL) *out_len = 0;
return STEGO_OK;
}
size_t floor_len = encrypted ? (size_t)ENCRYPTION_OVERHEAD_BYTES : 1;
if (HEADER_BYTES + length > capacity || length < floor_len) {
return STEGO_ERR_NOT_FOUND;
}
if (encrypted) {
/* Body is salt || IV || AES-256-GCM ciphertext+tag. Decrypting it needs
* PBKDF2-SHA256 (100k iterations) and AES-GCM from a crypto backend —
* the reference gets both from SubtleCrypto. */
if (!have_password) return STEGO_ERR_PASSWORD_REQUIRED;
return STEGO_ERR_ENCRYPTION_UNSUPPORTED;
}
if (have_password) return STEGO_ERR_NOT_ENCRYPTED;
if (length + 1 > cap) return STEGO_ERR_BUFFER_TOO_SMALL;
extract_bits(image->data, image->len, HEADER_BYTES, (uint8_t *)out, length);
if (!is_valid_utf8((const uint8_t *)out, length)) return STEGO_ERR_NOT_FOUND;
out[length] = '\0';
if (out_len != NULL) *out_len = length;
return STEGO_OK;
}
/* -------------------------------------------------------------------- demo --- */
static StegoImage make_image(size_t w, size_t h, uint8_t fill)
{
StegoImage img = { w, h, NULL, w * h * 4 };
img.data = malloc(img.len);
if (img.data != NULL) memset(img.data, fill, img.len);
return img;
}
int main(void)
{
/* A 64x64 image carries floor(64*64*3/8) = 1536 payload bytes. */
size_t capacity = 0;
calculate_capacity(64, 64, &capacity);
printf("64x64 capacity : %zu payload bytes\n", capacity);
printf(" max plaintext body: %zu bytes\n", max_message_bytes(64, 64, false));
printf(" max encrypted body: %zu bytes\n\n", max_message_bytes(64, 64, true));
StegoImage img = make_image(64, 64, 0x7f);
const char *secret = "Meet at the docks — bring the manifest. \xE2\x9C\x93";
StegoStatus st = stego_hide_message(&img, secret);
printf("hide : %s\n", stego_status_message(st));
char recovered[2048];
size_t got = 0;
st = stego_extract_message(&img, false, recovered, sizeof recovered, &got);
printf("extract: %s\n", stego_status_message(st));
printf(" %zu bytes: \"%s\"\n", got, recovered);
printf(" round-trip: %s\n\n", strcmp(recovered, secret) == 0 ? "exact" : "DIFFERS");
/* Handing a password to a plaintext payload is its own diagnosis. */
st = stego_extract_message(&img, true, recovered, sizeof recovered, &got);
printf("extract with password: %s\n", stego_status_message(st));
/* Untouched pixels carry no payload: the header reads as a huge length. */
StegoImage clean = make_image(8, 8, 0xff);
st = stego_extract_message(&clean, false, recovered, sizeof recovered, &got);
printf("empty image : %s\n", stego_status_message(st));
/* A message past capacity is refused before any pixel is touched. */
StegoImage tiny = make_image(4, 4, 0x00);
st = stego_hide_message(&tiny, "this will not fit in sixteen pixels at all");
printf("oversized message : %s\n", stego_status_message(st));
free(img.data);
free(clean.data);
free(tiny.data);
return EXIT_SUCCESS;
}
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 →