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 →