Skip to content

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

// Steganography — least-significant-bit (LSB) message hiding on RGBA pixel
// data, with optional AES-256-GCM encryption.
//
// Language: Java (17+, standard library only)
// Ported from src/lib/steganography.ts
// display source — part of CosmoDev's polyglot tool pages.
//
// 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 the TS reference: PBKDF2-SHA256 (100k iterations,
// 16-byte random salt) derives an AES-256 key; GCM encrypts with a 12-byte
// random IV and a 128-bit (16-byte) tag appended to the ciphertext — the same
// layout WebCrypto produces, so payloads interoperate between the two.

import java.nio.charset.CharacterCodingException;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.Arrays;

import javax.crypto.Cipher;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.PBEKeySpec;
import javax.crypto.spec.SecretKeySpec;

public final class Steganography {

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

    /** Minimal structural type satisfied by an image's RGBA pixel buffer. */
    public record StegoImageData(int width, int height, byte[] data) {
    }

    private static final SecureRandom RANDOM = new SecureRandom();

    /** Max payload bytes (header + body) an image of this size can carry. */
    public static int calculateCapacity(int width, int height) {
        if (width <= 0 || height <= 0) {
            throw new IllegalArgumentException("Width and height must be positive integers");
        }
        return (width * height * 3) / 8;
    }

    /** PBKDF2-SHA256 (100k iterations) -> AES-256 key. */
    private static SecretKeySpec deriveKey(String password, byte[] salt) throws Exception {
        PBEKeySpec spec = new PBEKeySpec(password.toCharArray(), salt, PBKDF2_ITERATIONS, 256);
        byte[] keyBytes = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256")
            .generateSecret(spec).getEncoded();
        spec.clearPassword();
        return new SecretKeySpec(keyBytes, "AES");
    }

    /** AES-256-GCM encrypt bytes -> packed salt + IV + ciphertext (+ tag). */
    private static byte[] encryptBytes(byte[] plain, String password) throws Exception {
        byte[] salt = new byte[SALT_BYTES];
        byte[] iv = new byte[IV_BYTES];
        RANDOM.nextBytes(salt);
        RANDOM.nextBytes(iv);
        Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
        cipher.init(Cipher.ENCRYPT_MODE, deriveKey(password, salt),
            new GCMParameterSpec(GCM_TAG_BYTES * 8, iv));
        byte[] cipherText = cipher.doFinal(plain);
        byte[] packed = new byte[SALT_BYTES + IV_BYTES + cipherText.length];
        System.arraycopy(salt, 0, packed, 0, SALT_BYTES);
        System.arraycopy(iv, 0, packed, SALT_BYTES, IV_BYTES);
        System.arraycopy(cipherText, 0, packed, SALT_BYTES + IV_BYTES, cipherText.length);
        return packed;
    }

    /** Unpack and AES-256-GCM decrypt a salt + IV + ciphertext payload. */
    private static byte[] decryptBytes(byte[] packed, String password) throws Exception {
        byte[] salt = Arrays.copyOfRange(packed, 0, SALT_BYTES);
        byte[] iv = Arrays.copyOfRange(packed, SALT_BYTES, SALT_BYTES + IV_BYTES);
        byte[] data = Arrays.copyOfRange(packed, SALT_BYTES + IV_BYTES, packed.length);
        Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
        cipher.init(Cipher.DECRYPT_MODE, deriveKey(password, salt),
            new GCMParameterSpec(GCM_TAG_BYTES * 8, iv));
        try {
            return cipher.doFinal(data);
        } catch (Exception e) {
            throw new IllegalArgumentException("Decryption failed - wrong password or corrupted data");
        }
    }

    /** Write {@code payload} into the LSBs of the R/G/B channels; returns copied pixels. */
    private static byte[] embedBits(byte[] data, byte[] payload) {
        byte[] out = data.clone(); // copy - the input is never mutated
        int totalBits = payload.length * 8;
        for (int i = 0; i < totalBits; i++) {
            int b = payload[i >> 3] & 0xff;
            int bit = (b >> (7 - (i & 7))) & 1;
            int px = i / 3;
            int channel = i % 3;
            int idx = px * 4 + channel;
            out[idx] = (byte) ((out[idx] & 0xfe) | bit);
        }
        return out;
    }

