Skip to content

Image Steganography — Zig 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 Zig implementation — the same logic the interactive tool runs, in a shareable, citable form.

//! steganography — least-significant-bit (LSB) encoding on RGBA pixel data.
//!
//! Language: Zig 0.14 (standard library only)
//! Ported from: src/lib/steganography.ts (the canonical TypeScript implementation).
//! display source — part of CosmoDev's polyglot tool pages.
//!
//! Pure logic - no Canvas, no DOM. Per the polyglot task spec this port covers
//! the LSB encode/decode math: capacity, payload-header packing, and the
//! bit-level embed/extract over R/G/B channels. The optional AES-256-GCM
//! body encryption from the TS reference is included too (Zig's std.crypto
//! provides PBKDF2 + GCM natively); only the browser Canvas API parts are
//! out of scope.
//!
//! 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.

const std = @import("std");

const pbkdf2_iterations: u32 = 100_000;
const salt_bytes: usize = 16;
const iv_bytes: usize = 12;
const gcm_tag_bytes: usize = 16;

/// Salt + IV + GCM tag overhead added to the body when a password is used.
pub const encryption_overhead_bytes: usize = salt_bytes + iv_bytes + gcm_tag_bytes;
/// The 4-byte length header is also stored in the pixels, so it consumes capacity.
pub const header_bytes: usize = 4;

const Aes256Gcm = std.crypto.aead.aes_gcm.Aes256Gcm;
const HmacSha256 = std.crypto.auth.hmac.sha2.HmacSha256;

/// RGBA pixel buffer with its dimensions (what the browser's ImageData holds).
pub const StegoImageData = struct {
    width: usize,
    height: usize,
    /// RGBA, 4 bytes per pixel; exactly width * height * 4 bytes.
    data: []u8,
};

pub const Error = error{
    InvalidDimensions,
    PasswordEmpty,
    MessageTooLong,
    NoHiddenMessage,
    NotPasswordProtected,
    PasswordRequired,
    DecryptionFailed,
    OutOfMemory,
};

/// Max payload bytes (header + body) an image of this size can carry.
pub fn calculateCapacity(width: usize, height: usize) Error!usize {
    if (width == 0 or height == 0) return Error.InvalidDimensions;
    return (width * height * 3) / 8;
}

/// PBKDF2-SHA256 (100k iterations) -> AES-256-GCM key.
fn deriveKey(password: []const u8, salt: [salt_bytes]u8) [32]u8 {
    var key: [32]u8 = undefined;
    std.crypto.pwhash.pbkdf2(&key, password, &salt, pbkdf2_iterations, HmacSha256) catch
        unreachable; // fixed-size output can only fail on weak parameters
    return key;
}

/// AES-256-GCM encrypt bytes -> packed salt + IV + ciphertext (+ tag).
fn encryptBytes(allocator: std.mem.Allocator, plain: []const u8, password: []const u8) ![]u8 {
    var salt: [salt_bytes]u8 = undefined;
    var iv: [iv_bytes]u8 = undefined;
    std.crypto.random.bytes(&salt);
    std.crypto.random.bytes(&iv);
    const key = deriveKey(password, salt);

    const packed_len = salt_bytes + iv_bytes + plain.len + Aes256Gcm.tag_length;
    const packed = try allocator.alloc(u8, packed_len);
    errdefer allocator.free(packed);
    @memcpy(packed[0..salt_bytes], &salt);
    @memcpy(packed[salt_bytes .. salt_bytes + iv_bytes], &iv);

    var tag: [Aes256Gcm.tag_length]u8 = undefined;
    Aes256Gcm.encrypt(
        packed[salt_bytes + iv_bytes ..][0..plain.len],
        &tag,
        plain,
        "", // no associated data
        iv,
        key,
    ) catch return Error.DecryptionFailed;
    @memcpy(packed[packed_len - Aes256Gcm.tag_length ..], &tag);
    return packed;
}

/// Unpack and AES-256-GCM decrypt a salt + IV + ciphertext payload.
fn decryptBytes(allocator: std.mem.Allocator, packed: []const u8, password: []const u8) Error![]u8 {
    var salt: [salt_bytes]u8 = undefined;
    @memcpy(&salt, packed[0..salt_bytes]);
    var iv: [iv_bytes]u8 = undefined;
    @memcpy(&iv, packed[salt_bytes .. salt_bytes + iv_bytes]);

    const ct = packed[salt_bytes + iv_bytes .. packed.len - Aes256Gcm.tag_length];
    var tag: [Aes256Gcm.tag_length]u8 = undefined;
    @memcpy(&tag, packed[packed.len - Aes256Gcm.tag_length ..]);

    const key = deriveKey(password, salt);
    const plain = allocator.alloc(u8, ct.len) catch return Error.OutOfMemory;
    errdefer allocator.free(plain);
    Aes256Gcm.decrypt(plain, ct, tag, "", iv, key) catch return Error.DecryptionFailed;
    return plain;
}

