diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/Manifest.java b/sdk/src/main/java/io/opentdf/platform/sdk/Manifest.java index e751a2120..5b5edc397 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/Manifest.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/Manifest.java @@ -1141,14 +1141,11 @@ public AssertionConfig.Statement deserialize(JsonElement json, Type typeOfT, Jso } var obj = json.getAsJsonObject(); var statement = new AssertionConfig.Statement(); - if (obj.has("format")) { - statement.format = obj.get("format").getAsString(); - } - if (obj.has("schema")) { - statement.schema = obj.get("schema").getAsString(); - } - if (obj.has("value")) { - var value = obj.get("value"); + statement.format = optionalString(obj, "format"); + statement.schema = optionalString(obj, "schema"); + var value = obj.get("value"); + // explicit JSON null means no value, not the string "null" + if (value != null && !value.isJsonNull()) { if (value.isJsonPrimitive()) { // it's already a primitive (hopefully string) so we don't need its escaped value here statement.value = value.getAsString(); @@ -1158,6 +1155,18 @@ public AssertionConfig.Statement deserialize(JsonElement json, Type typeOfT, Jso } return statement; } + + /** The member as a string, or null if absent or JSON null; a non-primitive is a JsonParseException. */ + private static String optionalString(JsonObject obj, String member) { + var element = obj.get(member); + if (element == null || element.isJsonNull()) { + return null; + } + if (!element.isJsonPrimitive()) { + throw new JsonParseException(String.format("assertion statement %s must be a string", member)); + } + return element.getAsString(); + } } public EncryptionInformation encryptionInformation; diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/SDK.java b/sdk/src/main/java/io/opentdf/platform/sdk/SDK.java index f4aa7d630..633f10405 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/SDK.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/SDK.java @@ -176,6 +176,11 @@ public static boolean isTDF(SeekableByteChannel channel) { zipReader = new ZipReader(channel); } catch (IOException | InvalidZipException e) { return false; + } catch (RuntimeException e) { + // backstop for the yes-or-no contract: an unforeseen parse failure, or an unchecked + // exception from the caller's channel, still means "not a TDF" + ZipReader.logger.debug("unexpected exception checking whether a channel holds a TDF", e); + return false; } var entries = zipReader.getEntries(); var names = entries.stream().map(ZipReader.Entry::getName).collect(Collectors.toSet()); @@ -191,14 +196,12 @@ public static boolean isTDF(SeekableByteChannel channel) { * Reads the {@link Manifest} without decrypting the TDF * @param tdfBytes A SeekableByteChannel containing the TDF data * @return The parsed {@link Manifest} object + * @throws MalformedTDFException if the bytes are not a well formed TDF or its manifest is invalid * @throws SDKException if an SDK-specific error occurs * @throws IOException if an I/O error occurs */ public static Manifest readManifest(SeekableByteChannel tdfBytes) throws SDKException, IOException { - TDFReader reader = new TDFReader(tdfBytes); - try (var manifestJson = reader.manifest()) { - return Manifest.readManifest(manifestJson); - } + return TDFReader.open(tdfBytes).readManifest(); } /** @@ -389,6 +392,10 @@ public static class MalformedTDFException extends SDKException { public MalformedTDFException(String errorMessage) { super(errorMessage); } + + public MalformedTDFException(String errorMessage, Exception reason) { + super(errorMessage, reason); + } } /** 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 883d885da..4ad242984 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java @@ -788,12 +788,8 @@ Reader loadTDF(SeekableByteChannel tdf, Config.TDFReaderConfig tdfReaderConfig, Reader loadTDF(SeekableByteChannel tdf, Config.TDFReaderConfig tdfReaderConfig) throws SDKException, IOException { - TDFReader tdfReader = new TDFReader(tdf); - // use Manifest.readManifest in order to validate the Manifest input - Manifest manifest; - try (var manifestJson = tdfReader.manifest()) { - manifest = Manifest.readManifest(manifestJson); - } + TDFReader tdfReader = TDFReader.open(tdf); + Manifest manifest = tdfReader.readManifest(); byte[] payloadKey = new byte[GCM_KEY_SIZE]; String unencryptedMetadata = null; 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 43fadb2a7..0f669962b 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/TDFReader.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/TDFReader.java @@ -1,5 +1,7 @@ package io.opentdf.platform.sdk; +import com.google.gson.JsonParseException; + import java.io.BufferedReader; import java.io.IOException; import java.io.InputStream; @@ -53,15 +55,50 @@ public TDFReader(SeekableByteChannel tdf) throws SDKException, IOException { payload = entries.get(TDF_PAYLOAD_FILE_NAME).getData(); } + /** Like the constructor, but reports a malformed container as {@link SDK.MalformedTDFException}. */ + static TDFReader open(SeekableByteChannel tdf) throws IOException { + try { + return new TDFReader(tdf); + } catch (InvalidZipException | IllegalArgumentException e) { + throw malformed("tdf is not a valid zip container", e); + } + } + + /** + * Reads and validates the manifest. Malformed JSON, and JSON that does not have the shape of + * a manifest, is reported as a {@link SDK.MalformedTDFException}. + */ + Manifest readManifest() { + try (Reader manifestJson = manifest()) { + return Manifest.readManifest(manifestJson); + } catch (InvalidZipException e) { + // the manifest entry runs past the end of the archive + throw malformed("error retrieving manifest from zip file", e); + } catch (JsonParseException | IllegalArgumentException e) { + // Manifest.readManifest reports a missing or misshapen field as IllegalArgumentException + throw malformed("tdf manifest is invalid", e); + } catch (IOException e) { + throw new SDKException("error reading manifest from zip file", e); + } + } + + private static SDK.MalformedTDFException malformed(String what, RuntimeException e) { + return new SDK.MalformedTDFException(what + ": " + e.getMessage(), e); + } + /** * The manifest entry as a character stream; the caller must close it. Returned * as a stream rather than a String because a manifest with tens of millions of * segments exceeds the maximum size of a Java String. */ Reader manifest() { + // no fixed size cap: manifests for very large payloads exceed 1 GiB. a hostile declared + // size is still bounded by the archive, since the entry's stream throws where it ends try { return new BufferedReader( new InputStreamReader(manifestEntry.getData(), StandardCharsets.UTF_8), MANIFEST_BUFFER_SIZE); + } catch (InvalidZipException e) { + throw malformed("error retrieving manifest from zip file", e); } catch (IOException e) { throw new SDKException("error retrieving manifest from zip file", e); } @@ -74,6 +111,9 @@ int readPayloadBytes(byte[] buf) { while (totalRead < buf.length && (nread = payload.read(buf, totalRead, buf.length - totalRead)) >= 0) { totalRead += nread; } + } catch (InvalidZipException e) { + // the payload entry runs past the end of the archive + throw malformed("error reading from payload in TDF", e); } catch (IOException e) { throw new SDKException("error reading from payload in TDF", e); } @@ -81,10 +121,7 @@ int readPayloadBytes(byte[] buf) { } PolicyObject readPolicyObject() { - try (Reader manifestRaw = manifest()) { - return Manifest.decodePolicyObject(Manifest.readManifest(manifestRaw)); - } catch (IOException e) { - throw new SDKException("error reading manifest from zip file", e); - } + Manifest manifest = readManifest(); + return Manifest.decodePolicyObject(manifest); } } 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 d2fd6e916..c1d24e39a 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/ZipReader.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/ZipReader.java @@ -120,10 +120,13 @@ private int readUnsignedShort() throws IOException { private static class CentralDirectoryRecord { final long numEntries; final long offsetToStart; + /** Never negative, and ends exactly where the record that follows the directory starts. */ + final long size; - public CentralDirectoryRecord(long numEntries, long offsetToStart) { + public CentralDirectoryRecord(long numEntries, long offsetToStart, long size) { this.numEntries = numEntries; this.offsetToStart = offsetToStart; + this.size = size; } } @@ -194,7 +197,7 @@ private CentralDirectoryRecord centralDirectoryEndingAt(String recordName, long + " describes a central directory of " + Long.toUnsignedString(size) + " bytes at offset " + offset + ", which does not end where that record starts"); } - return new CentralDirectoryRecord(numEntries, offset); + return new CentralDirectoryRecord(numEntries, offset, size); } /** @@ -412,7 +415,16 @@ private long findStartOfData() throws IOException { int filenameLength = readUnsignedShort(); int extrafieldLength = readUnsignedShort(); - return zipChannel.position() + filenameLength + extrafieldLength; + long startOfData = zipChannel.position() + filenameLength + extrafieldLength; + // the central directory can't see these two lengths, so this is the first place a + // start past the end shows. an empty entry is never read, so unchecked it would come + // back empty rather than fail as truncated + if (startOfData > zipChannel.size()) { + throw new InvalidZipException("The local file header of entry [" + fileName + "] at offset " + + offsetToLocalHeader + " puts its data at offset " + startOfData + + ", past the end of this " + zipChannel.size() + " byte archive"); + } + return startOfData; } public InputStream getData() throws IOException { @@ -478,7 +490,55 @@ public int read(byte[] b, int off, int len) throws IOException { }; } } + + /** Each extra field record starts with a 2-byte id and a 2-byte data size. */ + private static final int EXTRA_FIELD_HEADER_SIZE = 4; + + /** Throws if {@code size} bytes at the current position would run past {@code fieldEnd}. */ + private void requireWithinField(String what, int size, long fieldEnd, long headerStart) throws IOException { + if (zipChannel.position() + size > fieldEnd) { + throw new InvalidZipException("The zip64 extra field of the central directory file header at offset " + + headerStart + " is too short to hold the " + what + " its sentinel promises"); + } + } + + /** Reads an 8-byte zip64 value; one above {@link Long#MAX_VALUE} is rejected as corrupt. */ + private long readZip64Value(String what, long fieldEnd, long headerStart) throws IOException { + requireWithinField(what, Long.BYTES, fieldEnd, headerStart); + long value = readLong(); + if (value < 0) { + throw new InvalidZipException("The zip64 " + what + " of the central directory file header at offset " + + headerStart + " is " + Long.toUnsignedString(value) + ", which no archive can hold"); + } + return value; + } + + /** The field as read, or the next zip64 value if the field holds the 0xFFFFFFFF sentinel. */ + private long zip64ValueIfSentinel(long field, String what, long fieldEnd, long headerStart) throws IOException { + return field == ZIP64_MAGICVAL ? readZip64Value(what, fieldEnd, headerStart) : field; + } + public Entry readCentralDirectoryFileHeader() throws IOException { + return readCentralDirectoryFileHeader(zipChannel.size()); + } + + /** + * Bounded by the directory, not the archive, so an inflated length can't pull the records that + * follow the directory into an entry. + */ + private static void requireWithinCentralDirectory(String what, long end, long centralDirectoryEnd, + long headerStart) { + if (end > centralDirectoryEnd) { + throw new InvalidZipException("The " + what + " of the central directory file header at offset " + + headerStart + " ends at offset " + end + ", past the end of the central directory at offset " + + centralDirectoryEnd); + } + } + + private Entry readCentralDirectoryFileHeader(long centralDirectoryEnd) throws IOException { + long headerStart = zipChannel.position(); + requireWithinCentralDirectory("fixed part", headerStart + CENTRAL_DIRECTORY_FILE_HEADER_MIN_SIZE, + centralDirectoryEnd, headerStart); Integer signature = readInteger(); if (signature == null || signature != CENTRAL_FILE_HEADER_SIGNATURE) { throw new InvalidZipException("Invalid Central Directory File Header Signature"); @@ -501,48 +561,63 @@ public Entry readCentralDirectoryFileHeader() throws IOException { long relativeOffsetOfLocalHeader = readUnsignedInt(); long fileNameStart = zipChannel.position(); + requireWithinCentralDirectory(fileNameLength + " byte filename", fileNameStart + fileNameLength, + centralDirectoryEnd, headerStart); ByteBuffer fileName = ByteBuffer.allocate(fileNameLength); if (!fill(fileName)) { throw new InvalidZipException("Archive ended inside the " + fileNameLength - + " byte filename of the central directory file header at offset " - + (fileNameStart - CENTRAL_DIRECTORY_FILE_HEADER_MIN_SIZE)); + + " byte filename of the central directory file header at offset " + headerStart); } - // Parse the extra field - for (final long startPos = zipChannel.position(); zipChannel.position() < startPos + extraFieldLength; ) { - long fieldStart = zipChannel.position(); + long extraFieldStart = zipChannel.position(); + long extraFieldEnd = extraFieldStart + extraFieldLength; + requireWithinCentralDirectory(extraFieldLength + " byte extra field", extraFieldEnd, + centralDirectoryEnd, headerStart); + + // each step moves past a whole record, header included, so the walk always advances and + // never leaves the extra field + long fieldStart = extraFieldStart; + while (extraFieldEnd - fieldStart >= EXTRA_FIELD_HEADER_SIZE) { + zipChannel.position(fieldStart); int headerId = readUnsignedShort(); int dataSize = readUnsignedShort(); + long dataStart = fieldStart + EXTRA_FIELD_HEADER_SIZE; + long dataEnd = dataStart + dataSize; + if (dataEnd > extraFieldEnd) { + throw new InvalidZipException("Extra field 0x" + Integer.toHexString(headerId) + " of the central" + + " directory file header at offset " + headerStart + " claims " + dataSize + + " bytes, but only " + (extraFieldEnd - dataStart) + " remain in the extra field"); + } if (headerId == ZIP64_EXTID) { // APPNOTE 4.5.3 order: original size, compressed size, then local header offset - if (uncompressedSize == ZIP64_MAGICVAL) { - uncompressedSize = readLong(); - } - if (compressedSize == ZIP64_MAGICVAL) { - compressedSize = readLong(); - } - if (relativeOffsetOfLocalHeader == ZIP64_MAGICVAL) { - relativeOffsetOfLocalHeader = readLong(); - } + uncompressedSize = zip64ValueIfSentinel(uncompressedSize, "uncompressed size", dataEnd, headerStart); + compressedSize = zip64ValueIfSentinel(compressedSize, "compressed size", dataEnd, headerStart); + relativeOffsetOfLocalHeader = zip64ValueIfSentinel(relativeOffsetOfLocalHeader, + "local header offset", dataEnd, headerStart); // a 2-byte field, so its sentinel is 0xFFFF rather than 0xFFFFFFFF if (diskNumberStart == ZIP64_MAGIC_SHORT) { + requireWithinField("disk number", Integer.BYTES, dataEnd, headerStart); diskNumberStart = readInt(); } } - // Skip other extra fields - zipChannel.position(fieldStart + dataSize + 4); + fieldStart = dataEnd; } - - // 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) { + // fewer bytes than a record header may be left over; like other readers, ignore them + + long fileCommentEnd = extraFieldEnd + fileCommentLength; + requireWithinCentralDirectory(fileCommentLength + " byte comment", fileCommentEnd, + centralDirectoryEnd, headerStart); + // counted from the start of its local header, the entry's stored bytes must fit in the + // archive. where its data actually starts, and its uncompressed size, are checked when + // the entry is read + if (relativeOffsetOfLocalHeader >= zipChannel.size() + || compressedSize > zipChannel.size() - relativeOffsetOfLocalHeader) { throw new InvalidZipException("Entry [" + new String(fileName.array(), StandardCharsets.UTF_8) - + "] claims a size of " + Long.toUnsignedString(uncompressedSize) - + " bytes, which no archive can hold"); + + "] claims " + compressedSize + " bytes at local header offset " + relativeOffsetOfLocalHeader + + ", which does not fit in this " + zipChannel.size() + " byte archive"); } - - zipChannel.position(zipChannel.position() + fileCommentLength); + zipChannel.position(fileCommentEnd); return new Entry(fileName.array(), relativeOffsetOfLocalHeader, uncompressedSize); } @@ -551,17 +626,17 @@ public ZipReader(SeekableByteChannel channel) throws IOException { zipChannel = channel; var centralDirectoryRecord = readEndOfCentralDirectory(); seekWithinArchive("the central directory", centralDirectoryRecord.offsetToStart); - // a corrupt zip64 count can be negative, which would read as an empty archive, or more - // than the rest of the archive can hold, which would read past the central directory - long bytesAvailable = zipChannel.size() - centralDirectoryRecord.offsetToStart; - long mostEntriesThatFit = bytesAvailable / CENTRAL_DIRECTORY_FILE_HEADER_MIN_SIZE; + long centralDirectoryEnd = centralDirectoryRecord.offsetToStart + centralDirectoryRecord.size; + // a corrupt zip64 count can be negative, which would read as an empty archive; an + // excessive one would only fail at the first header past the end, with a vaguer message + long mostEntriesThatFit = centralDirectoryRecord.size / CENTRAL_DIRECTORY_FILE_HEADER_MIN_SIZE; if (centralDirectoryRecord.numEntries < 0 || centralDirectoryRecord.numEntries > mostEntriesThatFit) { throw new InvalidZipException("The central directory claims " + centralDirectoryRecord.numEntries - + " entries, but the " + bytesAvailable + " bytes from its start to the end of the archive" - + " can hold at most " + mostEntriesThatFit); + + " entries, but its " + centralDirectoryRecord.size + " bytes can hold at most " + + mostEntriesThatFit); } for (long i = 0; i < centralDirectoryRecord.numEntries; i++) { - entries.add(readCentralDirectoryFileHeader()); + entries.add(readCentralDirectoryFileHeader(centralDirectoryEnd)); } } diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/HostileZips.java b/sdk/src/test/java/io/opentdf/platform/sdk/HostileZips.java new file mode 100644 index 000000000..9db94d05e --- /dev/null +++ b/sdk/src/test/java/io/opentdf/platform/sdk/HostileZips.java @@ -0,0 +1,163 @@ +package io.opentdf.platform.sdk; + +import java.io.ByteArrayOutputStream; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +/** Hand-built stored zips whose central directory fields can be set to values no real writer produces. */ +final class HostileZips { + private HostileZips() { + } + + /** One archive entry, plus overrides for what its central directory header claims. */ + static final class Entry { + final String name; + final byte[] data; + byte[] centralExtra = new byte[0]; + Long compressedSize; + Long uncompressedSize; + Long localHeaderOffset; + Integer fileNameLength; + Integer extraFieldLength; + Integer fileCommentLength; + + Entry(String name, byte[] data) { + this.name = name; + this.data = data; + } + } + + /** + * A minimal TDF-shaped archive: a payload and a manifest. {@code hostile} adjusts the + * manifest entry, which is written last so its central directory header is the last one. + */ + static byte[] tdfWith(String manifestJson, Consumer hostile) { + var payload = new Entry("0.payload", "payload bytes".getBytes(StandardCharsets.UTF_8)); + var manifest = new Entry("0.manifest.json", manifestJson.getBytes(StandardCharsets.UTF_8)); + hostile.accept(manifest); + return build(List.of(payload, manifest)); + } + + /** The named hostile shapes, each applied to the manifest entry of {@link #tdfWith}. */ + static Map> hostileShapes() { + var shapes = new LinkedHashMap>(); + // 0xFFFC is -4 if read signed, which would rewind the walk to the start of the record + shapes.put("extra field data size 0xFFFC", e -> e.centralExtra = extraRecord(0xCAFE, 0xFFFC, new byte[4])); + shapes.put("extra field data size past the extra field", e -> e.centralExtra = extraRecord(0xCAFE, 16, new byte[4])); + shapes.put("zip64 local header offset 0xFFFFFFFFFFFFFFFF", e -> { + e.localHeaderOffset = 0xFFFFFFFFL; + e.centralExtra = extraRecord(0x0001, 8, longs(-1L)); + }); + shapes.put("zip64 uncompressed size 0xFFFFFFFFFFFFFFFF", e -> { + e.uncompressedSize = 0xFFFFFFFFL; + e.centralExtra = extraRecord(0x0001, 8, longs(-1L)); + }); + shapes.put("zip64 compressed size Long.MIN_VALUE", e -> { + e.compressedSize = 0xFFFFFFFFL; + e.centralExtra = extraRecord(0x0001, 8, longs(Long.MIN_VALUE)); + }); + // both sizes use the sentinel, but the zip64 record holds only one value. the two empty + // records after it read as a compressed size of 16, which fits, so only the record bound + // rejects this + shapes.put("zip64 extra field too short for its sentinels", e -> { + e.uncompressedSize = 0xFFFFFFFFL; + e.compressedSize = 0xFFFFFFFFL; + e.centralExtra = concat(extraRecord(0x0001, 8, longs(e.data.length)), + extraRecord(0x0010, 0, new byte[0]), extraRecord(0x0000, 0, new byte[0])); + }); + shapes.put("file name length 0xFFFF", e -> e.fileNameLength = 0xFFFF); + shapes.put("extra field length 0xFFFF", e -> e.extraFieldLength = 0xFFFF); + shapes.put("file comment length 0xFFFF", e -> e.fileCommentLength = 0xFFFF); + shapes.put("local header offset 0xFFFFFFF0", e -> e.localHeaderOffset = 0xFFFFFFF0L); + shapes.put("compressed size 0x7FFFFFFF", e -> e.compressedSize = 0x7FFFFFFFL); + return shapes; + } + + static byte[] extraRecord(int headerId, int dataSize, byte[] data) { + var buf = ByteBuffer.allocate(4 + data.length).order(ByteOrder.LITTLE_ENDIAN); + buf.putShort((short) headerId).putShort((short) dataSize).put(data); + return buf.array(); + } + + static byte[] longs(long... values) { + var buf = ByteBuffer.allocate(values.length * Long.BYTES).order(ByteOrder.LITTLE_ENDIAN); + for (long v : values) { + buf.putLong(v); + } + return buf.array(); + } + + static byte[] concat(byte[]... parts) { + var out = new ByteArrayOutputStream(); + for (var p : parts) { + out.writeBytes(p); + } + return out.toByteArray(); + } + + static byte[] build(List entries) { + var out = new ByteArrayOutputStream(); + var offsets = new ArrayList(); + for (var e : entries) { + offsets.add((long) out.size()); + var name = e.name.getBytes(StandardCharsets.UTF_8); + var local = ByteBuffer.allocate(30 + name.length).order(ByteOrder.LITTLE_ENDIAN); + local.putInt(0x04034b50) + .putShort((short) 20) // version needed + .putShort((short) 0) // flags + .putShort((short) 0) // stored + .putShort((short) 0).putShort((short) 0) // time, date + .putInt(0) // crc, unchecked by the reader + .putInt(e.data.length).putInt(e.data.length) + .putShort((short) name.length).putShort((short) 0) + .put(name); + out.writeBytes(local.array()); + out.writeBytes(e.data); + } + + long centralDirectoryStart = out.size(); + for (int i = 0; i < entries.size(); i++) { + var e = entries.get(i); + var name = e.name.getBytes(StandardCharsets.UTF_8); + var central = ByteBuffer.allocate(46 + name.length + e.centralExtra.length).order(ByteOrder.LITTLE_ENDIAN); + central.putInt(0x02014b50) + .putShort((short) 45).putShort((short) 45) // version made by, needed + .putShort((short) 0).putShort((short) 0) // flags, stored + .putShort((short) 0).putShort((short) 0) // time, date + .putInt(0) // crc + .putInt((int) orElse(e.compressedSize, e.data.length)) + .putInt((int) orElse(e.uncompressedSize, e.data.length)) + .putShort((short) orElse(e.fileNameLength, name.length)) + .putShort((short) orElse(e.extraFieldLength, e.centralExtra.length)) + .putShort((short) orElse(e.fileCommentLength, 0)) + .putShort((short) 0) // disk number start + .putShort((short) 0) // internal attributes + .putInt(0) // external attributes + .putInt((int) orElse(e.localHeaderOffset, offsets.get(i))) + .put(name) + .put(e.centralExtra); + out.writeBytes(central.array()); + } + long centralDirectorySize = out.size() - centralDirectoryStart; + + var eocd = ByteBuffer.allocate(22).order(ByteOrder.LITTLE_ENDIAN); + eocd.putInt(0x06054b50) + .putShort((short) 0).putShort((short) 0) + .putShort((short) entries.size()).putShort((short) entries.size()) + .putInt((int) centralDirectorySize) + .putInt((int) centralDirectoryStart) + .putShort((short) 0); + out.writeBytes(eocd.array()); + return out.toByteArray(); + } + + private static long orElse(Number override, long dflt) { + return override == null ? dflt : override.longValue(); + } +} diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/ManifestTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/ManifestTest.java index c49a709df..eafd1fd7f 100644 --- a/sdk/src/test/java/io/opentdf/platform/sdk/ManifestTest.java +++ b/sdk/src/test/java/io/opentdf/platform/sdk/ManifestTest.java @@ -1,6 +1,9 @@ package io.opentdf.platform.sdk; import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonNull; +import com.google.gson.JsonObject; import com.google.gson.JsonParseException; import com.google.gson.JsonParser; import org.junit.jupiter.api.Test; @@ -14,6 +17,7 @@ import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.stream.Stream; import static org.assertj.core.api.Assertions.assertThat; @@ -331,12 +335,7 @@ public void close() { @Test void testReadingManifestWithObjectStatementValue() throws IOException { - final Manifest manifest; - try (var mStream = getClass().getResourceAsStream("/io.opentdf.platform.sdk.TestData/manifest-with-object-statement-value.json")) { - assert mStream != null; - var manifestJson = new String(mStream.readAllBytes(), StandardCharsets.UTF_8); - manifest = Manifest.readManifest(manifestJson); - } + var manifest = Manifest.readManifest(manifestWithObjectStatementValue()); assertThat(manifest.assertions).hasSize(2); @@ -476,4 +475,42 @@ static Stream nonAlignedPlacements() { Arguments.of("root", "", ",\"tdf_spec_version\":\"4.3.0\""), Arguments.of("payload", ",\"tdf_spec_version\":\"4.3.0\"", "")); } + + private String manifestWithObjectStatementValue() throws IOException { + try (var mStream = Objects.requireNonNull(getClass().getResourceAsStream( + "/io.opentdf.platform.sdk.TestData/manifest-with-object-statement-value.json"))) { + return new String(mStream.readAllBytes(), StandardCharsets.UTF_8); + } + } + + /** The first assertion's statement with {@code member} replaced, keeping explicit nulls. */ + private String withStatementMember(String member, com.google.gson.JsonElement replacement) throws IOException { + var root = JsonParser.parseString(manifestWithObjectStatementValue()).getAsJsonObject(); + var statement = root.getAsJsonArray("assertions").get(0).getAsJsonObject().getAsJsonObject("statement"); + statement.add(member, replacement); + return new GsonBuilder().serializeNulls().create().toJson(root); + } + + /** An explicit JSON null in a statement field reads as absent, not as the string "null". */ + @Test + void testNullStatementFieldsReadAsNull() throws IOException { + for (var member : List.of("format", "schema", "value")) { + var manifest = Manifest.readManifest(withStatementMember(member, JsonNull.INSTANCE)); + var statement = manifest.assertions.get(0).statement; + var actual = member.equals("format") ? statement.format + : member.equals("schema") ? statement.schema : statement.value; + assertThat(actual).as(member).isNull(); + } + } + + @Test + void testNonStringStatementFormatIsAParseError() throws IOException { + for (var member : List.of("format", "schema")) { + var json = withStatementMember(member, new JsonObject()); + assertThatThrownBy(() -> Manifest.readManifest(json)) + .as(member) + .isInstanceOf(JsonParseException.class) + .hasMessageContaining(member); + } + } } diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/SDKTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/SDKTest.java index bd099a001..138bd5358 100644 --- a/sdk/src/test/java/io/opentdf/platform/sdk/SDKTest.java +++ b/sdk/src/test/java/io/opentdf/platform/sdk/SDKTest.java @@ -15,17 +15,20 @@ import java.nio.ByteBuffer; import java.nio.channels.SeekableByteChannel; import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Objects; import java.util.Random; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively; import static org.mockito.Mockito.mock; class SDKTest { @Test void testExaminingValidZTDF() throws IOException { - try (var ztdf = SDKTest.class.getClassLoader().getResourceAsStream("sample.txt.tdf")) { - assert ztdf != null; + try (var ztdf = Objects.requireNonNull(SDKTest.class.getClassLoader().getResourceAsStream("sample.txt.tdf"))) { var chan = new SeekableInMemoryByteChannel(ztdf.readAllBytes()); assertThat(SDK.isTDF(chan)).isTrue(); } @@ -108,6 +111,45 @@ void testExaminingZipThatListsAnUnrelatedNameTwice() throws IOException { } } + @Test + void testExaminingHostileArchivesReturnsFalse() { + HostileZips.hostileShapes().forEach((shape, hostile) -> { + var archive = HostileZips.tdfWith("{}", hostile); + boolean result = assertTimeoutPreemptively(Duration.ofSeconds(5), + () -> SDK.isTDF(new SeekableInMemoryByteChannel(archive)), shape); + assertThat(result).as(shape).isFalse(); + }); + } + + /** The unmodified hand-built archive those shapes start from is a TDF. */ + @Test + void testExaminingHandBuiltTDF() { + assertThat(SDK.isTDF(new SeekableInMemoryByteChannel(HostileZips.tdfWith("{}", e -> { })))).isTrue(); + } + + @Test + void testExaminingAChannelThatThrowsUncheckedReturnsFalse() { + var channel = new SeekableInMemoryByteChannel(HostileZips.tdfWith("{}", e -> { })) { + @Override + public int read(ByteBuffer buf) { + throw new IllegalStateException("broken channel"); + } + }; + assertThat(SDK.isTDF(channel)).isFalse(); + } + + @Test + void testReadingTheManifestOfHostileArchivesThrowsMalformedTDF() { + HostileZips.hostileShapes().forEach((shape, hostile) -> { + var archive = HostileZips.tdfWith("{}", hostile); + assertTimeoutPreemptively(Duration.ofSeconds(5), () -> + assertThatThrownBy(() -> SDK.readManifest(new SeekableInMemoryByteChannel(archive))) + .as(shape) + .isInstanceOf(SDK.MalformedTDFException.class), + shape); + }); + } + /** Builds a zip holding the named entries; contents are irrelevant to {@link SDK#isTDF}. */ private static SeekableInMemoryByteChannel zipOf(String... names) throws IOException { var out = new ByteArrayOutputStream(); 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 43f2a02dc..b366566dd 100644 --- a/sdk/src/test/java/io/opentdf/platform/sdk/TDFReaderTest.java +++ b/sdk/src/test/java/io/opentdf/platform/sdk/TDFReaderTest.java @@ -10,6 +10,7 @@ import java.io.Reader; import java.io.StringWriter; import java.nio.charset.StandardCharsets; +import java.util.List; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -102,8 +103,8 @@ void rejectsEmptyManifest(String manifest) throws IOException { var readerConfig = Config.newTDFReaderConfig(); assertThatThrownBy(() -> tdf.loadTDF(tdfBytes, readerConfig)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("Manifest is empty"); + .isInstanceOf(SDK.MalformedTDFException.class) + .hasMessage("tdf manifest is invalid: Manifest is empty"); } } @@ -177,6 +178,24 @@ void rejectsAnArchiveThatListsTheManifestNameTwice() throws IOException { } } + /** A well formed zip that is not a TDF: no manifest, no payload, or a name listed twice. */ + @Test + void openReportsAZipThatIsNotATDFAsMalformed() throws IOException { + var notTDFs = List.of( + new String[] { TDFWriter.TDF_PAYLOAD_FILE_NAME, PAYLOAD }, + new String[] { "manifest.json", MANIFEST }, + new String[] { TDFWriter.TDF_PAYLOAD_FILE_NAME, PAYLOAD, + "manifest.json", MANIFEST, "manifest.json", MANIFEST }); + for (var entries : notTDFs) { + try (var tdf = archiveOf(entries)) { + assertThatThrownBy(() -> TDFReader.open(tdf)) + .as(String.join(", ", entries)) + .isInstanceOf(SDK.MalformedTDFException.class) + .hasCauseInstanceOf(IllegalArgumentException.class); + } + } + } + /** The buffer is one byte longer than the payload, so a short read would show up. */ @Test void readsThePayloadAlongsideASpecNamedManifest() throws IOException { @@ -191,4 +210,19 @@ void readsThePayloadAlongsideASpecNamedManifest() throws IOException { assertThat(buf).startsWith(expected); } } + + /** An oversized payload is only detected on read, and still surfaces as a malformed TDF. */ + @Test + void reportsATruncatedPayloadAsMalformed() throws IOException { + var payload = new HostileZips.Entry(TDFWriter.TDF_PAYLOAD_FILE_NAME, PAYLOAD.getBytes(StandardCharsets.UTF_8)); + payload.uncompressedSize = 1_000_000L; + var manifest = new HostileZips.Entry("0.manifest.json", MANIFEST.getBytes(StandardCharsets.UTF_8)); + try (var tdf = new SeekableInMemoryByteChannel(HostileZips.build(List.of(payload, manifest)))) { + var reader = new TDFReader(tdf); + + assertThatThrownBy(() -> reader.readPayloadBytes(new byte[1_000_000])) + .isInstanceOf(SDK.MalformedTDFException.class) + .hasCauseInstanceOf(InvalidZipException.class); + } + } } 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 aa5b38e6b..0ddb0b9d5 100644 --- a/sdk/src/test/java/io/opentdf/platform/sdk/TDFTest.java +++ b/sdk/src/test/java/io/opentdf/platform/sdk/TDFTest.java @@ -24,8 +24,10 @@ import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; +import java.io.StringWriter; import java.nio.charset.StandardCharsets; import java.security.KeyPair; +import java.time.Duration; import java.security.cert.X509Certificate; import java.util.ArrayList; import java.util.Arrays; @@ -45,6 +47,7 @@ import static io.opentdf.platform.sdk.TDF.GLOBAL_KEY_SALT; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -1502,6 +1505,66 @@ private static Config.KASInfo[] getSingleRSAKASInfo() { return getKASInfos(i -> i == 0); } + @Test + void loadTDFReportsHostileArchivesAsMalformed() { + HostileZips.hostileShapes().forEach((shape, hostile) -> + assertLoadTDFFailsAsMalformed(shape, HostileZips.tdfWith("{}", hostile))); + } + + /** + * A size past the end of the archive is malformed, not read short: an oversized uncompressed + * size fails when the manifest is read, an oversized compressed size when the archive is opened. + */ + @Test + void loadTDFReportsAManifestRunningPastTheArchiveAsMalformed() { + // the manifest opens a JSON comment, so the parser reads on to the end of the archive + // looking for its close, rather than failing at the first byte past the entry + var uncompressedPastTheEnd = HostileZips.tdfWith("/*", e -> e.uncompressedSize = 0x7FFFFFFFL); + assertThatThrownBy(() -> loadTDFFrom(uncompressedPastTheEnd)) + .isInstanceOf(SDK.MalformedTDFException.class) + .hasCauseInstanceOf(InvalidZipException.class) + .hasMessageContaining("Archive ended"); + + var storedPastTheEnd = HostileZips.tdfWith("{}", e -> e.compressedSize = 0x7FFFFFFFL); + assertThatThrownBy(() -> loadTDFFrom(storedPastTheEnd)) + .isInstanceOf(SDK.MalformedTDFException.class) + .hasCauseInstanceOf(InvalidZipException.class); + } + + /** Guards against reintroducing a fixed manifest size cap. */ + @Test + void readsAManifestLargerThanTenMebibytes() throws IOException { + var padding = "a".repeat(11 * 1024 * 1024); + var manifest = "{\"padding\":\"" + padding + "\"}"; + var archive = HostileZips.tdfWith(manifest, e -> { }); + var read = new StringWriter(); + try (var reader = TDFReader.open(new SeekableInMemoryByteChannel(archive)).manifest()) { + reader.transferTo(read); + } + assertThat(read).hasToString(manifest); + } + + @Test + void loadTDFReportsAnInvalidManifestAsMalformed() { + var assertionWithObjectFormat = "{\"payload\":{},\"assertions\":[{\"statement\":{\"format\":{}}}]}"; + for (var manifest : List.of("not json", "[]", "{\"payload\": 5}", "{}", assertionWithObjectFormat)) { + assertLoadTDFFailsAsMalformed(manifest, HostileZips.tdfWith(manifest, e -> { })); + } + } + + private static void assertLoadTDFFailsAsMalformed(String description, byte[] archive) { + assertTimeoutPreemptively(Duration.ofSeconds(5), () -> + assertThatThrownBy(() -> loadTDFFrom(archive)) + .as(description) + .isInstanceOf(SDK.MalformedTDFException.class), + description); + } + + private static void loadTDFFrom(byte[] archive) throws IOException { + new TDF(new FakeServicesBuilder().setKas(kas).build()) + .loadTDF(new SeekableInMemoryByteChannel(archive), Config.newTDFReaderConfig()); + } + private static boolean isHexChar(byte b) { return (b >= 'a' && b <= 'f') || (b >= '0' && b <= '9'); } diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/ZipReaderTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/ZipReaderTest.java index 13289d500..7cc188b53 100644 --- a/sdk/src/test/java/io/opentdf/platform/sdk/ZipReaderTest.java +++ b/sdk/src/test/java/io/opentdf/platform/sdk/ZipReaderTest.java @@ -761,6 +761,114 @@ public long size() { } } + /** Every hostile shape fails with {@link InvalidZipException}, without hanging. */ + @Test + public void testHostileCentralDirectoryFieldsAreRejected() { + HostileZips.hostileShapes().forEach((shape, hostile) -> { + var archive = HostileZips.tdfWith(MANIFEST, hostile); + try (var channel = new SeekableInMemoryByteChannel(archive)) { + // reading every entry, not just the directory, so a shape that only fails once its + // data is read is caught too + assertTimeoutPreemptively(Duration.ofSeconds(5), () -> + assertThatThrownBy(() -> testReadingZipChannel(channel, false)) + .as(shape).isInstanceOf(InvalidZipException.class), + shape); + } + }); + } + + /** + * The last central directory header claims a filename longer than the one it holds, so the + * name runs into the end of central directory record. Checked only against the end of the + * archive, that record's bytes would come back as part of the entry name. + */ + @Test + public void testFilenameRunningPastTheCentralDirectoryIsRejected() { + var archive = HostileZips.tdfWith(MANIFEST, e -> e.fileNameLength = e.name.length() + 10); + + assertThatThrownBy(() -> readArchive(archive)) + .isInstanceOf(InvalidZipException.class) + .hasMessageContaining("past the end of the central directory"); + } + + /** + * A central directory whose declared size runs into the record after it, in both a plain + * archive and a zip64 one. With no other candidate record to fall back on, the scan reports + * why it passed over the real one. + */ + @Test + public void testCentralDirectorySizeRunningIntoTheNextRecordIsRejected() throws IOException { + var plain = plainArchive(); + var plainBuf = ByteBuffer.wrap(plain).order(ByteOrder.LITTLE_ENDIAN); + int sizeField = plain.length - EOCD_SIZE + 12; + plainBuf.putInt(sizeField, plainBuf.getInt(sizeField) + 1); + + var zip64 = zip64Archive(); + int zip64Eocd = zip64.length - (EOCD_SIZE + ZIP64_EOCD_LOCATOR_SIZE + ZIP64_EOCD_SIZE); + var zip64Buf = ByteBuffer.wrap(zip64).order(ByteOrder.LITTLE_ENDIAN); + assertThat(zip64Buf.getInt(zip64Eocd)).isEqualTo(ZIP64_EOCD_SIGNATURE); + zip64Buf.putLong(zip64Eocd + 40, zip64Buf.getLong(zip64Eocd + 40) + 1); + + for (var archive : List.of(plain, zip64)) { + assertThatThrownBy(() -> readArchive(archive)) + .isInstanceOf(InvalidZipException.class) + .hasMessageContaining("which does not end where that record starts"); + } + } + + /** The hand-built archives the hostile shapes start from are valid until a shape is applied. */ + @Test + public void testHandBuiltArchiveReads() throws IOException { + var archive = HostileZips.tdfWith(MANIFEST, e -> { }); + try (var channel = new SeekableInMemoryByteChannel(archive)) { + var reader = new ZipReader(channel); + assertThat(readEntry(reader, "0.manifest.json")).isEqualTo(MANIFEST); + assertThat(readEntry(reader, "0.payload")).isEqualTo("payload bytes"); + } + } + + /** + * The local header of an empty entry claims a filename long enough to put its data past the + * end of the archive. The central directory can't see that length, and an empty entry is + * never read, so without a check on the local header it would read as empty. + */ + @Test + public void testEmptyEntryWhoseDataStartsPastTheEndIsRejected() throws IOException { + var archive = HostileZips.build(List.of(new HostileZips.Entry("empty", new byte[0]))); + // the filename length of the first local file header, which starts at offset 0 + ByteBuffer.wrap(archive).order(ByteOrder.LITTLE_ENDIAN).putShort(26, (short) 0xFFFF); + + try (var channel = new SeekableInMemoryByteChannel(archive)) { + var entry = new ZipReader(channel).getEntries().get(0); + assertThatThrownBy(entry::getData) + .isInstanceOf(InvalidZipException.class) + .hasMessageContaining("past the end of this"); + } + } + + /** + * A zip64 record whose values are all within range is still honoured, after an unrelated + * record and followed by a few bytes too short to be a record, which readers ignore. + */ + @Test + public void testZip64ExtraFieldAmongOtherRecordsStillReads() throws IOException { + var archive = HostileZips.tdfWith(MANIFEST, e -> { + e.uncompressedSize = 0xFFFFFFFFL; + e.compressedSize = 0xFFFFFFFFL; + e.localHeaderOffset = 0xFFFFFFFFL; + long offset = "0.payload".length() + 30 + "payload bytes".length(); + e.centralExtra = HostileZips.concat( + HostileZips.extraRecord(0xCAFE, 2, new byte[2]), + HostileZips.extraRecord(0x0001, 24, HostileZips.longs(e.data.length, e.data.length, offset)), + new byte[3]); + }); + try (var channel = new SeekableInMemoryByteChannel(archive)) { + var reader = new ZipReader(channel); + assertThat(readEntry(reader, "0.manifest.json")).isEqualTo(MANIFEST); + assertThat(readEntry(reader, "0.payload")).isEqualTo("payload bytes"); + } + } + /** The same three entries as {@link #zip64Archive()}, written without anything zip64. */ private static byte[] plainArchive() throws IOException { var out = new ByteArrayOutputStream(); diff --git a/sdk/src/test/resources/io/opentdf/platform/sdk/FuzzingInputs/fuzzTDF/dspx5103-compressed-size-0x7fffffff b/sdk/src/test/resources/io/opentdf/platform/sdk/FuzzingInputs/fuzzTDF/dspx5103-compressed-size-0x7fffffff new file mode 100644 index 000000000..884c36025 Binary files /dev/null and b/sdk/src/test/resources/io/opentdf/platform/sdk/FuzzingInputs/fuzzTDF/dspx5103-compressed-size-0x7fffffff differ diff --git a/sdk/src/test/resources/io/opentdf/platform/sdk/FuzzingInputs/fuzzTDF/dspx5103-extra-field-data-size-0xfffc b/sdk/src/test/resources/io/opentdf/platform/sdk/FuzzingInputs/fuzzTDF/dspx5103-extra-field-data-size-0xfffc new file mode 100644 index 000000000..c3160aec4 Binary files /dev/null and b/sdk/src/test/resources/io/opentdf/platform/sdk/FuzzingInputs/fuzzTDF/dspx5103-extra-field-data-size-0xfffc differ diff --git a/sdk/src/test/resources/io/opentdf/platform/sdk/FuzzingInputs/fuzzTDF/dspx5103-extra-field-data-size-past-the-extra-field b/sdk/src/test/resources/io/opentdf/platform/sdk/FuzzingInputs/fuzzTDF/dspx5103-extra-field-data-size-past-the-extra-field new file mode 100644 index 000000000..419f5c3f2 Binary files /dev/null and b/sdk/src/test/resources/io/opentdf/platform/sdk/FuzzingInputs/fuzzTDF/dspx5103-extra-field-data-size-past-the-extra-field differ diff --git a/sdk/src/test/resources/io/opentdf/platform/sdk/FuzzingInputs/fuzzTDF/dspx5103-extra-field-length-0xffff b/sdk/src/test/resources/io/opentdf/platform/sdk/FuzzingInputs/fuzzTDF/dspx5103-extra-field-length-0xffff new file mode 100644 index 000000000..aca32c908 Binary files /dev/null and b/sdk/src/test/resources/io/opentdf/platform/sdk/FuzzingInputs/fuzzTDF/dspx5103-extra-field-length-0xffff differ diff --git a/sdk/src/test/resources/io/opentdf/platform/sdk/FuzzingInputs/fuzzTDF/dspx5103-file-comment-length-0xffff b/sdk/src/test/resources/io/opentdf/platform/sdk/FuzzingInputs/fuzzTDF/dspx5103-file-comment-length-0xffff new file mode 100644 index 000000000..67aee97f0 Binary files /dev/null and b/sdk/src/test/resources/io/opentdf/platform/sdk/FuzzingInputs/fuzzTDF/dspx5103-file-comment-length-0xffff differ diff --git a/sdk/src/test/resources/io/opentdf/platform/sdk/FuzzingInputs/fuzzTDF/dspx5103-file-name-length-0xffff b/sdk/src/test/resources/io/opentdf/platform/sdk/FuzzingInputs/fuzzTDF/dspx5103-file-name-length-0xffff new file mode 100644 index 000000000..39089bdf9 Binary files /dev/null and b/sdk/src/test/resources/io/opentdf/platform/sdk/FuzzingInputs/fuzzTDF/dspx5103-file-name-length-0xffff differ diff --git a/sdk/src/test/resources/io/opentdf/platform/sdk/FuzzingInputs/fuzzTDF/dspx5103-local-header-offset-0xfffffff0 b/sdk/src/test/resources/io/opentdf/platform/sdk/FuzzingInputs/fuzzTDF/dspx5103-local-header-offset-0xfffffff0 new file mode 100644 index 000000000..dfdcf67c3 Binary files /dev/null and b/sdk/src/test/resources/io/opentdf/platform/sdk/FuzzingInputs/fuzzTDF/dspx5103-local-header-offset-0xfffffff0 differ diff --git a/sdk/src/test/resources/io/opentdf/platform/sdk/FuzzingInputs/fuzzTDF/dspx5103-zip64-compressed-size-long-min-value b/sdk/src/test/resources/io/opentdf/platform/sdk/FuzzingInputs/fuzzTDF/dspx5103-zip64-compressed-size-long-min-value new file mode 100644 index 000000000..21f049c32 Binary files /dev/null and b/sdk/src/test/resources/io/opentdf/platform/sdk/FuzzingInputs/fuzzTDF/dspx5103-zip64-compressed-size-long-min-value differ diff --git a/sdk/src/test/resources/io/opentdf/platform/sdk/FuzzingInputs/fuzzTDF/dspx5103-zip64-extra-field-too-short-for-its-sentinels b/sdk/src/test/resources/io/opentdf/platform/sdk/FuzzingInputs/fuzzTDF/dspx5103-zip64-extra-field-too-short-for-its-sentinels new file mode 100644 index 000000000..5e96973ac Binary files /dev/null and b/sdk/src/test/resources/io/opentdf/platform/sdk/FuzzingInputs/fuzzTDF/dspx5103-zip64-extra-field-too-short-for-its-sentinels differ diff --git a/sdk/src/test/resources/io/opentdf/platform/sdk/FuzzingInputs/fuzzTDF/dspx5103-zip64-local-header-offset-0xffffffffffffffff b/sdk/src/test/resources/io/opentdf/platform/sdk/FuzzingInputs/fuzzTDF/dspx5103-zip64-local-header-offset-0xffffffffffffffff new file mode 100644 index 000000000..985640f54 Binary files /dev/null and b/sdk/src/test/resources/io/opentdf/platform/sdk/FuzzingInputs/fuzzTDF/dspx5103-zip64-local-header-offset-0xffffffffffffffff differ diff --git a/sdk/src/test/resources/io/opentdf/platform/sdk/FuzzingInputs/fuzzTDF/dspx5103-zip64-uncompressed-size-0xffffffffffffffff b/sdk/src/test/resources/io/opentdf/platform/sdk/FuzzingInputs/fuzzTDF/dspx5103-zip64-uncompressed-size-0xffffffffffffffff new file mode 100644 index 000000000..0fca8af9a Binary files /dev/null and b/sdk/src/test/resources/io/opentdf/platform/sdk/FuzzingInputs/fuzzTDF/dspx5103-zip64-uncompressed-size-0xffffffffffffffff differ diff --git a/sdk/src/test/resources/io/opentdf/platform/sdk/FuzzingInputs/fuzzZipRead/dspx5103-compressed-size-0x7fffffff b/sdk/src/test/resources/io/opentdf/platform/sdk/FuzzingInputs/fuzzZipRead/dspx5103-compressed-size-0x7fffffff new file mode 100644 index 000000000..884c36025 Binary files /dev/null and b/sdk/src/test/resources/io/opentdf/platform/sdk/FuzzingInputs/fuzzZipRead/dspx5103-compressed-size-0x7fffffff differ diff --git a/sdk/src/test/resources/io/opentdf/platform/sdk/FuzzingInputs/fuzzZipRead/dspx5103-extra-field-data-size-0xfffc b/sdk/src/test/resources/io/opentdf/platform/sdk/FuzzingInputs/fuzzZipRead/dspx5103-extra-field-data-size-0xfffc new file mode 100644 index 000000000..c3160aec4 Binary files /dev/null and b/sdk/src/test/resources/io/opentdf/platform/sdk/FuzzingInputs/fuzzZipRead/dspx5103-extra-field-data-size-0xfffc differ diff --git a/sdk/src/test/resources/io/opentdf/platform/sdk/FuzzingInputs/fuzzZipRead/dspx5103-extra-field-data-size-past-the-extra-field b/sdk/src/test/resources/io/opentdf/platform/sdk/FuzzingInputs/fuzzZipRead/dspx5103-extra-field-data-size-past-the-extra-field new file mode 100644 index 000000000..419f5c3f2 Binary files /dev/null and b/sdk/src/test/resources/io/opentdf/platform/sdk/FuzzingInputs/fuzzZipRead/dspx5103-extra-field-data-size-past-the-extra-field differ diff --git a/sdk/src/test/resources/io/opentdf/platform/sdk/FuzzingInputs/fuzzZipRead/dspx5103-extra-field-length-0xffff b/sdk/src/test/resources/io/opentdf/platform/sdk/FuzzingInputs/fuzzZipRead/dspx5103-extra-field-length-0xffff new file mode 100644 index 000000000..aca32c908 Binary files /dev/null and b/sdk/src/test/resources/io/opentdf/platform/sdk/FuzzingInputs/fuzzZipRead/dspx5103-extra-field-length-0xffff differ diff --git a/sdk/src/test/resources/io/opentdf/platform/sdk/FuzzingInputs/fuzzZipRead/dspx5103-file-comment-length-0xffff b/sdk/src/test/resources/io/opentdf/platform/sdk/FuzzingInputs/fuzzZipRead/dspx5103-file-comment-length-0xffff new file mode 100644 index 000000000..67aee97f0 Binary files /dev/null and b/sdk/src/test/resources/io/opentdf/platform/sdk/FuzzingInputs/fuzzZipRead/dspx5103-file-comment-length-0xffff differ diff --git a/sdk/src/test/resources/io/opentdf/platform/sdk/FuzzingInputs/fuzzZipRead/dspx5103-file-name-length-0xffff b/sdk/src/test/resources/io/opentdf/platform/sdk/FuzzingInputs/fuzzZipRead/dspx5103-file-name-length-0xffff new file mode 100644 index 000000000..39089bdf9 Binary files /dev/null and b/sdk/src/test/resources/io/opentdf/platform/sdk/FuzzingInputs/fuzzZipRead/dspx5103-file-name-length-0xffff differ diff --git a/sdk/src/test/resources/io/opentdf/platform/sdk/FuzzingInputs/fuzzZipRead/dspx5103-local-header-offset-0xfffffff0 b/sdk/src/test/resources/io/opentdf/platform/sdk/FuzzingInputs/fuzzZipRead/dspx5103-local-header-offset-0xfffffff0 new file mode 100644 index 000000000..dfdcf67c3 Binary files /dev/null and b/sdk/src/test/resources/io/opentdf/platform/sdk/FuzzingInputs/fuzzZipRead/dspx5103-local-header-offset-0xfffffff0 differ diff --git a/sdk/src/test/resources/io/opentdf/platform/sdk/FuzzingInputs/fuzzZipRead/dspx5103-zip64-compressed-size-long-min-value b/sdk/src/test/resources/io/opentdf/platform/sdk/FuzzingInputs/fuzzZipRead/dspx5103-zip64-compressed-size-long-min-value new file mode 100644 index 000000000..21f049c32 Binary files /dev/null and b/sdk/src/test/resources/io/opentdf/platform/sdk/FuzzingInputs/fuzzZipRead/dspx5103-zip64-compressed-size-long-min-value differ diff --git a/sdk/src/test/resources/io/opentdf/platform/sdk/FuzzingInputs/fuzzZipRead/dspx5103-zip64-extra-field-too-short-for-its-sentinels b/sdk/src/test/resources/io/opentdf/platform/sdk/FuzzingInputs/fuzzZipRead/dspx5103-zip64-extra-field-too-short-for-its-sentinels new file mode 100644 index 000000000..5e96973ac Binary files /dev/null and b/sdk/src/test/resources/io/opentdf/platform/sdk/FuzzingInputs/fuzzZipRead/dspx5103-zip64-extra-field-too-short-for-its-sentinels differ diff --git a/sdk/src/test/resources/io/opentdf/platform/sdk/FuzzingInputs/fuzzZipRead/dspx5103-zip64-local-header-offset-0xffffffffffffffff b/sdk/src/test/resources/io/opentdf/platform/sdk/FuzzingInputs/fuzzZipRead/dspx5103-zip64-local-header-offset-0xffffffffffffffff new file mode 100644 index 000000000..985640f54 Binary files /dev/null and b/sdk/src/test/resources/io/opentdf/platform/sdk/FuzzingInputs/fuzzZipRead/dspx5103-zip64-local-header-offset-0xffffffffffffffff differ diff --git a/sdk/src/test/resources/io/opentdf/platform/sdk/FuzzingInputs/fuzzZipRead/dspx5103-zip64-uncompressed-size-0xffffffffffffffff b/sdk/src/test/resources/io/opentdf/platform/sdk/FuzzingInputs/fuzzZipRead/dspx5103-zip64-uncompressed-size-0xffffffffffffffff new file mode 100644 index 000000000..0fca8af9a Binary files /dev/null and b/sdk/src/test/resources/io/opentdf/platform/sdk/FuzzingInputs/fuzzZipRead/dspx5103-zip64-uncompressed-size-0xffffffffffffffff differ