Skip to content

Image Steganography — TypeScript 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 TypeScript 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. Pure logic - no React, no Canvas, no DOM. Works on
// any ImageData-shaped object ({ width, height, data }), so tests can build
// synthetic buffers and the browser can pass canvas ImageData 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 a non-extractable AES-256 key; GCM
// encrypts with a 12-byte random IV. Requires a secure context (https or
// localhost) because SubtleCrypto does.

const PBKDF2_ITERATIONS = 100_000;
const SALT_BYTES = 16;
const IV_BYTES = 12;
const GCM_TAG_BYTES = 16;
/** Salt + IV + GCM tag overhead added to the body when a password is used. */
export const 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. */
export const HEADER_BYTES = 4;

/** Minimal structural type satisfied by the browser's ImageData. */
export interface StegoImageData {
  readonly width: number;
  readonly height: number;
  readonly data: Uint8ClampedArray | Uint8Array;
}

/** Max payload bytes (header + body) an image of this size can carry. */
export function calculateCapacity(width: number, height: number): number {
  if (!Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0) {
    throw new Error('Width and height must be positive integers');
  }
  return Math.floor((width * height * 3) / 8);
}

/** PBKDF2-SHA256 (100k iterations) -> non-extractable AES-256-GCM key. */
async function deriveKey(password: string, salt: Uint8Array<ArrayBuffer>): Promise<CryptoKey> {
  const material = await crypto.subtle.importKey(
    'raw',
    new TextEncoder().encode(password),
    'PBKDF2',
    false,
    ['deriveKey'],
  );
  return crypto.subtle.deriveKey(
    { name: 'PBKDF2', salt, iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' },
    material,
    { name: 'AES-GCM', length: 256 },
    false,
    ['encrypt', 'decrypt'],
  );
}

/** AES-256-GCM encrypt bytes -> packed salt + IV + ciphertext (+ tag). */
async function encryptBytes(
  plain: Uint8Array<ArrayBuffer>,
  password: string,
): Promise<Uint8Array> {
  const salt = crypto.getRandomValues(new Uint8Array(SALT_BYTES));
  const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES));
  const key = await deriveKey(password, salt);
  const cipher = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, plain);
  const packed = new Uint8Array(SALT_BYTES + IV_BYTES + cipher.byteLength);
  packed.set(salt, 0);
  packed.set(iv, SALT_BYTES);
  packed.set(new Uint8Array(cipher), SALT_BYTES + IV_BYTES);
  return packed;
}

/** Unpack and AES-256-GCM decrypt a salt + IV + ciphertext payload. */
async function decryptBytes(packed: Uint8Array, password: string): Promise<Uint8Array> {
  const salt = packed.slice(0, SALT_BYTES);
  const iv = packed.slice(SALT_BYTES, SALT_BYTES + IV_BYTES);
  const data = packed.slice(SALT_BYTES + IV_BYTES);
  const key = await deriveKey(password, salt);
  try {
    const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, data);
    return new Uint8Array(plain);
  } catch {
    throw new Error('Decryption failed - wrong password or corrupted data');
  }
}

/** Write `payload` into the LSBs of the R/G/B channels; returns copied pixels. */
function embedBits(data: Uint8ClampedArray | Uint8Array, payload: Uint8Array): Uint8ClampedArray {
  const out = new Uint8ClampedArray(data); // copy - the input is never mutated
  const totalBits = payload.length * 8;
  for (let i = 0; i < totalBits; i++) {
    const byte = payload[i >> 3]!;
    const bit = (byte >> (7 - (i & 7))) & 1;
    const px = Math.floor(i / 3);
    const channel = i % 3;
    const idx = px * 4 + channel;
    out[idx] = (out[idx]! & 0xfe) | bit;
  }
  return out;
}

/** Read `count` payload bytes back out of the R/G/B LSBs. */
function extractBits(
  data: Uint8ClampedArray | Uint8Array,
  offsetBytes: number,
  count: number,
): Uint8Array {
  const out = new Uint8Array(count);
  const startBit = offsetBytes * 8;
  for (let i = 0; i < count * 8; i++) {
    const bitIndex = startBit + i;
    const px = Math.floor(bitIndex / 3);
    const channel = bitIndex % 3;
    const bit = data[px * 4 + channel]! & 1;
    out[i >> 3] = out[i >> 3]! | (bit << (7 - (i & 7)));
  }
  return out;
}

/**
 * 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.
 */
export async function hideMessage(
  imageData: StegoImageData,
  message: string,
  password?: string,
): Promise<StegoImageData> {
  if (password !== undefined && password === '') {
    throw new Error('Password must not be empty');
  }
  const capacity = calculateCapacity(imageData.width, imageData.height);
  const plain = new TextEncoder().encode(message);
  const body = password ? await encryptBytes(plain, password) : plain;
  const payload = new Uint8Array(HEADER_BYTES + body.length);
  const header = body.length | (password ? 0x8000_0000 : 0);
  new DataView(payload.buffer).setUint32(0, header >>> 0); // big-endian
  payload.set(body, HEADER_BYTES);
  if (payload.length > capacity) {
    const maxBody = capacity - HEADER_BYTES;
    throw new Error(
      `Message too long: ${body.length} bytes with overhead, but this image can hold at most ${maxBody} bytes of message`,
    );
  }
  return {
    width: imageData.width,
    height: imageData.height,
    data: embedBits(imageData.data, payload),
  };
}

/**
 * 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).
 */
export async function extractMessage(
  imageData: StegoImageData,
  password?: string,
): Promise<string> {
  if (password !== undefined && password === '') {
    throw new Error('Password must not be empty');
  }
  const capacity = calculateCapacity(imageData.width, imageData.height);
  const header = new DataView(extractBits(imageData.data, 0, HEADER_BYTES).buffer).getUint32(0);
  const encrypted = (header & 0x8000_0000) !== 0;
  const length = header & 0x7fff_ffff;
  if (length === 0 && !encrypted) return '';
  if (HEADER_BYTES + length > capacity || length < (encrypted ? SALT_BYTES + IV_BYTES + GCM_TAG_BYTES : 1)) {
    throw new Error('No hidden message found in this image');
  }
  const body = extractBits(imageData.data, HEADER_BYTES, length);
  if (!encrypted) {
    if (password) {
      throw new Error('This message is not password-protected - extract without a password');
    }
    try {
      return new TextDecoder('utf-8', { fatal: true }).decode(body);
    } catch {
      throw new Error('No hidden message found in this image');
    }
  }
  if (!password) {
    throw new Error('This image contains an encrypted message - a password is required');
  }
  const plain = await decryptBytes(body, password);
  return new TextDecoder().decode(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 →