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.
// Least-significant-bit (LSB) steganography on RGBA pixel data, with optional
// AES-256-GCM encryption.
//
// Language: C++17 (standard library + OpenSSL EVP)
// Ported from src/lib/steganography.ts (the canonical TypeScript
// implementation). display source — part of CosmoDev's polyglot tool pages.
//
// Works on any RGBA buffer ({ width, height, data }), so tests can build
// synthetic buffers and callers can pass decoded image pixels straight 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 makes the
// "password required" / "not password-protected" errors deterministic.
//
// 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.
//
// Encryption mirrors src/lib/text-encryptor.ts: PBKDF2-SHA256 (100k
// iterations, 16-byte random salt) derives an AES-256 key; GCM encrypts with a
// 12-byte random IV.
#include <algorithm>
#include <cstdint>
#include <optional>
#include <stdexcept>
#include <string>
#include <vector>
#include <openssl/evp.h>
#include <openssl/rand.h>
namespace stego {
using Bytes = std::vector<uint8_t>;
constexpr int PBKDF2_ITERATIONS = 100000;
constexpr size_t SALT_BYTES = 16;
constexpr size_t IV_BYTES = 12;
constexpr size_t GCM_TAG_BYTES = 16;
/// Salt + IV + GCM tag overhead added to the body when a password is used.
constexpr size_t ENCRYPTION_OVERHEAD_BYTES = SALT_BYTES + IV_BYTES + GCM_TAG_BYTES;
/// The 4-byte length header is also stored in the pixels, so it consumes capacity.
constexpr size_t HEADER_BYTES = 4;
/** Minimal structural type satisfied by any RGBA pixel buffer. */
struct StegoImageData {
int width = 0;
int height = 0;
Bytes data; ///< width * height * 4 RGBA bytes
};
/** Max payload bytes (header + body) an image of this size can carry. */
size_t calculateCapacity(int width, int height) {
if (width <= 0 || height <= 0) {
throw std::invalid_argument("Width and height must be positive integers");
}
return static_cast<size_t>(width) * static_cast<size_t>(height) * 3 / 8;
}
// --- AES-256-GCM + PBKDF2 (the SubtleCrypto mirror) --------------------------
static Bytes deriveKey(const std::string& password, const uint8_t* salt, size_t saltLen) {
Bytes key(32);
if (PKCS5_PBKDF2_HMAC(password.data(), static_cast<int>(password.size()), salt,
static_cast<int>(saltLen), PBKDF2_ITERATIONS, EVP_sha256(),
static_cast<int>(key.size()), key.data()) != 1) {
throw std::runtime_error("PBKDF2 key derivation failed.");
}
return key;
}
static Bytes randomBytes(size_t n) {
Bytes out(n);
if (RAND_bytes(out.data(), static_cast<int>(n)) != 1) {
throw std::runtime_error("The system CSPRNG is unavailable.");
}
return out;
}
static Bytes gcmEncrypt(const Bytes& key, const uint8_t* iv, const uint8_t* data, size_t len) {
EVP_CIPHER_CTX* ctx = EVP_CIPHER_CTX_new();
if (ctx == nullptr) throw std::runtime_error("OpenSSL context allocation failed.");
Bytes out(len + GCM_TAG_BYTES);
int outLen = 0, total = 0;
bool ok = EVP_EncryptInit_ex(ctx, EVP_aes_256_gcm(), nullptr, nullptr, nullptr) == 1 &&
EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_SET_IVLEN, static_cast<int>(IV_BYTES), nullptr) == 1 &&
EVP_EncryptInit_ex(ctx, nullptr, nullptr, key.data(), iv) == 1 &&
(len == 0 || EVP_EncryptUpdate(ctx, out.data(), &outLen, data, static_cast<int>(len)) == 1) &&
([&] { total = outLen; return EVP_EncryptFinal_ex(ctx, out.data() + total, &outLen) == 1; }()) &&
([&] {
total += outLen;
return EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_GET_TAG, static_cast<int>(GCM_TAG_BYTES),
out.data() + total) == 1;
}());
EVP_CIPHER_CTX_free(ctx);
if (!ok) throw std::runtime_error("AES-256-GCM encryption failed.");
out.resize(static_cast<size_t>(total) + GCM_TAG_BYTES);
return out;
}
static bool gcmDecrypt(const Bytes& key, const uint8_t* iv, const uint8_t* data, size_t len, Bytes& out) {
if (len < GCM_TAG_BYTES) return false;
EVP_CIPHER_CTX* ctx = EVP_CIPHER_CTX_new();
if (ctx == nullptr) return false;
const size_t bodyLen = len - GCM_TAG_BYTES;
out.assign(bodyLen, 0);
uint8_t tag[GCM_TAG_BYTES];
std::copy_n(data + bodyLen, GCM_TAG_BYTES, tag);
int outLen = 0, total = 0;
bool ok = EVP_DecryptInit_ex(ctx, EVP_aes_256_gcm(), nullptr, nullptr, nullptr) == 1 &&
EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_SET_IVLEN, static_cast<int>(IV_BYTES), nullptr) == 1 &&
EVP_DecryptInit_ex(ctx, nullptr, nullptr, key.data(), iv) == 1 &&
(bodyLen == 0 || EVP_DecryptUpdate(ctx, out.data(), &outLen, data, static_cast<int>(bodyLen)) == 1) &&
([&] { total = outLen; return true; }()) &&
EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_SET_TAG, static_cast<int>(GCM_TAG_BYTES), tag) == 1 &&
EVP_DecryptFinal_ex(ctx, out.data() + total, &outLen) == 1;
EVP_CIPHER_CTX_free(ctx);
if (ok) out.resize(static_cast<size_t>(total + outLen));
return ok;
}
/** AES-256-GCM encrypt bytes -> packed salt + IV + ciphertext (+ tag). */
static Bytes encryptBytes(const Bytes& plain, const std::string& password) {
const Bytes salt = randomBytes(SALT_BYTES);
const Bytes iv = randomBytes(IV_BYTES);
const Bytes key = deriveKey(password, salt.data(), salt.size());
const Bytes cipher = gcmEncrypt(key, iv.data(), plain.data(), plain.size());
Bytes packed;
packed.reserve(SALT_BYTES + IV_BYTES + cipher.size());
packed.insert(packed.end(), salt.begin(), salt.end());
packed.insert(packed.end(), iv.begin(), iv.end());
packed.insert(packed.end(), cipher.begin(), cipher.end());
return packed;
}
/** Unpack and AES-256-GCM decrypt a salt + IV + ciphertext payload. */
static Bytes decryptBytes(const Bytes& packed, const std::string& password) {
const uint8_t* salt = packed.data();
const uint8_t* iv = packed.data() + SALT_BYTES;
const uint8_t* data = packed.data() + SALT_BYTES + IV_BYTES;
const size_t dataLen = packed.size() - SALT_BYTES - IV_BYTES;
const Bytes key = deriveKey(password, salt, SALT_BYTES);
Bytes plain;
if (!gcmDecrypt(key, iv, data, dataLen, plain)) {
throw std::runtime_error("Decryption failed - wrong password or corrupted data");
}
return plain;
}
// --- LSB bit plumbing ---------------------------------------------------------
/** Write `payload` into the LSBs of the R/G/B channels; returns copied pixels. */
static Bytes embedBits(const Bytes& data, const Bytes& payload) {
Bytes out = data; // copy - the input is never mutated
const size_t totalBits = payload.size() * 8;
for (size_t i = 0; i < totalBits; i++) {
const uint8_t byte = payload[i >> 3];
const uint8_t bit = (byte >> (7 - (i & 7))) & 1;
const size_t px = i / 3;
const size_t channel = i % 3;
const size_t idx = px * 4 + channel;
out[idx] = static_cast<uint8_t>((out[idx] & 0xfe) | bit);
}
return out;
}
/** Read `count` payload bytes back out of the R/G/B LSBs. */
static Bytes extractBits(const Bytes& data, size_t offsetBytes, size_t count) {
Bytes out(count, 0);
const size_t startBit = offsetBytes * 8;
for (size_t i = 0; i < count * 8; i++) {
const size_t bitIndex = startBit + i;
const size_t px = bitIndex / 3;
const size_t channel = bitIndex % 3;
const uint8_t bit = data[px * 4 + channel] & 1;
out[i >> 3] = static_cast<uint8_t>(out[i >> 3] | (bit << (7 - (i & 7))));
}
return out;
}
/** The TS TextDecoder('utf-8', { fatal: true }) equivalent: strict validation. */
static bool isValidUtf8(const Bytes& bytes) {
size_t i = 0;
const size_t n = bytes.size();
while (i < n) {
const uint8_t b0 = bytes[i];
if (b0 < 0x80) {
i++;
continue;
}
size_t len = 0;
uint32_t min = 0, max = 0;
uint8_t successorMask = 0;
if ((b0 & 0xe0) == 0xc0) { len = 2; min = 0x80; max = 0x7ff; successorMask = 0x80; }
else if ((b0 & 0xf0) == 0xe0) { len = 3; min = 0x800; max = 0xffff; successorMask = 0x80; }
else if ((b0 & 0xf8) == 0xf0) { len = 4; min = 0x10000; max = 0x10ffff; successorMask = 0x80; }
else return false;
if (i + len > n) return false;
uint32_t cp = b0 & (0xff >> (len + 1));
for (size_t j = 1; j < len; j++) {
const uint8_t bj = bytes[i + j];
if ((bj & 0xc0) != successorMask) return false;
cp = (cp << 6) | (bj & 0x3f);
}
if (cp < min || cp > max || (cp >= 0xd800 && cp <= 0xdfff)) return false;
i += len;
}
return true;
}
static void assertPassword(const std::optional<std::string>& password) {
if (password.has_value() && password->empty()) {
throw std::invalid_argument("Password must not be empty");
}
}
/**
* Hide `message` inside a copy of `imageData`'s pixels (LSB of R/G/B) and
* return the modified pixel data. With `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.
*/
StegoImageData hideMessage(const StegoImageData& imageData, const std::string& message,
const std::optional<std::string>& password = std::nullopt) {
assertPassword(password);
const size_t capacity = calculateCapacity(imageData.width, imageData.height);
const Bytes plain(message.begin(), message.end()); // UTF-8 bytes
const Bytes body = password.has_value() ? encryptBytes(plain, *password) : plain;
Bytes payload(HEADER_BYTES + body.size());
const uint32_t header = static_cast<uint32_t>(body.size()) |
(password.has_value() ? 0x80000000u : 0u);
payload[0] = static_cast<uint8_t>(header >> 24); // big-endian, like the DataView
payload[1] = static_cast<uint8_t>(header >> 16);
payload[2] = static_cast<uint8_t>(header >> 8);
payload[3] = static_cast<uint8_t>(header);
std::copy(body.begin(), body.end(), payload.begin() + HEADER_BYTES);
if (payload.size() > capacity) {
const size_t maxBody = capacity - HEADER_BYTES;
throw std::runtime_error("Message too long: " + std::to_string(body.size()) +
" bytes with overhead, but this image can hold at most " +
std::to_string(maxBody) + " bytes of message");
}
StegoImageData out;
out.width = imageData.width;
out.height = imageData.height;
out.data = embedBits(imageData.data, payload);
return out;
}
/**
* Read the hidden message out of `imageData`'s pixels. Throws when the pixels
* carry no valid payload ("No hidden message found"), 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 authentication failure).
*/
std::string extractMessage(const StegoImageData& imageData,
const std::optional<std::string>& password = std::nullopt) {
assertPassword(password);
const size_t capacity = calculateCapacity(imageData.width, imageData.height);
const Bytes headerBytesBuf = extractBits(imageData.data, 0, HEADER_BYTES);
const uint32_t header = (static_cast<uint32_t>(headerBytesBuf[0]) << 24) |
(static_cast<uint32_t>(headerBytesBuf[1]) << 16) |
(static_cast<uint32_t>(headerBytesBuf[2]) << 8) |
static_cast<uint32_t>(headerBytesBuf[3]);
const bool encrypted = (header & 0x80000000u) != 0;
const uint32_t length = header & 0x7fffffffu;
if (length == 0 && !encrypted) return "";
if (HEADER_BYTES + length > capacity ||
length < (encrypted ? static_cast<uint32_t>(SALT_BYTES + IV_BYTES + GCM_TAG_BYTES) : 1u)) {
throw std::runtime_error("No hidden message found in this image");
}
const Bytes body = extractBits(imageData.data, HEADER_BYTES, length);
if (!encrypted) {
if (password.has_value()) {
throw std::runtime_error("This message is not password-protected - extract without a password");
}
if (!isValidUtf8(body)) {
throw std::runtime_error("No hidden message found in this image");
}
return std::string(body.begin(), body.end());
}
if (!password.has_value()) {
throw std::runtime_error("This image contains an encrypted message - a password is required");
}
const Bytes plain = decryptBytes(body, *password);
return std::string(plain.begin(), plain.end()); // non-fatal decode, like TextDecoder()
}
} // namespace stego
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 →