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 →