fix(ext): bound covert payload processing

Harden LSB extraction against untrusted lengths, insufficient carrier
capacity, truncated payloads, and unbounded stream consumption.

Validate JPEG/EXIF covert framing, configured slot capacity, duplicate
slots, Pack7 lengths, payload completeness, and the one MiB payload
limit.

Add focused boundary and malformed-input tests and document the
incubating JPEG parser trust boundary and accepted residual risk.
This commit is contained in:
2026-07-31 00:09:33 +02:00
parent 54c6c3dd47
commit 369c626ed8
4 changed files with 838 additions and 80 deletions

View File

@@ -33,6 +33,8 @@
******************************************************************************/ ******************************************************************************/
package zeroecho.ext.integrations.covert.jpeg; package zeroecho.ext.integrations.covert.jpeg;
import java.io.ByteArrayInputStream;
import java.io.EOFException;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.io.OutputStream; import java.io.OutputStream;
@@ -40,10 +42,9 @@ import java.nio.ByteOrder;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays;
import java.util.Base64; import java.util.Base64;
import java.util.List; import java.util.List;
import java.util.logging.Level;
import java.util.logging.Logger;
import org.apache.commons.imaging.Imaging; import org.apache.commons.imaging.Imaging;
import org.apache.commons.imaging.ImagingException; import org.apache.commons.imaging.ImagingException;
@@ -57,7 +58,6 @@ import org.apache.commons.imaging.formats.tiff.write.TiffOutputField;
import org.apache.commons.imaging.formats.tiff.write.TiffOutputSet; import org.apache.commons.imaging.formats.tiff.write.TiffOutputSet;
import zeroecho.core.io.Util; import zeroecho.core.io.Util;
import zeroecho.sdk.util.Pack7LStreamWriter;
/** /**
* Utility class for embedding and extracting binary payloads across multiple * Utility class for embedding and extracting binary payloads across multiple
@@ -72,19 +72,43 @@ import zeroecho.sdk.util.Pack7LStreamWriter;
* The original JPEG EXIF metadata is preserved, except for any overwritten * The original JPEG EXIF metadata is preserved, except for any overwritten
* fields defined via slot configuration. * fields defined via slot configuration.
* </p> * </p>
*
* <p>
* This is an incubating integration, not a general-purpose parser for hostile
* JPEG input. Structural JPEG and TIFF/EXIF parsing is delegated to Apache
* Commons Imaging. This class independently validates its covert Pack7 framing,
* configured slot order and capacity, duplicate slots, payload completeness, and
* the one-MiB covert-payload limit. Successful parsing by the underlying library
* does not guarantee rejection of every malformed JPEG marker or APP1 boundary.
* Callers accepting fully untrusted images should perform separate structural
* validation or process them in an appropriate sandbox.
* </p>
*
* <p>
* No partial covert payload is written when the declared ZeroEcho payload is
* incomplete. Covert-payload memory is bounded by configured slot capacity and
* one MiB, subject to the independent resource behavior of Apache Commons
* Imaging while parsing the carrier. The existing EXIF-slot and Pack7 on-wire
* representation is unchanged.
* </p>
*/ */
public final class JpegExifEmbedder { public final class JpegExifEmbedder {
private static final Logger LOG = Logger.getLogger(JpegExifEmbedder.class.getName()); /* package */ static final int MAXIMUM_PAYLOAD_BYTES = 1024 * 1024;
private static final int MAXIMUM_PACKED_PREFIX_BYTES = 10;
private final List<Slot> slots = new ArrayList<>(); private final List<Slot> slots = new ArrayList<>();
/** /**
* Configures the list of EXIF slots to be used for payload embedding and * Configures the list of EXIF slots to be used for payload embedding and
* extraction. The order determines the splitting/joining order of the payload. * extraction. The order determines the splitting/joining order of the payload.
* Slots must be unique and have positive capacities.
* *
* @param slots List of slot configurations * @param slots nonempty list of positive-capacity slot configurations
* @throws IllegalArgumentException if the list is empty or contains a
* {@code null} or nonpositive-capacity slot
*/ */
public void setSlots(List<Slot> slots) { public void setSlots(List<Slot> slots) {
requireValidSlots(slots);
this.slots.clear(); this.slots.clear();
this.slots.addAll(slots); this.slots.addAll(slots);
} }
@@ -100,6 +124,12 @@ public final class JpegExifEmbedder {
* stream using a lossless EXIF update. * stream using a lossless EXIF update.
* </p> * </p>
* *
* <p>
* Apache Commons Imaging owns structural JPEG and TIFF/EXIF parsing. This
* method limits the covert payload to one MiB and configured slot capacity,
* but it is not an independent validator for arbitrary outer JPEG corruption.
* </p>
*
* @param jpegPath the path to the input JPEG file; must not be {@code null} * @param jpegPath the path to the input JPEG file; must not be {@code null}
* @param payloadInput the input stream containing the binary payload to embed; * @param payloadInput the input stream containing the binary payload to embed;
* it must be prefixed using 7-bit length encoding (e.g. * it must be prefixed using 7-bit length encoding (e.g.
@@ -107,28 +137,38 @@ public final class JpegExifEmbedder {
* @param jpegOutput the output stream where the modified JPEG will be written * @param jpegOutput the output stream where the modified JPEG will be written
* @return the total number of bytes read from {@code payloadInput}, including * @return the total number of bytes read from {@code payloadInput}, including
* the prefix * the prefix
* @throws IOException if an I/O error occurs while reading the payload, * @throws IOException if an I/O error occurs, the payload exceeds
* processing EXIF metadata, or writing the output JPEG * one MiB or configured slot capacity, or EXIF
* processing fails
* @throws IllegalStateException if no slots have been configured
*/ */
public int embed(Path jpegPath, InputStream payloadInput, OutputStream jpegOutput) throws IOException { public int embed(Path jpegPath, InputStream payloadInput, OutputStream jpegOutput) throws IOException {
requireConfiguredSlots();
int maximumPayloadBytes = maximumPayloadBytesForSlots();
byte[] allBytes = Util.readWithPackedLengthPrefix(payloadInput, maximumPayloadBytes);
long totalCapacity = totalRawCapacity();
if (allBytes.length > totalCapacity) {
throw new IOException("JPEG EXIF payload exceeds configured slot capacity.");
}
try { try {
ImageMetadata metadata = Imaging.getMetadata(jpegPath.toFile()); ImageMetadata metadata = Imaging.getMetadata(jpegPath.toFile());
TiffImageMetadata exif = (metadata instanceof JpegImageMetadata jmeta) ? jmeta.getExif() : null; TiffImageMetadata exif = (metadata instanceof JpegImageMetadata jmeta) ? jmeta.getExif() : null;
TiffOutputSet outputSet = (exif != null) ? exif.getOutputSet() : new TiffOutputSet(ByteOrder.BIG_ENDIAN); TiffOutputSet outputSet = (exif != null) ? exif.getOutputSet() : new TiffOutputSet(ByteOrder.BIG_ENDIAN);
byte[] allBytes = Util.readWithPackedLengthPrefix(payloadInput, 1024 * 1024);
int offset = 0; int offset = 0;
for (Slot slot : slots) { for (Slot slot : slots) {
if (offset >= allBytes.length) {
break;
}
boolean useBase64 = slot.tagInfo.isText(); boolean useBase64 = slot.tagInfo.isText();
int chunkLimit = slot.defaultCapacity; int chunkLimit = rawCapacity(slot);
byte[] encoded; byte[] encoded;
if (useBase64) { if (useBase64) {
// base64 expands ~33%, so reduce raw chunk size int size = Math.min(chunkLimit, allBytes.length - offset);
int safeRawSize = chunkLimit * 3 / 4;
int size = Math.min(safeRawSize, allBytes.length - offset);
byte[] raw = new byte[size]; // NOPMD byte[] raw = new byte[size]; // NOPMD
System.arraycopy(allBytes, offset, raw, 0, size); System.arraycopy(allBytes, offset, raw, 0, size);
offset += size; offset += size;
@@ -160,11 +200,11 @@ public final class JpegExifEmbedder {
directory.removeField(slot.tagInfo); // Remove only if collides directory.removeField(slot.tagInfo); // Remove only if collides
directory.add(field); directory.add(field);
if (offset >= allBytes.length) {
break;
}
} }
if (offset != allBytes.length) {
throw new IOException("JPEG EXIF payload exceeds configured slot capacity.");
}
new ExifRewriter().updateExifMetadataLossless(jpegPath.toFile(), jpegOutput, outputSet); new ExifRewriter().updateExifMetadataLossless(jpegPath.toFile(), jpegOutput, outputSet);
return allBytes.length; return allBytes.length;
@@ -177,51 +217,218 @@ public final class JpegExifEmbedder {
* Extracts a binary payload that was previously embedded into the JPEG via EXIF * Extracts a binary payload that was previously embedded into the JPEG via EXIF
* slots. * slots.
* *
* <p>
* Apache Commons Imaging parses the carrier structure. ZeroEcho validates the
* selected EXIF fields, Pack7 length, configured capacity, and complete
* availability of the declared covert payload before writing caller output.
* Outer marker and APP1 corruption accepted by the underlying parser is outside
* this method's independent guarantees.
* </p>
*
* @param jpegPath input JPEG path * @param jpegPath input JPEG path
* @param payloadOutput stream to write the reconstructed binary payload * @param payloadOutput stream to write the reconstructed binary payload
* @throws IOException if the extraction fails * @throws EOFException if the packed length prefix or declared
* payload is incomplete
* @throws IOException if supported carrier parsing fails or the
* payload length or configured slot data is
* malformed or exceeds one MiB
* @throws IllegalStateException if no slots have been configured
*/ */
public void extract(Path jpegPath, OutputStream payloadOutput) throws IOException { public void extract(Path jpegPath, OutputStream payloadOutput) throws IOException {
requireConfiguredSlots();
try { try {
ImageMetadata metadata = Imaging.getMetadata(jpegPath.toFile()); ImageMetadata metadata = Imaging.getMetadata(jpegPath.toFile());
TiffImageMetadata exif = (metadata instanceof JpegImageMetadata jmeta) ? jmeta.getExif() : null; TiffImageMetadata exif = (metadata instanceof JpegImageMetadata jmeta) ? jmeta.getExif() : null;
if (exif == null) { if (exif == null) {
LOG.warning("EXIF metadata not found in image."); throw new EOFException("JPEG EXIF covert payload is missing.");
return;
} }
Pack7LStreamWriter output = new Pack7LStreamWriter(payloadOutput); PackedPayloadCollector collector = new PackedPayloadCollector();
for (Slot slot : slots) { for (Slot slot : slots) {
TiffField field = exif.findField(slot.tagInfo); TiffField field = exif.findField(slot.tagInfo);
if (field == null) { if (field == null) {
continue; continue;
} }
requireBoundedField(field, slot);
Object value = slot.tagInfo.getValue(field); Object value = slot.tagInfo.getValue(field);
byte[] chunk; byte[] chunk = decodeChunk(value, slot);
if (chunk.length > rawCapacity(slot)) {
if (value instanceof byte[] binary) { throw new IOException("JPEG EXIF covert chunk exceeds slot capacity.");
chunk = slot.tagInfo.isText() ? Base64.getDecoder().decode(binary) : binary;
} else if (value instanceof String str) {
chunk = slot.tagInfo.isText() ? Base64.getDecoder().decode(str.getBytes(StandardCharsets.US_ASCII))
: str.getBytes(StandardCharsets.UTF_8);
} else {
LOG.log(Level.WARNING, "Unsupported EXIF field value type for tag: {0}", slot.tagInfo.name);
continue;
} }
collector.accept(chunk);
if (chunk.length > output.write(chunk)) { if (collector.isComplete()) {
// all data has been read
break; break;
} }
} }
byte[] payload = collector.finish();
payloadOutput.write(payload);
} catch (ImagingException e) { } catch (ImagingException e) {
throw new IOException("Failed to read EXIF from JPEG", e); throw new IOException("Failed to read EXIF from JPEG", e);
} }
} }
private void requireConfiguredSlots() {
if (slots.isEmpty()) {
throw new IllegalStateException("JPEG EXIF slots are not configured.");
}
}
private static void requireValidSlots(List<Slot> configuredSlots) {
if (configuredSlots == null || configuredSlots.isEmpty()) {
throw new IllegalArgumentException("JPEG EXIF slots must not be empty.");
}
for (int index = 0; index < configuredSlots.size(); index++) {
Slot slot = configuredSlots.get(index);
if (slot == null || slot.defaultCapacity <= 0) {
throw new IllegalArgumentException("JPEG EXIF slot capacity must be positive.");
}
if (configuredSlots.subList(0, index).contains(slot)) {
throw new IllegalArgumentException("JPEG EXIF slots must be unique.");
}
}
}
private int maximumPayloadBytesForSlots() throws IOException {
long capacity = totalRawCapacity();
int candidate = (int) Math.min(MAXIMUM_PAYLOAD_BYTES, capacity);
while (candidate > 0 && candidate + packedLengthBytes(candidate) > capacity) {
candidate--;
}
if (packedLengthBytes(candidate) > capacity) {
throw new IOException("JPEG EXIF slot capacity cannot contain a payload prefix.");
}
return candidate;
}
private long totalRawCapacity() throws IOException {
long capacity = 0;
try {
for (Slot slot : slots) {
capacity = Math.addExact(capacity, rawCapacity(slot));
}
return capacity;
} catch (ArithmeticException exception) {
throw new IOException("JPEG EXIF slot capacity is invalid.", exception);
}
}
private static int rawCapacity(Slot slot) {
return slot.tagInfo.isText() ? slot.defaultCapacity / 4 * 3 : slot.defaultCapacity;
}
private static int packedLengthBytes(long value) {
int bytes = 1;
long remaining = value;
while ((remaining & ~0x7fL) != 0) {
remaining >>>= 7;
bytes++;
}
return bytes;
}
private static void requireBoundedField(TiffField field, Slot slot) throws IOException {
final long encodedLength;
try {
encodedLength = Math.multiplyExact(field.getCount(), field.getFieldType().getSize());
} catch (ArithmeticException exception) {
throw new IOException("JPEG EXIF covert field length is invalid.", exception);
}
long maximumStoredBytes = slot.tagInfo.isText()
? Math.min((long) slot.defaultCapacity + 1, encodedLimitForPayload())
: Math.min((long) slot.defaultCapacity, MAXIMUM_PAYLOAD_BYTES + MAXIMUM_PACKED_PREFIX_BYTES);
if (encodedLength < 0 || encodedLength > maximumStoredBytes) {
throw new IOException("JPEG EXIF covert field exceeds slot capacity.");
}
}
private static long encodedLimitForPayload() {
long rawBytes = MAXIMUM_PAYLOAD_BYTES + MAXIMUM_PACKED_PREFIX_BYTES;
return (rawBytes + 2) / 3 * 4 + 1;
}
private static byte[] decodeChunk(Object value, Slot slot) throws IOException {
byte[] chunk = null;
try {
if (value instanceof byte[] binary) {
chunk = slot.tagInfo.isText() ? Base64.getDecoder().decode(binary) : binary;
} else if (value instanceof String string) {
chunk = slot.tagInfo.isText()
? Base64.getDecoder().decode(string.getBytes(StandardCharsets.US_ASCII))
: string.getBytes(StandardCharsets.UTF_8);
}
} catch (IllegalArgumentException exception) {
chunk = null;
}
if (chunk == null) {
throw new IOException("JPEG EXIF covert chunk is malformed.");
}
return chunk;
}
/* package */ static byte[] decodePayloadChunks(List<byte[]> chunks) throws IOException {
PackedPayloadCollector collector = new PackedPayloadCollector();
for (byte[] chunk : chunks) {
collector.accept(chunk);
if (collector.isComplete()) {
break;
}
}
return collector.finish();
}
/**
* Accumulates one bounded Pack7-framed payload across configured EXIF slots.
*/
private static final class PackedPayloadCollector {
private final byte[] prefix = new byte[MAXIMUM_PACKED_PREFIX_BYTES];
private int prefixLength;
private byte[] payload;
private int payloadLength;
/* package */ void accept(byte[] chunk) throws IOException {
int offset = 0;
boolean prefixComplete = false;
while (payload == null && offset < chunk.length) {
if (prefixLength == prefix.length) {
throw new IOException("JPEG EXIF packed payload length is malformed.");
}
byte current = chunk[offset++];
prefix[prefixLength++] = current;
if ((current & 0x80) != 0) {
prefixComplete = true;
break;
}
}
if (prefixComplete) {
long declaredLength = Util.readPack7L(new ByteArrayInputStream(prefix, 0, prefixLength));
if (declaredLength < 0 || declaredLength > MAXIMUM_PAYLOAD_BYTES) {
throw new IOException("JPEG EXIF payload length exceeds maximum.");
}
payload = new byte[(int) declaredLength];
}
if (payload != null && offset < chunk.length && payloadLength < payload.length) {
int remaining = payload.length - payloadLength;
int copied = Math.min(remaining, chunk.length - offset);
System.arraycopy(chunk, offset, payload, payloadLength, copied);
payloadLength += copied;
}
}
/* package */ boolean isComplete() {
return payload != null && payloadLength == payload.length;
}
/* package */ byte[] finish() throws EOFException {
if (payload == null) {
throw new EOFException("JPEG EXIF payload length prefix is truncated.");
}
if (payloadLength != payload.length) {
throw new EOFException("JPEG EXIF payload is truncated.");
}
return Arrays.copyOf(payload, payload.length);
}
}
} }

View File

@@ -36,10 +36,9 @@ package zeroecho.ext.integrations.stegano;
import java.awt.image.BufferedImage; import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream; import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.DataOutputStream; import java.io.EOFException;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.nio.ByteBuffer;
import java.util.Locale; import java.util.Locale;
import java.util.logging.Level; import java.util.logging.Level;
import java.util.logging.Logger; import java.util.logging.Logger;
@@ -99,6 +98,9 @@ import javax.imageio.ImageIO;
* }</pre> * }</pre>
*/ */
public class LSBSteganographyMethod implements SteganographyMethod { public class LSBSteganographyMethod implements SteganographyMethod {
private static final int LENGTH_PREFIX_BYTES = Integer.BYTES;
private static final int LENGTH_PREFIX_BITS = LENGTH_PREFIX_BYTES * Byte.SIZE;
private static final int CARRIER_BITS_PER_PAYLOAD_BYTE = Byte.SIZE;
private static final Logger LOG = Logger.getLogger(LSBSteganographyMethod.class.getName()); private static final Logger LOG = Logger.getLogger(LSBSteganographyMethod.class.getName());
/** /**
@@ -118,9 +120,9 @@ public class LSBSteganographyMethod implements SteganographyMethod {
* @param message the message stream to embed * @param message the message stream to embed
* @return an {@code InputStream} containing the stego image encoded in * @return an {@code InputStream} containing the stego image encoded in
* {@code outputFormat} * {@code outputFormat}
* @throws IOException if an I/O error occurs while reading the * @throws IOException if an I/O error occurs, the carrier is too
* image or message, or while writing the * small, or the message exceeds the carrier's
* output image * bounded capacity
* @throws IllegalArgumentException if {@code outputFormat} is unsupported for * @throws IllegalArgumentException if {@code outputFormat} is unsupported for
* this method (for example, {@code JPEG}) * this method (for example, {@code JPEG})
*/ */
@@ -135,15 +137,14 @@ public class LSBSteganographyMethod implements SteganographyMethod {
throw new IOException("Failed to read input image."); throw new IOException("Failed to read input image.");
} }
byte[] messageBytes = message.readAllBytes();
ByteArrayOutputStream fullData = new ByteArrayOutputStream();
DataOutputStream dos = new DataOutputStream(fullData);
dos.writeInt(messageBytes.length);
dos.write(messageBytes);
byte[] fullMessage = fullData.toByteArray();
final int width = img.getWidth(); final int width = img.getWidth();
final int height = img.getHeight(); final int height = img.getHeight();
final int maximumPayloadBytes = maximumPayloadBytes(width, height);
byte[] messageBytes = message.readNBytes(maximumPayloadBytes + 1);
if (messageBytes.length > maximumPayloadBytes) {
throw new IOException("LSB payload exceeds carrier capacity.");
}
final long requiredBits = requiredCarrierBits(messageBytes.length);
int msgBitIndex = 0; int msgBitIndex = 0;
if (LOG.isLoggable(Level.INFO)) { if (LOG.isLoggable(Level.INFO)) {
@@ -152,13 +153,13 @@ public class LSBSteganographyMethod implements SteganographyMethod {
outer: for (int y = 0; y < height; y++) { outer: for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) { for (int x = 0; x < width; x++) {
if (msgBitIndex >= fullMessage.length * 8) { if (msgBitIndex >= requiredBits) {
break outer; break outer;
} }
int rgb = img.getRGB(x, y); int rgb = img.getRGB(x, y);
int gray = rgb & 0xFF; int gray = rgb & 0xFF;
int bit = (fullMessage[msgBitIndex / 8] >> (7 - (msgBitIndex % 8))) & 1; int bit = embeddedBit(messageBytes, msgBitIndex);
gray = (gray & 0xFE) | bit; gray = (gray & 0xFE) | bit;
int newRgb = (gray << 16) | (gray << 8) | gray; int newRgb = (gray << 16) | (gray << 8) | gray;
img.setRGB(x, y, newRgb); img.setRGB(x, y, newRgb);
@@ -185,8 +186,10 @@ public class LSBSteganographyMethod implements SteganographyMethod {
* *
* @param inputImage the stego image stream that contains an embedded message * @param inputImage the stego image stream that contains an embedded message
* @return an {@code InputStream} of the extracted message bytes * @return an {@code InputStream} of the extracted message bytes
* @throws IOException if an I/O error occurs while reading the image or if the * @throws EOFException if the carrier cannot contain the complete prefix or
* image cannot be decoded * declared payload
* @throws IOException if the image cannot be decoded or the embedded length
* is negative or outside the carrier's bounded capacity
*/ */
@Override @Override
public InputStream extract(InputStream inputImage) throws IOException { public InputStream extract(InputStream inputImage) throws IOException {
@@ -197,42 +200,38 @@ public class LSBSteganographyMethod implements SteganographyMethod {
final int width = img.getWidth(); final int width = img.getWidth();
final int height = img.getHeight(); final int height = img.getHeight();
ByteArrayOutputStream messageBytes = new ByteArrayOutputStream(); final long carrierBits = carrierBits(width, height);
if (carrierBits < LENGTH_PREFIX_BITS) {
throw new EOFException("LSB carrier length prefix is truncated.");
}
if (LOG.isLoggable(Level.INFO)) { if (LOG.isLoggable(Level.INFO)) {
LOG.log(Level.INFO, "extract from picture w={0} x h={1}", new Object[] { width, height }); LOG.log(Level.INFO, "extract from picture w={0} x h={1}", new Object[] { width, height });
} }
int byteVal = 0; int messageLength = 0;
int messageLength = -1; for (int bitIndex = 0; bitIndex < LENGTH_PREFIX_BITS; bitIndex++) {
int bitsCollected = 0; messageLength = (messageLength << 1) | carrierBit(img, width, bitIndex);
}
outer: for (int y = 0; y < height; y++) { if (messageLength < 0) {
for (int x = 0; x < width; x++) { throw new IOException("LSB payload length is invalid.");
int gray = img.getRGB(x, y) & 0xFF; }
int bit = gray & 1; long requiredBits = requiredCarrierBits(messageLength);
byteVal = (byteVal << 1) | bit; if (requiredBits > carrierBits) {
bitsCollected++; throw new EOFException("LSB carrier payload is truncated.");
if (bitsCollected == 8) { // NOPMD
messageBytes.write(byteVal);
byteVal = 0;
bitsCollected = 0;
if (messageLength == -1 && messageBytes.size() == 4) {
byte[] lenBytes = messageBytes.toByteArray();
messageBytes.reset();
messageLength = ByteBuffer.wrap(lenBytes).getInt();
}
if (messageLength != -1 && messageBytes.size() == messageLength) {
break outer;
}
}
}
} }
return new ByteArrayInputStream(messageBytes.toByteArray()); byte[] messageBytes = new byte[messageLength];
long carrierBitIndex = LENGTH_PREFIX_BITS;
for (int byteIndex = 0; byteIndex < messageLength; byteIndex++) {
int value = 0;
for (int bitIndex = 0; bitIndex < Byte.SIZE; bitIndex++) {
value = (value << 1) | carrierBit(img, width, carrierBitIndex++);
}
messageBytes[byteIndex] = (byte) value;
}
return new ByteArrayInputStream(messageBytes);
} }
/** /**
@@ -250,4 +249,47 @@ public class LSBSteganographyMethod implements SteganographyMethod {
return new StegoMetadata("LSB", "Least Significant Bit Spatial Domain Image Steganography", return new StegoMetadata("LSB", "Least Significant Bit Spatial Domain Image Steganography",
"Embeds message bits into the least significant bits of grayscale pixel values."); "Embeds message bits into the least significant bits of grayscale pixel values.");
} }
private static int maximumPayloadBytes(int width, int height) throws IOException {
long availableBits = carrierBits(width, height) - LENGTH_PREFIX_BITS;
if (availableBits < 0) {
throw new IOException("LSB carrier is too small.");
}
long maximumPayloadBytes = availableBits / CARRIER_BITS_PER_PAYLOAD_BYTE;
if (maximumPayloadBytes >= Integer.MAX_VALUE) {
throw new IOException("LSB carrier capacity exceeds supported range.");
}
return (int) maximumPayloadBytes;
}
private static long carrierBits(int width, int height) throws IOException {
try {
return Math.multiplyExact((long) width, (long) height);
} catch (ArithmeticException exception) {
throw new IOException("LSB carrier capacity is invalid.", exception);
}
}
private static long requiredCarrierBits(int payloadBytes) throws IOException {
try {
return Math.addExact(LENGTH_PREFIX_BITS,
Math.multiplyExact((long) payloadBytes, CARRIER_BITS_PER_PAYLOAD_BYTE));
} catch (ArithmeticException exception) {
throw new IOException("LSB payload capacity is invalid.", exception);
}
}
private static int embeddedBit(byte[] message, int bitIndex) {
if (bitIndex < LENGTH_PREFIX_BITS) {
return (message.length >>> (LENGTH_PREFIX_BITS - 1 - bitIndex)) & 1;
}
int payloadBit = bitIndex - LENGTH_PREFIX_BITS;
return (message[payloadBit / Byte.SIZE] >>> (Byte.SIZE - 1 - payloadBit % Byte.SIZE)) & 1;
}
private static int carrierBit(BufferedImage image, int width, long bitIndex) {
int x = (int) (bitIndex % width);
int y = (int) (bitIndex / width);
return image.getRGB(x, y) & 1;
}
} }

View File

@@ -0,0 +1,312 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.ext.integrations.covert.jpeg;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.EOFException;
import java.io.IOException;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import zeroecho.core.io.Util;
class JpegExifEmbedderLengthTest {
@TempDir
Path temporaryDirectory;
@Test
void validPackedPayloadBoundariesDecodeExactly() throws Exception {
System.out.println("validPackedPayloadBoundariesDecodeExactly");
int[] lengths = { 0, 1, 127, 128, 16_384, JpegExifEmbedder.MAXIMUM_PAYLOAD_BYTES };
for (int length : lengths) {
byte[] payload = payload(length);
byte[] framed = frame(payload);
List<byte[]> chunks = split(framed, 1, Math.min(97, framed.length));
assertArrayEquals(payload, JpegExifEmbedder.decodePayloadChunks(chunks));
}
System.out.println("...packed boundary lengths: " + lengths.length);
System.out.println("...ok");
}
@Test
void malformedPackedLengthsAndTruncationFailClosed() throws Exception {
System.out.println("malformedPackedLengthsAndTruncationFailClosed");
for (int prefixBytes = 0; prefixBytes < 10; prefixBytes++) {
byte[] incompletePrefix = new byte[prefixBytes];
EOFException incomplete = assertThrows(EOFException.class,
() -> JpegExifEmbedder.decodePayloadChunks(List.of(incompletePrefix)));
assertFalse(incomplete.getMessage().contains("PAYLOAD_SENTINEL"));
}
assertThrows(IOException.class, () -> JpegExifEmbedder.decodePayloadChunks(List.of(new byte[10])));
byte[] excessive = packedLength(JpegExifEmbedder.MAXIMUM_PAYLOAD_BYTES + 1L);
IOException overLimit = assertThrows(IOException.class,
() -> JpegExifEmbedder.decodePayloadChunks(List.of(excessive)));
assertEquals("JPEG EXIF payload length exceeds maximum.", overLimit.getMessage());
byte[] negative = packedLength(-1L);
assertThrows(IOException.class, () -> JpegExifEmbedder.decodePayloadChunks(List.of(negative)));
byte[] illegalHighTerminal = new byte[10];
illegalHighTerminal[0] = 0x02;
illegalHighTerminal[9] = (byte) 0x80;
assertThrows(IOException.class,
() -> JpegExifEmbedder.decodePayloadChunks(List.of(illegalHighTerminal)));
byte[] framed = frame(payload(12));
int prefixLength = packedLength(12).length;
int[] retainedPayloadBytes = { 0, 6, 11 };
for (int retained : retainedPayloadBytes) {
byte[] truncated = Arrays.copyOf(framed, prefixLength + retained);
ByteArrayOutputStream output = new ByteArrayOutputStream();
EOFException incomplete = assertThrows(EOFException.class,
() -> output.write(JpegExifEmbedder.decodePayloadChunks(List.of(truncated))));
assertEquals(0, output.size());
assertEquals("JPEG EXIF payload is truncated.", incomplete.getMessage());
}
byte[] surplus = Arrays.copyOf(frame(payload(3)), frame(payload(3)).length + 4);
assertArrayEquals(payload(3), JpegExifEmbedder.decodePayloadChunks(List.of(surplus)));
byte[] firstResult = JpegExifEmbedder.decodePayloadChunks(List.of(frame(payload(3))));
firstResult[0] ^= 0x7f;
assertArrayEquals(payload(3), JpegExifEmbedder.decodePayloadChunks(List.of(frame(payload(3)))));
System.out.println("...hostile packed cases: 18");
System.out.println("...ok");
}
@Test
void realJpegRoundTripPreservesPayload() throws Exception {
System.out.println("realJpegRoundTripPreservesPayload");
URL carrierResource = Objects.requireNonNull(
getClass().getClassLoader().getResource("test.jpg"));
Path carrier = Path.of(carrierResource.toURI());
Path encoded = temporaryDirectory.resolve("encoded.jpg");
byte[] payload = payload(512);
JpegExifEmbedder embedder = new JpegExifEmbedder();
embedder.setSlots(Slot.defaults());
try (ByteArrayOutputStream jpegOutput = new ByteArrayOutputStream()) {
int framedBytes = embedder.embed(carrier, new ByteArrayInputStream(payload), jpegOutput);
Files.write(encoded, jpegOutput.toByteArray());
assertEquals(frame(payload).length, framedBytes);
}
ByteArrayOutputStream extracted = new ByteArrayOutputStream();
embedder.extract(encoded, extracted);
assertArrayEquals(payload, extracted.toByteArray());
System.out.println("...round-trip payload bytes: " + payload.length);
System.out.println("...ok");
}
@Test
void embeddingRejectsOverCapacityBeforeOutput() throws Exception {
System.out.println("embeddingRejectsOverCapacityBeforeOutput");
JpegExifEmbedder embedder = new JpegExifEmbedder();
embedder.setSlots(Slot.defaults());
byte[] payload = payload(20_000);
ByteArrayOutputStream output = new ByteArrayOutputStream();
IOException failure = assertThrows(IOException.class,
() -> embedder.embed(temporaryDirectory.resolve("unused.jpg"), new ByteArrayInputStream(payload),
output));
assertEquals(0, output.size());
assertEquals("Input exceeds maximum allowed size: 10238", failure.getMessage());
System.out.println("...rejected payload bytes: " + payload.length);
System.out.println("...ok");
}
@Test
void allConstructionPathsRequireValidSlots() {
System.out.println("allConstructionPathsRequireValidSlots");
JpegExifEmbedder embedder = new JpegExifEmbedder();
Slot duplicate = customRawSlot(50_100, 64);
assertThrows(IllegalArgumentException.class, () -> embedder.setSlots(List.of()));
assertThrows(IllegalArgumentException.class, () -> embedder.setSlots(List.of(duplicate, duplicate)));
assertThrows(IllegalStateException.class,
() -> embedder.extract(temporaryDirectory.resolve("unused.jpg"), new ByteArrayOutputStream()));
System.out.println("...slot validation paths: 3");
System.out.println("...ok");
}
@Test
void parsedExifPayloadBoundariesFailWithoutOutput() throws Exception {
System.out.println("parsedExifPayloadBoundariesFailWithoutOutput");
Slot first = customRawSlot(50_110, 8);
Slot second = customRawSlot(50_111, 64);
byte[] expected = payload(24);
byte[] jpeg = embeddedJpeg(expected, List.of(first, second));
assertExtractionFails(jpeg, List.of(first));
assertArrayEquals(expected, extractPayload(jpeg, List.of(first, second)));
System.out.println("...validated EXIF slots: 2");
System.out.println("...ok");
}
@Test
void hostileExifSlotsFailClosedWithoutPartialOutput() throws Exception {
System.out.println("hostileExifSlotsFailClosedWithoutPartialOutput");
Slot raw = customRawSlot(50_120, 64);
Slot anotherRaw = customRawSlot(50_121, 64);
byte[] validRaw = embeddedJpeg(payload(20), List.of(raw));
assertExtractionFails(validRaw, List.of(anotherRaw));
assertExtractionFails(validRaw, List.of(customRawSlot(50_120, 8)));
Slot first = customRawSlot(50_122, 8);
Slot second = customRawSlot(50_123, 64);
byte[] splitPayload = embeddedJpeg(payload(24), List.of(first, second));
assertExtractionFails(splitPayload, List.of(first));
assertExtractionFails(splitPayload, List.of(second, first));
System.out.println("...bounded EXIF slot cases: 4");
System.out.println("...ok");
}
@Test
void configuredCapacityAndMaximumAreEnforcedBeforeCallerOutput() throws Exception {
System.out.println("configuredCapacityAndMaximumAreEnforcedBeforeCallerOutput");
byte[] excessive = packedLength(JpegExifEmbedder.MAXIMUM_PAYLOAD_BYTES + 1L);
ByteArrayOutputStream output = new ByteArrayOutputStream();
assertThrows(IOException.class,
() -> output.write(JpegExifEmbedder.decodePayloadChunks(List.of(excessive))));
assertEquals(0, output.size());
Slot largeText = customTextSlot(50_130, Integer.MAX_VALUE);
JpegExifEmbedder embedder = new JpegExifEmbedder();
embedder.setSlots(List.of(largeText));
IOException missingCarrier = assertThrows(IOException.class,
() -> embedder.embed(temporaryDirectory.resolve("missing.jpg"),
new ByteArrayInputStream(payload(1)), new ByteArrayOutputStream()));
assertFalse(missingCarrier.getMessage().contains("PAYLOAD_SENTINEL"));
System.out.println("...maximum payload bytes: " + JpegExifEmbedder.MAXIMUM_PAYLOAD_BYTES);
System.out.println("...ok");
}
private static byte[] payload(int length) {
byte[] payload = new byte[length];
for (int index = 0; index < payload.length; index++) {
payload[index] = (byte) index;
}
return payload;
}
private static byte[] frame(byte[] payload) throws IOException {
ByteArrayOutputStream output = new ByteArrayOutputStream();
Util.writePack7L(output, payload.length);
output.write(payload);
return output.toByteArray();
}
private static byte[] packedLength(long length) throws IOException {
ByteArrayOutputStream output = new ByteArrayOutputStream();
Util.writePack7L(output, length);
return output.toByteArray();
}
private static List<byte[]> split(byte[] value, int firstLength, int nextLength) {
if (value.length <= firstLength) {
return List.of(value);
}
byte[] first = Arrays.copyOfRange(value, 0, firstLength);
byte[] remaining = Arrays.copyOfRange(value, firstLength, value.length);
if (remaining.length <= nextLength) {
return List.of(first, remaining);
}
byte[] second = Arrays.copyOfRange(remaining, 0, nextLength);
byte[] third = Arrays.copyOfRange(remaining, nextLength, remaining.length);
return List.of(first, second, third);
}
private Path carrierPath() throws Exception {
URL carrierResource = Objects.requireNonNull(getClass().getClassLoader().getResource("test.jpg"));
return Path.of(carrierResource.toURI());
}
private byte[] embeddedJpeg(byte[] payload, List<Slot> slots) throws Exception {
JpegExifEmbedder embedder = new JpegExifEmbedder();
embedder.setSlots(slots);
ByteArrayOutputStream output = new ByteArrayOutputStream();
embedder.embed(carrierPath(), new ByteArrayInputStream(payload), output);
return output.toByteArray();
}
private void assertExtractionFails(byte[] jpeg, List<Slot> slots) throws Exception {
Path path = temporaryDirectory.resolve("hostile-" + Math.abs(Arrays.hashCode(jpeg)) + ".jpg");
Files.write(path, jpeg);
ByteArrayOutputStream output = new ByteArrayOutputStream();
JpegExifEmbedder embedder = new JpegExifEmbedder();
embedder.setSlots(slots);
IOException failure = assertThrows(IOException.class, () -> embedder.extract(path, output));
assertEquals(0, output.size());
assertFalse(failure.getMessage().contains("PAYLOAD_SENTINEL"));
}
private byte[] extractPayload(byte[] jpeg, List<Slot> slots) throws Exception {
Path path = temporaryDirectory.resolve("valid-" + Math.abs(Arrays.hashCode(jpeg)) + ".jpg");
Files.write(path, jpeg);
ByteArrayOutputStream output = new ByteArrayOutputStream();
JpegExifEmbedder embedder = new JpegExifEmbedder();
embedder.setSlots(slots);
embedder.extract(path, output);
return output.toByteArray();
}
private static Slot customRawSlot(int tag, int capacity) {
return Slot.parse("EXIF.raw/tag=" + tag + ",undefined,1,exif:" + capacity);
}
private static Slot customTextSlot(int tag, int capacity) {
return Slot.parse("EXIF.text/tag=" + tag + ",ascii,1,exif:" + capacity);
}
}

View File

@@ -0,0 +1,197 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.ext.integrations.stegano;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.EOFException;
import java.io.IOException;
import java.io.InputStream;
import javax.imageio.ImageIO;
import org.junit.jupiter.api.Test;
class LSBSteganographyMethodLengthTest {
private static final int PREFIX_BITS = Integer.SIZE;
@Test
void validBoundaryPayloadsRoundTrip() throws Exception {
System.out.println("validBoundaryPayloadsRoundTrip");
int[] lengths = { 0, 1, 127, 128, 256 };
LSBSteganographyMethod method = new LSBSteganographyMethod();
for (int length : lengths) {
byte[] payload = payload(length);
int carrierBits = PREFIX_BITS + length * Byte.SIZE;
InputStream encoded = method.embed(image(carrierBits + Byte.SIZE), ImageFormat.PNG,
new ByteArrayInputStream(payload));
byte[] decoded = method.extract(encoded).readAllBytes();
assertArrayEquals(payload, decoded);
}
System.out.println("...boundary lengths: " + lengths.length);
System.out.println("...ok");
}
@Test
void exactCapacityAndInsufficientCapacityFailClosed() throws Exception {
System.out.println("exactCapacityAndInsufficientCapacityFailClosed");
LSBSteganographyMethod method = new LSBSteganographyMethod();
byte[] payload = payload(16);
int exactBits = PREFIX_BITS + payload.length * Byte.SIZE;
InputStream encoded = method.embed(image(exactBits), ImageFormat.PNG, new ByteArrayInputStream(payload));
assertArrayEquals(payload, method.extract(encoded).readAllBytes());
IOException failure = assertThrows(IOException.class,
() -> method.embed(image(exactBits - 1), ImageFormat.PNG, new ByteArrayInputStream(payload)));
assertEquals("LSB payload exceeds carrier capacity.", failure.getMessage());
System.out.println("...exact carrier bits: " + exactBits);
System.out.println("...ok");
}
@Test
void malformedPrefixesAndTruncatedPayloadsFailClosed() throws Exception {
System.out.println("malformedPrefixesAndTruncatedPayloadsFailClosed");
LSBSteganographyMethod method = new LSBSteganographyMethod();
assertThrows(IOException.class, () -> method.extract(new ByteArrayInputStream(new byte[0])));
assertThrows(EOFException.class, () -> method.extract(imageWithDeclaredLength(31, 0)));
IOException negative = assertThrows(IOException.class,
() -> method.extract(imageWithDeclaredLength(PREFIX_BITS, -1)));
assertEquals("LSB payload length is invalid.", negative.getMessage());
EOFException truncated = assertThrows(EOFException.class,
() -> method.extract(imageWithDeclaredLength(PREFIX_BITS + Byte.SIZE, 2)));
assertEquals("LSB carrier payload is truncated.", truncated.getMessage());
assertFalse(truncated.getMessage().contains("PAYLOAD_SENTINEL"));
System.out.println("...hostile prefix cases: 4");
System.out.println("...ok");
}
@Test
void everyIncompletePrefixAndRepresentativePayloadTruncationFailsClosed() throws Exception {
System.out.println("everyIncompletePrefixAndRepresentativePayloadTruncationFailsClosed");
LSBSteganographyMethod method = new LSBSteganographyMethod();
IOException empty = assertThrows(IOException.class,
() -> method.extract(new ByteArrayInputStream(new byte[0])));
assertFalse(empty.getMessage().contains("PAYLOAD_SENTINEL"));
for (int availablePrefixBits = 1; availablePrefixBits < PREFIX_BITS; availablePrefixBits++) {
int testedPrefixBits = availablePrefixBits;
EOFException failure = assertThrows(EOFException.class,
() -> method.extract(imageWithDeclaredLength(testedPrefixBits, 0)));
assertEquals("LSB carrier length prefix is truncated.", failure.getMessage());
assertFalse(failure.getMessage().contains("PAYLOAD_SENTINEL"));
}
assertArrayEquals(new byte[0],
method.extract(imageWithDeclaredLength(PREFIX_BITS, 0)).readAllBytes());
int declaredPayloadBytes = 4;
int[] availablePayloadBits = { 0, 16, 31 };
for (int payloadBits : availablePayloadBits) {
EOFException failure = assertThrows(EOFException.class,
() -> method.extract(
imageWithDeclaredLength(PREFIX_BITS + payloadBits, declaredPayloadBytes)));
assertEquals("LSB carrier payload is truncated.", failure.getMessage());
assertFalse(failure.getMessage().contains("PAYLOAD_SENTINEL"));
}
System.out.println("...incomplete prefix positions: 32");
System.out.println("...payload truncation positions: " + availablePayloadBits.length);
System.out.println("...ok");
}
@Test
void overCapacityMessageReadIsDeterministicallyBounded() throws Exception {
System.out.println("overCapacityMessageReadIsDeterministicallyBounded");
LSBSteganographyMethod method = new LSBSteganographyMethod();
CountingInfiniteInputStream message = new CountingInfiniteInputStream();
int payloadCapacity = 8;
IOException failure = assertThrows(IOException.class,
() -> method.embed(image(PREFIX_BITS + payloadCapacity * Byte.SIZE), ImageFormat.PNG, message));
assertEquals("LSB payload exceeds carrier capacity.", failure.getMessage());
assertEquals(payloadCapacity + 1, message.readCount);
System.out.println("...bounded message reads: " + message.readCount);
System.out.println("...ok");
}
private static byte[] payload(int length) {
byte[] payload = new byte[length];
for (int index = 0; index < payload.length; index++) {
payload[index] = (byte) index;
}
return payload;
}
private static InputStream image(int pixels) throws IOException {
BufferedImage image = new BufferedImage(pixels, 1, BufferedImage.TYPE_INT_RGB);
ByteArrayOutputStream output = new ByteArrayOutputStream();
ImageIO.write(image, "png", output);
return new ByteArrayInputStream(output.toByteArray());
}
private static InputStream imageWithDeclaredLength(int pixels, int declaredLength) throws IOException {
BufferedImage image = new BufferedImage(pixels, 1, BufferedImage.TYPE_INT_RGB);
int prefixBits = Math.min(pixels, PREFIX_BITS);
for (int bitIndex = 0; bitIndex < prefixBits; bitIndex++) {
int bit = (declaredLength >>> (PREFIX_BITS - 1 - bitIndex)) & 1;
image.setRGB(bitIndex, 0, bit);
}
ByteArrayOutputStream output = new ByteArrayOutputStream();
ImageIO.write(image, "png", output);
return new ByteArrayInputStream(output.toByteArray());
}
private static final class CountingInfiniteInputStream extends InputStream {
private int readCount;
@Override
public int read() {
readCount++;
return 0x5a;
}
}
}