/// Write `payload` into the LSBs of the R/G/B channels of a COPY of `data`
/// (the input is never mutated). Caller owns the returned buffer.
fn embedBits(allocator: std.mem.Allocator, data: []const u8, payload: []const u8) Error![]u8 {
    const out = allocator.dupe(u8, data) catch return Error.OutOfMemory;
    errdefer allocator.free(out);
    const total_bits = payload.len * 8;
    var i: usize = 0;
    while (i < total_bits) : (i += 1) {
        const byte = payload[i >> 3];
        const bit = (byte >> @intCast(7 - (i & 7))) & 1;
        const px = 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, starting at
/// `offset_bytes` into the logical payload stream. `out` must be `count` long.
fn extractBits(data: []const u8, out: []u8, offset_bytes: usize, count: usize) void {
    const start_bit = offset_bytes * 8;
    var i: usize = 0;
    while (i < count * 8) : (i += 1) {
        const bit_index = start_bit + i;
        const px = bit_index / 3;
        const channel = bit_index % 3;
        const bit = data[px * 4 + channel] & 1;
        out[i >> 3] |= bit << @intCast(7 - (i & 7));
    }
}

/// Hide `message` inside a copy of `image`'s pixels (LSB of R/G/B) and
/// return the modified pixel data. With `password`, the message body is
/// AES-256-GCM encrypted first. Fails if the message (including header and
/// encryption overhead) exceeds the image capacity, or on an empty password.
pub fn hideMessage(
    allocator: std.mem.Allocator,
    image: StegoImageData,
    message: []const u8,
    password: ?[]const u8,
) Error!StegoImageData {
    if (password) |p| {
        if (p.len == 0) return Error.PasswordEmpty;
    }
    const capacity = try calculateCapacity(image.width, image.height);

    const body = if (password) |p|
        try encryptBytes(allocator, message, p)
    else
        message;
    var body_owned = password != null;
    defer if (body_owned) allocator.free(@constCast(body));

    const payload_len = header_bytes + body.len;
    const payload = allocator.alloc(u8, payload_len) catch return Error.OutOfMemory;
    defer allocator.free(payload);

    // 4-byte big-endian header: top bit = encrypted flag, low 31 bits = length.
    const header: u32 = @as(u32, @intCast(body.len)) |
        (if (password != null) @as(u32, 0x8000_0000) else 0);
    std.mem.writeInt(u32, payload[0..4], header, .big);
    @memcpy(payload[header_bytes..], body);
    body_owned = false;

    if (payload.len > capacity) return Error.MessageTooLong;

    return .{
        .width = image.width,
        .height = image.height,
        .data = try embedBits(allocator, image.data, payload),
    };
}

/// Read the hidden message out of `image`'s pixels. Fails when the pixels
/// carry no valid payload, 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). Caller owns the result.
pub fn extractMessage(
    allocator: std.mem.Allocator,
    image: StegoImageData,
    password: ?[]const u8,
) Error![]u8 {
    if (password) |p| {
        if (p.len == 0) return Error.PasswordEmpty;
    }
    const capacity = try calculateCapacity(image.width, image.height);

    var header_buf: [header_bytes]u8 = undefined;
    extractBits(image.data, &header_buf, 0, header_bytes);
    const header = std.mem.readInt(u32, &header_buf, .big);
    const encrypted = (header & 0x8000_0000) != 0;
    const length: usize = header & 0x7fff_ffff;
    if (length == 0 and !encrypted) {
        return allocator.dupe(u8, "") catch Error.OutOfMemory;
    }
    if (header_bytes + length > capacity or
        length < (if (encrypted) salt_bytes + iv_bytes + gcm_tag_bytes else 1))
    {
        return Error.NoHiddenMessage;
    }

    const body = allocator.alloc(u8, length) catch return Error.OutOfMemory;
    defer allocator.free(body);
    extractBits(image.data, body, header_bytes, length);

    if (!encrypted) {
        if (password != null) return Error.NotPasswordProtected;
        // Validate UTF-8 the way the TS TextDecoder(fatal: true) does.
        if (!std.unicode.utf8ValidateSlice(body)) return Error.NoHiddenMessage;
        return allocator.dupe(u8, body) catch Error.OutOfMemory;
    }
    const pass = password orelse return Error.PasswordRequired;
    const plain = try decryptBytes(allocator, body, pass);
    if (!std.unicode.utf8ValidateSlice(plain)) return Error.NoHiddenMessage;
    return 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 →