    /** Read {@code count} payload bytes back out of the R/G/B LSBs. */
    private static byte[] extractBits(byte[] data, int offsetBytes, int count) {
        byte[] out = new byte[count];
        int startBit = offsetBytes * 8;
        for (int i = 0; i < count * 8; i++) {
            int bitIndex = startBit + i;
            int px = bitIndex / 3;
            int channel = bitIndex % 3;
            int bit = data[px * 4 + channel] & 1;
            out[i >> 3] = (byte) (out[i >> 3] | (bit << (7 - (i & 7))));
        }
        return out;
    }

    /** UTF-8 decode that fails loudly on invalid byte sequences (mirrors
     *  TextDecoder('utf-8', { fatal: true })). */
    private static String strictUtf8(byte[] bytes) throws CharacterCodingException {
        return StandardCharsets.UTF_8.newDecoder()
            .onMalformedInput(CodingErrorAction.REPORT)
            .onUnmappableCharacter(CodingErrorAction.REPORT)
            .decode(java.nio.ByteBuffer.wrap(bytes))
            .toString();
    }

    /**
     * Hide {@code message} inside a copy of {@code imageData}'s pixels (LSB of
     * R/G/B) and return the modified pixel data. With {@code 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.
     */
    public static StegoImageData hideMessage(StegoImageData imageData, String message, String password)
            throws Exception {
        if (password != null && password.isEmpty()) {
            throw new IllegalArgumentException("Password must not be empty");
        }
        int capacity = calculateCapacity(imageData.width(), imageData.height());
        byte[] plain = message.getBytes(StandardCharsets.UTF_8);
        byte[] body = password != null ? encryptBytes(plain, password) : plain;
        byte[] payload = new byte[HEADER_BYTES + body.length];
        long header = ((long) body.length) | (password != null ? 0x8000_0000L : 0L);
        payload[0] = (byte) (header >>> 24); // big-endian
        payload[1] = (byte) (header >>> 16);
        payload[2] = (byte) (header >>> 8);
        payload[3] = (byte) header;
        System.arraycopy(body, 0, payload, HEADER_BYTES, body.length);
        if (payload.length > capacity) {
            int maxBody = capacity - HEADER_BYTES;
            throw new IllegalArgumentException(
                "Message too long: " + body.length + " bytes with overhead, but this image can hold at most "
                    + maxBody + " bytes of message");
        }
        return new StegoImageData(imageData.width(), imageData.height(),
            embedBits(imageData.data(), payload));
    }

    /** Hide without a password. */
    public static StegoImageData hideMessage(StegoImageData imageData, String message) throws Exception {
        return hideMessage(imageData, message, null);
    }

    /**
     * Read the hidden message out of {@code 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).
     */
    public static String extractMessage(StegoImageData imageData, String password) throws Exception {
        if (password != null && password.isEmpty()) {
            throw new IllegalArgumentException("Password must not be empty");
        }
        int capacity = calculateCapacity(imageData.width(), imageData.height());
        byte[] headerBytes = extractBits(imageData.data(), 0, HEADER_BYTES);
        long header = ((headerBytes[0] & 0xffL) << 24) | ((headerBytes[1] & 0xffL) << 16)
            | ((headerBytes[2] & 0xffL) << 8) | (headerBytes[3] & 0xffL);
        boolean encrypted = (header & 0x8000_0000L) != 0;
        int length = (int) (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 IllegalArgumentException("No hidden message found in this image");
        }
        byte[] body = extractBits(imageData.data(), HEADER_BYTES, length);
        if (!encrypted) {
            if (password != null) {
                throw new IllegalArgumentException(
                    "This message is not password-protected - extract without a password");
            }
            try {
                return strictUtf8(body);
            } catch (CharacterCodingException e) {
                throw new IllegalArgumentException("No hidden message found in this image");
            }
        }
        if (password == null) {
            throw new IllegalArgumentException(
                "This image contains an encrypted message - a password is required");
        }
        byte[] plain = decryptBytes(body, password);
        return new String(plain, StandardCharsets.UTF_8);
    }

    /** Extract without a password. */
    public static String extractMessage(StegoImageData imageData) throws Exception {
        return extractMessage(imageData, null);
    }

    private Steganography() {
    }
}

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 →