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.

// 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 →