diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/InvalidZipException.java b/sdk/src/main/java/io/opentdf/platform/sdk/InvalidZipException.java index 8bba58bd..82e5b2da 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/InvalidZipException.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/InvalidZipException.java @@ -9,4 +9,8 @@ public class InvalidZipException extends RuntimeException { public InvalidZipException(String message) { super(message); } + + public InvalidZipException(String message, Throwable cause) { + super(message, cause); + } } diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java b/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java index fddead94..2455fb5e 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java @@ -398,6 +398,17 @@ public void readPayload(OutputStream outputStream) throws SDK.TamperException, I validateSegmentSizes(); + try { + readSegments(outputStream, digest); + // the segment lengths are all the loop reads, so check a deflated payload ends there + tdfReader.finishPayload(); + } finally { + // releases a deflated payload's inflater however the read ended + tdfReader.closePayload(); + } + } + + private void readSegments(OutputStream outputStream, MessageDigest digest) throws IOException { for (Manifest.Segment segment : manifest.encryptionInformation.integrityInformation.segments) { byte[] readBuf = new byte[(int) segment.encryptedSegmentSize]; int bytesRead = tdfReader.readPayloadBytes(readBuf); diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/TDFReader.java b/sdk/src/main/java/io/opentdf/platform/sdk/TDFReader.java index 93548d67..b909c8af 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/TDFReader.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/TDFReader.java @@ -7,6 +7,7 @@ import java.nio.charset.StandardCharsets; import java.util.Map; import java.util.stream.Collectors; +import java.util.zip.ZipEntry; import static io.opentdf.platform.sdk.TDFWriter.TDF_MANIFEST_FILE_NAME; import static io.opentdf.platform.sdk.TDFWriter.TDF_MANIFEST_FILE_NAME_SPEC; @@ -20,7 +21,13 @@ public class TDFReader { private final ZipReader.Entry manifestEntry; - private final InputStream payload; + private final ZipReader.Entry payloadEntry; + /** + * Opened by the first payload read rather than here, so reading only the manifest never + * allocates an inflater for a deflated payload. Released by {@link #closePayload()}. + */ + private InputStream payload; + private boolean payloadReleased = false; public TDFReader(SeekableByteChannel tdf) throws SDKException, IOException { // A zip may legally list the same name twice, and readers disagree about which copy @@ -46,13 +53,18 @@ public TDFReader(SeekableByteChannel tdf) throws SDKException, IOException { } manifestEntry = manifest; - payload = entries.get(TDF_PAYLOAD_FILE_NAME).getData(); + payloadEntry = entries.get(TDF_PAYLOAD_FILE_NAME); + // check the payload now, so one that can't be read (an unsupported compression method, + // or a deflated size over its budget) is refused before any key is requested -- but + // without opening it, since a caller may only want the manifest + payloadEntry.checkReadable(); } String manifest() { var out = new ByteArrayOutputStream(); - try { - manifestEntry.getData().transferTo(out); + // closing releases the inflater when the manifest entry is deflated + try (var manifestData = manifestEntry.getData()) { + manifestData.transferTo(out); } catch (IOException e) { throw new SDKException("error retrieving manifest from zip file", e); } @@ -60,19 +72,79 @@ String manifest() { return out.toString(StandardCharsets.UTF_8); } + /** + * Reads the next payload bytes into {@code buf}, opening the payload on the first call. A + * failed read releases the payload; once it has been released this reads nothing. + */ int readPayloadBytes(byte[] buf) { + if (payloadReleased) { + return 0; + } int totalRead = 0; int nread; + boolean succeeded = false; try { + if (payload == null) { + payload = payloadEntry.getData(); + } while (totalRead < buf.length && (nread = payload.read(buf, totalRead, buf.length - totalRead)) >= 0) { totalRead += nread; } + succeeded = true; } catch (IOException e) { throw new SDKException("error reading from payload in TDF", e); + } finally { + if (!succeeded) { + closePayload(); + } } return totalRead; } + /** + * Called once every segment has been read. A deflated payload must end exactly there: a + * further read has to find the end of the entry, which for a deflated entry also confirms + * the deflate stream ends at its declared size. A stored payload is not checked, as before. + * Releases the payload either way. + * + * @throws InvalidZipException if a deflated payload holds data past its last segment + */ + void finishPayload() { + try { + if (payloadEntry.getCompressionMethod() == ZipEntry.DEFLATED && !payloadReleased) { + if (payload == null) { + payload = payloadEntry.getData(); + } + if (payload.read() != -1) { + throw new InvalidZipException("deflated entry [" + payloadEntry.getName() + + "] holds data past the end of the TDF's last segment"); + } + } + } catch (IOException e) { + throw new SDKException("error reading from payload in TDF", e); + } finally { + closePayload(); + } + } + + /** + * Releases the payload stream, and with it a deflated payload's inflater. Idempotent; after + * it, payload reads return nothing. + */ + void closePayload() { + payloadReleased = true; + if (payload == null) { + return; + } + try { + payload.close(); + } catch (IOException e) { + throw new SDKException("error closing payload in TDF", e); + } finally { + payload = null; + } + } + PolicyObject readPolicyObject() { String manifestJson = manifest(); Manifest manifest = Manifest.readManifest(manifestJson); diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/ZipReader.java b/sdk/src/main/java/io/opentdf/platform/sdk/ZipReader.java index d2fd6e91..506b0689 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/ZipReader.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/ZipReader.java @@ -11,12 +11,17 @@ import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.zip.DataFormatException; +import java.util.zip.Inflater; /** * The ZipReader class provides functionality to read basic ZIP file * structures, such as the End of Central Directory Record and the * Local File Header. This class supports standard ZIP archives as well - * as ZIP64 format. + * as ZIP64 format, with entries that are stored (method 0) or deflated + * (method 8). Entry sizes are taken from the central directory. */ public class ZipReader { @@ -138,6 +143,17 @@ public CentralDirectoryRecord(long numEntries, long offsetToStart) { /** The same sentinel for a 16-bit field, which is only two bytes wide. */ private static final int ZIP64_MAGIC_SHORT = 0xFFFF; private static final int ZIP64_EXTID= 0x0001; + /** Compression method 0: the entry's bytes are written as they are. All OpenTDF writers use it. */ + private static final int STORED = 0; + /** Compression method 8: raw DEFLATE (RFC 1951), which some other TDF writers use. */ + private static final int DEFLATED = 8; + /** + * The least uncompressed size a deflated entry may declare, however small the archive: 10 MiB. + * Past it, an entry may declare no more than the archive's length. A deflated TDF payload is + * ciphertext, which does not compress, so it never declares more than the archive holds; + * the floor leaves room for a manifest that compresses well. + */ + private static final long DEFLATED_SIZE_BUDGET_FLOOR = 10L * 1024 * 1024; /** * The longest comment the end of central directory record can carry, since its length lives @@ -361,19 +377,36 @@ private CentralDirectoryRecord extractZIP64CentralDirectoryInfo(long locatorStar public class Entry { private final long fileSize; + private final long compressedSize; + private final int compressionMethod; private final String fileName; final long offsetToLocalHeader; - private Entry(byte[] fileName, long offsetToLocalHeader, long fileSize) { + private Entry(byte[] fileName, long offsetToLocalHeader, int compressionMethod, long compressedSize, + long uncompressedSize) { this.fileName = new String(fileName, StandardCharsets.UTF_8); this.offsetToLocalHeader = offsetToLocalHeader; - this.fileSize = fileSize; + this.compressionMethod = compressionMethod; + this.compressedSize = compressedSize; + this.fileSize = uncompressedSize; } public String getName() { return fileName; } + int getCompressionMethod() { + return compressionMethod; + } + + long getCompressedSize() { + return compressedSize; + } + + long getUncompressedSize() { + return fileSize; + } + /** * Checks that this entry's local header offset points inside the archive, so a corrupt * or truncated central directory fails here rather than at an arbitrary position. @@ -415,9 +448,74 @@ private long findStartOfData() throws IOException { return zipChannel.position() + filenameLength + extrafieldLength; } + /** + * Opens this entry's contents. A stored entry is read straight from the archive; a + * deflated one is inflated as it is read, never past the uncompressed size the central + * directory declares. Any other compression method is refused here, so an archive whose + * other entries use one can still be listed and those entries left unread. + *
+ * A deflated entry may not declare an uncompressed size larger than the larger of the + * archive's length and 10 MiB. Inflation never passes the declared size, so this is what + * bounds how far a small archive can expand. + *
+ * Entries share the archive's channel. Each stream positions the channel itself on every
+ * read, so streams over different entries may be interleaved, but a stream must not be
+ * used from more than one thread at a time. Close a deflated entry's stream to release its
+ * inflater.
+ *
+ * @return the entry's uncompressed bytes
+ * @throws InvalidZipException if the entry uses a compression method other than stored
+ * or deflate, declares a deflated size over its budget, or its local header is
+ * invalid. A deflated stream also throws it from {@code read} when its data is
+ * corrupt or does not inflate to exactly the declared uncompressed size.
+ */
public InputStream getData() throws IOException {
- final long startPosition = findStartOfData();
- final long endPosition = startPosition + fileSize;
+ long startOfData = checkReadable();
+ if (compressionMethod == DEFLATED) {
+ return new InflatingInputStream(channelStream(startOfData, compressedSize), fileSize, fileName);
+ }
+ return channelStream(startOfData, fileSize);
+ }
+
+ /**
+ * Makes every check {@link #getData()} makes, without opening a stream or allocating an
+ * inflater, so a reader can refuse an entry it will only read later.
+ *
+ * @return the offset of the entry's first byte of data
+ * @throws InvalidZipException as {@link #getData()} does before it returns a stream
+ */
+ long checkReadable() throws IOException {
+ switch (compressionMethod) {
+ case STORED:
+ break;
+ case DEFLATED:
+ checkDeflatedSizeBudget();
+ break;
+ default:
+ throw new InvalidZipException("unsupported compression method " + compressionMethod
+ + " for entry [" + fileName + "]; only stored (0) and deflate (8) are supported");
+ }
+ return findStartOfData();
+ }
+
+ /**
+ * Neither size can be negative here: {@link ZipReader#readCentralDirectoryFileHeader()}
+ * rejects a zip64 size that reads as one when it parses the entry.
+ */
+ private void checkDeflatedSizeBudget() throws IOException {
+ long budget = Math.max(zipChannel.size(), DEFLATED_SIZE_BUDGET_FLOOR);
+ if (fileSize > budget) {
+ throw new InvalidZipException("deflated entry [" + fileName + "] declares an uncompressed size of "
+ + fileSize + " bytes, more than its budget of " + budget
+ + " bytes (the larger of the archive's length and " + DEFLATED_SIZE_BUDGET_FLOOR + " bytes)");
+ }
+ }
+
+ /**
+ * Reads {@code length} bytes of the archive starting at {@code startPosition}. If the
+ * archive ends first, the read throws rather than reporting a short entry.
+ */
+ private InputStream channelStream(final long startPosition, final long length) {
final ByteBuffer buf = ByteBuffer.allocate(1);
return new InputStream() {
long offset = 0;
@@ -436,16 +534,18 @@ public int read() throws IOException {
}
private boolean doneReading() {
- return offset >= fileSize;
+ return offset >= length;
}
/**
* The archive ended before this entry's data did. Reporting that as an ordinary
* end of stream would hand the caller a short entry with no sign anything is wrong.
+ * {@code length} is what the entry occupies in the archive: its size if stored, its
+ * compressed size if deflated.
*/
private InvalidZipException truncated() {
return new InvalidZipException("Archive ended " + offset + " bytes into the "
- + fileSize + " byte entry [" + fileName + "]");
+ + length + " bytes of data that entry [" + fileName + "] occupies in it");
}
private void setChannelPosition() throws IOException {
@@ -464,7 +564,7 @@ public int read(byte[] b, int off, int len) throws IOException {
return -1;
}
setChannelPosition();
- var lenToRead = (int)Math.min(len, fileSize - offset); // cast is always valid because len is an int
+ var lenToRead = (int)Math.min(len, length - offset); // cast is always valid because len is an int
var buf = ByteBuffer.wrap(b, off, lenToRead);
// InputStream must not return 0 for a non-empty request, so this has to wait
// for at least one byte rather than pass an empty channel read through
@@ -486,7 +586,7 @@ public Entry readCentralDirectoryFileHeader() throws IOException {
short versionMadeBy = readShort();
short versionNeededToExtract = readShort();
short generalPurposeBitFlag = readShort();
- short compressionMethod = readShort();
+ int compressionMethod = readUnsignedShort();
short lastModFileTime = readShort();
short lastModFileDate = readShort();
int crc32 = readInt();
@@ -535,16 +635,19 @@ public Entry readCentralDirectoryFileHeader() throws IOException {
}
// a zip64 size is unsigned on the wire, but nothing holds more than Long.MAX_VALUE bytes, so
- // one that reads as negative is corrupt. left alone, it reads as an empty entry, not an error
- if (uncompressedSize < 0) {
+ // one that reads as negative is corrupt. left alone, an uncompressed size reads as an empty
+ // entry and a compressed one as a deflated entry with no data, not as an error
+ if (uncompressedSize < 0 || compressedSize < 0) {
throw new InvalidZipException("Entry [" + new String(fileName.array(), StandardCharsets.UTF_8)
- + "] claims a size of " + Long.toUnsignedString(uncompressedSize)
+ + "] claims a " + (uncompressedSize < 0 ? "size" : "compressed size") + " of "
+ + Long.toUnsignedString(uncompressedSize < 0 ? uncompressedSize : compressedSize)
+ " bytes, which no archive can hold");
}
zipChannel.position(zipChannel.position() + fileCommentLength);
- return new Entry(fileName.array(), relativeOffsetOfLocalHeader, uncompressedSize);
+ return new Entry(fileName.array(), relativeOffsetOfLocalHeader, compressionMethod, compressedSize,
+ uncompressedSize);
}
public ZipReader(SeekableByteChannel channel) throws IOException {
@@ -571,4 +674,164 @@ public ZipReader(SeekableByteChannel channel) throws IOException {
public List
+ * The CRC-32 is not checked. A TDF's integrity comes from its segment hashes and root
+ * signature, which are verified over these bytes.
+ */
+ private static final class InflatingInputStream extends InputStream {
+ private static final int INPUT_BUFFER_SIZE = 8192;
+
+ private final InputStream compressed;
+ private final long uncompressedSize;
+ private final String entryName;
+ private final Inflater inflater = new Inflater(true);
+ private final byte[] input = new byte[INPUT_BUFFER_SIZE];
+ private final byte[] singleByte = new byte[1];
+ private long produced = 0;
+ private boolean paddingFed = false;
+ private boolean endVerified = false;
+ private boolean closed = false;
+
+ InflatingInputStream(InputStream compressed, long uncompressedSize, String entryName) {
+ this.compressed = compressed;
+ this.uncompressedSize = uncompressedSize;
+ this.entryName = entryName;
+ INFLATERS_CREATED.incrementAndGet();
+ OPEN_INFLATERS.incrementAndGet();
+ }
+
+ @Override
+ public int read() throws IOException {
+ return read(singleByte, 0, 1) == -1 ? -1 : singleByte[0] & 0xFF;
+ }
+
+ @Override
+ public int read(byte[] b, int off, int len) throws IOException {
+ Objects.checkFromIndexSize(off, len, b.length);
+ if (closed) {
+ throw new IOException("stream closed");
+ }
+ if (len == 0) {
+ return 0;
+ }
+ if (produced == uncompressedSize) {
+ verifyEnd();
+ return -1;
+ }
+ // never ask for more than the declared size, so nothing past it reaches the caller
+ int wanted = (int) Math.min(len, uncompressedSize - produced);
+ while (true) {
+ int n = inflate(b, off, wanted);
+ if (n > 0) {
+ produced += n;
+ return n;
+ }
+ if (inflater.finished()) {
+ throw new InvalidZipException("deflated entry [" + entryName + "] ended after " + produced
+ + " bytes, but its declared uncompressed size is " + uncompressedSize);
+ }
+ fill();
+ }
+ }
+
+ /**
+ * At the declared size the deflate stream must also be at its end. Probes for a single
+ * further byte: getting one means the entry holds more than it declares.
+ */
+ private void verifyEnd() throws IOException {
+ if (endVerified) {
+ return;
+ }
+ byte[] probe = new byte[1];
+ while (!inflater.finished()) {
+ if (inflate(probe, 0, 1) > 0) {
+ throw new InvalidZipException("deflated entry [" + entryName
+ + "] holds more data than its declared uncompressed size of " + uncompressedSize);
+ }
+ if (!inflater.finished()) {
+ fill();
+ }
+ }
+ endVerified = true;
+ }
+
+ private int inflate(byte[] b, int off, int len) {
+ int n;
+ try {
+ n = inflater.inflate(b, off, len);
+ } catch (DataFormatException e) {
+ throw new InvalidZipException("invalid deflate data in entry [" + entryName + "]", e);
+ }
+ if (n > 0 && paddingFed) {
+ // the padding byte was taken as data, so the real compressed data ran out first
+ throw truncated();
+ }
+ return n;
+ }
+
+ private void fill() throws IOException {
+ if (inflater.needsDictionary()) {
+ // raw deflate has no way to name a dictionary, so only corrupt data gets here
+ throw new InvalidZipException("invalid deflate data in entry [" + entryName
+ + "]: it asks for a preset dictionary");
+ }
+ if (!inflater.needsInput()) {
+ return;
+ }
+ int n = compressed.read(input, 0, input.length);
+ if (n > 0) {
+ inflater.setInput(input, 0, n);
+ return;
+ }
+ if (!paddingFed) {
+ // without the zlib wrapper, zlib may want one byte past the end of the deflate
+ // stream before it reports the end (see Inflater(boolean)); ZipFile pads the same way
+ paddingFed = true;
+ input[0] = 0;
+ inflater.setInput(input, 0, 1);
+ return;
+ }
+ throw truncated();
+ }
+
+ private InvalidZipException truncated() {
+ return new InvalidZipException("deflated entry [" + entryName + "] is truncated: its compressed data ran out after "
+ + produced + " of its declared " + uncompressedSize + " uncompressed bytes");
+ }
+
+ @Override
+ public void close() throws IOException {
+ if (!closed) {
+ closed = true;
+ inflater.end();
+ OPEN_INFLATERS.decrementAndGet();
+ compressed.close();
+ }
+ }
+ }
}
diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/TDFReaderTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/TDFReaderTest.java
index 8844a002..b5b9bc87 100644
--- a/sdk/src/test/java/io/opentdf/platform/sdk/TDFReaderTest.java
+++ b/sdk/src/test/java/io/opentdf/platform/sdk/TDFReaderTest.java
@@ -1,5 +1,6 @@
package io.opentdf.platform.sdk;
+import org.apache.commons.compress.archivers.zip.Zip64Mode;
import org.apache.commons.compress.utils.SeekableInMemoryByteChannel;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
@@ -181,4 +182,157 @@ void readsThePayloadAlongsideASpecNamedManifest() throws IOException {
assertThat(buf).startsWith(expected);
}
}
+
+ /**
+ * The reader opens the payload stream when it is constructed and may read the manifest again
+ * at any point (for the policy, say) while the payload is part way through. With both
+ * entries deflated, the payload stream must pick up where it left off.
+ */
+ @ParameterizedTest
+ @ValueSource(strings = { "manifest.json", "0.manifest.json" })
+ void readsADeflatedPayloadAroundRereadsOfADeflatedManifest(String manifestName) throws IOException {
+ byte[] payload = ZipReaderTest.compressibleBytes(100_000);
+ byte[] archive = ZipReaderTest.zipOf(Zip64Mode.Never,
+ ZipReaderTest.deflated(TDFWriter.TDF_PAYLOAD_FILE_NAME, payload),
+ ZipReaderTest.deflated(manifestName, MANIFEST.getBytes(StandardCharsets.UTF_8)));
+ try (var tdf = new SeekableInMemoryByteChannel(archive)) {
+ var reader = new TDFReader(tdf);
+ var first = new byte[30_000];
+ var rest = new byte[payload.length - first.length + 1];
+
+ assertThat(reader.readPayloadBytes(first)).isEqualTo(first.length);
+ assertThat(reader.manifest()).isEqualTo(MANIFEST);
+ assertThat(reader.readPayloadBytes(rest)).isEqualTo(payload.length - first.length);
+ assertThat(reader.manifest()).isEqualTo(MANIFEST);
+
+ var whole = new ByteArrayOutputStream();
+ whole.write(first);
+ whole.write(rest, 0, payload.length - first.length);
+ assertThat(whole.toByteArray()).isEqualTo(payload);
+ }
+ }
+
+ @Test
+ void rejectsAPayloadWithAnUnsupportedCompressionMethod() throws IOException {
+ byte[] archive = ZipReaderTest.withCompressionMethod(ZipReaderTest.zipOf(Zip64Mode.Never,
+ ZipReaderTest.stored(TDFWriter.TDF_PAYLOAD_FILE_NAME, PAYLOAD.getBytes(StandardCharsets.UTF_8)),
+ ZipReaderTest.stored("manifest.json", MANIFEST.getBytes(StandardCharsets.UTF_8))),
+ TDFWriter.TDF_PAYLOAD_FILE_NAME, 12);
+ try (var tdf = new SeekableInMemoryByteChannel(archive)) {
+ assertThatThrownBy(() -> new TDFReader(tdf))
+ .isInstanceOf(InvalidZipException.class)
+ .hasMessageContaining("[" + TDFWriter.TDF_PAYLOAD_FILE_NAME + "]")
+ .hasMessageContaining("unsupported compression method 12");
+ }
+ }
+
+ @Test
+ void rejectsAManifestWithAnUnsupportedCompressionMethod() throws IOException {
+ byte[] archive = ZipReaderTest.withCompressionMethod(ZipReaderTest.zipOf(Zip64Mode.Never,
+ ZipReaderTest.stored(TDFWriter.TDF_PAYLOAD_FILE_NAME, PAYLOAD.getBytes(StandardCharsets.UTF_8)),
+ ZipReaderTest.stored("manifest.json", MANIFEST.getBytes(StandardCharsets.UTF_8))),
+ "manifest.json", 12);
+ try (var tdf = new SeekableInMemoryByteChannel(archive)) {
+ var reader = new TDFReader(tdf);
+ assertThatThrownBy(reader::manifest)
+ .isInstanceOf(InvalidZipException.class)
+ .hasMessageContaining("[manifest.json]")
+ .hasMessageContaining("unsupported compression method 12");
+ }
+ }
+
+ /**
+ * The manifest is read whole into memory before it is validated, so a tiny archive whose
+ * deflated manifest declares 16 MiB is refused on its declaration, before anything is
+ * inflated.
+ */
+ @Test
+ void rejectsADeflatedManifestBombBeforeInflatingIt() throws IOException {
+ byte[] archive = ZipReaderTest.zipOf(Zip64Mode.Never,
+ ZipReaderTest.stored(TDFWriter.TDF_PAYLOAD_FILE_NAME, PAYLOAD.getBytes(StandardCharsets.UTF_8)),
+ ZipReaderTest.deflated("manifest.json", new byte[16 * 1024 * 1024]));
+ assertThat(archive.length).isLessThan(64 * 1024);
+ int inflatersBefore = ZipReader.inflatersCreated();
+ try (var tdf = new SeekableInMemoryByteChannel(archive)) {
+ assertThatThrownBy(() -> SDK.readManifest(tdf))
+ .isInstanceOf(InvalidZipException.class)
+ .hasMessageContaining("[manifest.json]")
+ .hasMessageContaining("budget");
+ }
+ assertThat(ZipReader.inflatersCreated()).isEqualTo(inflatersBefore);
+ }
+
+ /**
+ * A deflated payload over the budget is refused when the reader is built, before a key is
+ * requested. Real payloads are ciphertext, which does not compress, so a legitimate one never
+ * declares more than the archive holds.
+ */
+ @Test
+ void rejectsADeflatedPayloadDeclaringMoreThanTheBudget() throws IOException {
+ byte[] archive = ZipReaderTest.zipOf(Zip64Mode.Never,
+ ZipReaderTest.deflated(TDFWriter.TDF_PAYLOAD_FILE_NAME, new byte[11 * 1024 * 1024]),
+ ZipReaderTest.stored("manifest.json", MANIFEST.getBytes(StandardCharsets.UTF_8)));
+ try (var tdf = new SeekableInMemoryByteChannel(archive)) {
+ assertThatThrownBy(() -> new TDFReader(tdf))
+ .isInstanceOf(InvalidZipException.class)
+ .hasMessageContaining("[" + TDFWriter.TDF_PAYLOAD_FILE_NAME + "]")
+ .hasMessageContaining("uncompressed size of " + (11 * 1024 * 1024) + " bytes")
+ .hasMessageContaining("budget of " + (10 * 1024 * 1024) + " bytes");
+ }
+ }
+
+ /**
+ * Reading only the manifest never opens the payload, so a deflated payload gets no inflater.
+ * The manifest here is stored, so any inflater at all would have to be the payload's.
+ */
+ @Test
+ void manifestOnlyReadsDoNotCreateAPayloadInflater() throws IOException {
+ byte[] archive = ZipReaderTest.zipOf(Zip64Mode.Never,
+ ZipReaderTest.deflated(TDFWriter.TDF_PAYLOAD_FILE_NAME, ZipReaderTest.compressibleBytes(50_000)),
+ ZipReaderTest.stored("manifest.json", MANIFEST.getBytes(StandardCharsets.UTF_8)));
+ int inflatersBefore = ZipReader.inflatersCreated();
+ try (var tdf = new SeekableInMemoryByteChannel(archive)) {
+ var reader = new TDFReader(tdf);
+ assertThat(reader.manifest()).isEqualTo(MANIFEST);
+ assertThat(reader.manifest()).isEqualTo(MANIFEST);
+ }
+ assertThat(ZipReader.inflatersCreated()).isEqualTo(inflatersBefore);
+ }
+
+ /**
+ * Once the payload has been read to its end it is released, and a failed read releases it
+ * too. A deflated manifest's inflater is released as soon as the manifest has been read.
+ */
+ @Test
+ void releasesInflatersWhenThePayloadIsFinishedOrFails() throws IOException {
+ byte[] payload = ZipReaderTest.compressibleBytes(50_000);
+ byte[] archive = ZipReaderTest.zipOf(Zip64Mode.Never,
+ ZipReaderTest.deflated(TDFWriter.TDF_PAYLOAD_FILE_NAME, payload),
+ ZipReaderTest.deflated("manifest.json", MANIFEST.getBytes(StandardCharsets.UTF_8)));
+ int openBefore = ZipReader.openInflaters();
+
+ try (var tdf = new SeekableInMemoryByteChannel(archive)) {
+ var reader = new TDFReader(tdf);
+ assertThat(reader.manifest()).isEqualTo(MANIFEST);
+ assertThat(ZipReader.openInflaters()).isEqualTo(openBefore);
+
+ assertThat(reader.readPayloadBytes(new byte[payload.length])).isEqualTo(payload.length);
+ assertThat(ZipReader.openInflaters()).isEqualTo(openBefore + 1);
+ reader.finishPayload();
+ assertThat(ZipReader.openInflaters()).isEqualTo(openBefore);
+ }
+
+ // declares one byte fewer than it holds, so the read that reaches the end fails
+ byte[] corrupt = ZipReaderTest.withDeclaredUncompressedSize(
+ ZipReaderTest.zipOf(Zip64Mode.Never,
+ ZipReaderTest.deflated(TDFWriter.TDF_PAYLOAD_FILE_NAME, payload),
+ ZipReaderTest.stored("manifest.json", MANIFEST.getBytes(StandardCharsets.UTF_8))),
+ TDFWriter.TDF_PAYLOAD_FILE_NAME, payload.length - 1L);
+ try (var tdf = new SeekableInMemoryByteChannel(corrupt)) {
+ var reader = new TDFReader(tdf);
+ var buf = new byte[payload.length];
+ assertThatThrownBy(() -> reader.readPayloadBytes(buf)).isInstanceOf(InvalidZipException.class);
+ assertThat(ZipReader.openInflaters()).isEqualTo(openBefore);
+ }
+ }
}
diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/TDFTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/TDFTest.java
index aa5b38e6..5b2bc419 100644
--- a/sdk/src/test/java/io/opentdf/platform/sdk/TDFTest.java
+++ b/sdk/src/test/java/io/opentdf/platform/sdk/TDFTest.java
@@ -17,6 +17,7 @@
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.ValueSource;
import javax.annotation.Nonnull;
@@ -39,8 +40,12 @@
import java.util.concurrent.Future;
import java.util.function.Consumer;
import java.util.function.Predicate;
+import java.util.function.UnaryOperator;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
+import java.util.zip.CRC32;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
import static io.opentdf.platform.sdk.TDF.GLOBAL_KEY_SALT;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
@@ -916,6 +921,252 @@ private static ByteArrayOutputStream readZipEntry(ZipReader reader, String name)
return data;
}
+ /**
+ * OpenTDF writers store both entries, but other TDF writers may deflate either one. Takes a
+ * TDF this SDK wrote, repacks it with the requested entries deflated, and decrypts it end to
+ * end. The entry contents are unchanged, so the segment hashes and root signature still have
+ * to verify against the inflated bytes. The payload spans several segments, so the reader
+ * walks the inflated stream across segment boundaries.
+ */
+ @ParameterizedTest
+ @CsvSource({
+ "manifest.json, true, true",
+ "0.manifest.json, true, true",
+ "manifest.json, true, false",
+ "0.manifest.json, false, true",
+ })
+ public void testDecryptingATDFWithDeflatedEntries(String manifestName, boolean deflateManifest,
+ boolean deflatePayload) throws Exception {
+ String metadata = "metadata for a deflated tdf";
+ var data = new byte[(int) (Config.MIN_SEGMENT_SIZE * 3.5)];
+ new Random(7741).nextBytes(data);
+ var tdf = new TDF(new FakeServicesBuilder().setKas(kas)
+ .setKeyAccessServerRegistryService(kasRegistryService).build());
+ var original = new ByteArrayOutputStream();
+ var tdfObject = tdf.createTDF(new ByteArrayInputStream(data), original,
+ Config.newTDFConfig(
+ Config.withAutoconfigure(false),
+ Config.withKasInformation(getRSAKASInfos()),
+ Config.withSegmentSize(Config.MIN_SEGMENT_SIZE),
+ Config.withMetaData(metadata)));
+ assertThat(tdfObject.getManifest().encryptionInformation.integrityInformation.segments)
+ .withFailMessage("the test needs more than one segment to be meaningful")
+ .hasSizeGreaterThan(1);
+
+ byte[] repacked = repackWithDeflate(original.toByteArray(), manifestName, deflateManifest, deflatePayload);
+ assertThat(ZipReaderTest.compressionMethodOf(repacked, manifestName))
+ .isEqualTo(deflateManifest ? ZipEntry.DEFLATED : ZipEntry.STORED);
+ assertThat(ZipReaderTest.compressionMethodOf(repacked, TDFWriter.TDF_PAYLOAD_FILE_NAME))
+ .isEqualTo(deflatePayload ? ZipEntry.DEFLATED : ZipEntry.STORED);
+
+ var unwrapped = new ByteArrayOutputStream();
+ var reader = tdf.loadTDF(new SeekableInMemoryByteChannel(repacked), platformUrl);
+ reader.readPayload(unwrapped);
+
+ assertThat(unwrapped.toByteArray())
+ .withFailMessage("extracted data does not match")
+ .containsExactly(data);
+ assertThat(reader.getMetadata()).isEqualTo(metadata);
+ assertThat(SDK.readManifest(new SeekableInMemoryByteChannel(repacked)).payload.isEncrypted).isTrue();
+ }
+
+ /**
+ * Repacks a TDF this SDK wrote using {@link ZipOutputStream}, deflating the entries asked
+ * for. That writer streams, so deflated entries carry their sizes in a data descriptor and
+ * the central directory, with zeros in the local header.
+ */
+ private static byte[] repackWithDeflate(byte[] tdfBytes, String manifestName, boolean deflateManifest,
+ boolean deflatePayload) throws IOException {
+ return repackWithDeflate(tdfBytes, manifestName, deflateManifest, deflatePayload, UnaryOperator.identity());
+ }
+
+ /** As above, with the payload bytes passed through {@code editPayload} on the way. */
+ private static byte[] repackWithDeflate(byte[] tdfBytes, String manifestName, boolean deflateManifest,
+ boolean deflatePayload, UnaryOperator