Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions sdk/src/main/java/io/opentdf/platform/sdk/Manifest.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,17 @@
import com.google.gson.JsonDeserializer;
import com.google.gson.JsonElement;
import com.google.gson.JsonParseException;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import com.google.gson.JsonSerializationContext;
import com.google.gson.JsonSerializer;
import com.google.gson.TypeAdapter;
import com.google.gson.TypeAdapterFactory;
import com.google.gson.annotations.JsonAdapter;
import com.google.gson.annotations.SerializedName;
import com.google.gson.reflect.TypeToken;
import com.google.gson.stream.JsonReader;
import com.google.gson.stream.JsonWriter;
import com.nimbusds.jose.JOSEException;
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.JWSHeader;
Expand Down Expand Up @@ -56,6 +63,7 @@ public class Manifest {

private static final Gson gson = new GsonBuilder()
.registerTypeAdapter(AssertionConfig.Statement.class, new AssertionValueAdapter())
.registerTypeAdapterFactory(new IntegrityInformationAdapterFactory())
.create();
@SerializedName(value = "schemaVersion")
String tdfVersion;
Expand Down Expand Up @@ -98,7 +106,17 @@ public JsonElement serialize(Object src, Type typeOfSrc, JsonSerializationContex

static public class Segment {
public String hash;
/**
* The plaintext length of this segment. Optional in the JSON: when a producer leaves it
* out it means {@link IntegrityInformation#segmentSizeDefault}, which
* {@link IntegrityInformationAdapterFactory} fills in during deserialization.
*/
public long segmentSize;
/**
* The on-the-wire length of this segment. Optional in the JSON the same way
* {@link #segmentSize} is, defaulting to
* {@link IntegrityInformation#encryptedSegmentSizeDefault}.
*/
public long encryptedSegmentSize;

@Override
Expand Down Expand Up @@ -167,6 +185,83 @@ public int hashCode() {
}
}

/**
* Applies {@code segmentSizeDefault} / {@code encryptedSegmentSizeDefault} to any segment
* that left the corresponding per-segment key out of its JSON.
* <p>
* The per-segment values are optional overrides: {@code manifest.schema.json} marks the two
* defaults required on {@code integrityInformation} but puts no {@code required} list on
* {@code segments/items}. web-sdk omits a per-segment size whenever it equals the default,
* which is every full segment of a payload larger than one segment. Gson leaves an absent
* key at {@code 0}, so before this the reader allocated a zero length buffer for those
* segments and failed inside the integrity check.
* <p>
* This runs as a post-deserialization fixup rather than by boxing the fields to {@code Long},
* which keeps {@link Segment#segmentSize} a primitive for callers, keeps the value/absence
* distinction out of the public API, and applies to every path that parses a manifest. A
* primitive alone cannot tell an absent key from a literal {@code 0}, so the decision is made
* against the parse tree rather than against the deserialized value.
*/
private static class IntegrityInformationAdapterFactory implements TypeAdapterFactory {
private static final String SEGMENTS = "segments";
private static final String SEGMENT_SIZE = "segmentSize";
private static final String ENCRYPTED_SEGMENT_SIZE = "encryptedSegmentSize";

@Override
public <T> TypeAdapter<T> create(Gson gson, TypeToken<T> type) {
if (!IntegrityInformation.class.equals(type.getRawType())) {
return null;
}
final TypeAdapter<T> delegate = gson.getDelegateAdapter(this, type);
final TypeAdapter<JsonElement> elementAdapter = gson.getAdapter(JsonElement.class);
return new TypeAdapter<T>() {
@Override
public void write(JsonWriter out, T value) throws IOException {
delegate.write(out, value);
}

@Override
public T read(JsonReader in) throws IOException {
JsonElement tree = elementAdapter.read(in);
T value = delegate.fromJsonTree(tree);
if (value instanceof IntegrityInformation && tree != null && tree.isJsonObject()) {
applySegmentSizeDefaults((IntegrityInformation) value, tree.getAsJsonObject());
}
return value;
}
};
}

private static void applySegmentSizeDefaults(IntegrityInformation integrityInformation, JsonObject json) {
List<Segment> segments = integrityInformation.segments;
JsonElement rawSegments = json.get(SEGMENTS);
if (segments == null || rawSegments == null || !rawSegments.isJsonArray()) {
return;
}
JsonArray rawSegmentArray = rawSegments.getAsJsonArray();
int count = Math.min(segments.size(), rawSegmentArray.size());
for (int i = 0; i < count; i++) {
Segment segment = segments.get(i);
JsonElement rawSegment = rawSegmentArray.get(i);
if (segment == null || rawSegment == null || !rawSegment.isJsonObject()) {
continue;
}
JsonObject rawSegmentObject = rawSegment.getAsJsonObject();
if (!hasValue(rawSegmentObject, SEGMENT_SIZE)) {
segment.segmentSize = integrityInformation.segmentSizeDefault;
}
if (!hasValue(rawSegmentObject, ENCRYPTED_SEGMENT_SIZE)) {
segment.encryptedSegmentSize = integrityInformation.encryptedSegmentSizeDefault;
}
}
}

private static boolean hasValue(JsonObject object, String memberName) {
JsonElement member = object.get(memberName);
return member != null && !member.isJsonNull();
}
}

static public class PolicyBinding {
public String alg;
public String hash;
Expand Down
13 changes: 13 additions & 0 deletions sdk/src/main/java/io/opentdf/platform/sdk/TDF.java
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,19 @@ public void readPayload(OutputStream outputStream) throws SDK.SegmentSignatureMi
}

for (Manifest.Segment segment : manifest.encryptionInformation.integrityInformation.segments) {
if (segment.encryptedSegmentSize <= 0) {
// an encrypted segment always carries at least an IV and a tag, so this only
// happens on a manifest that supplied neither a per-segment
// encryptedSegmentSize nor a usable encryptedSegmentSizeDefault. reported
// here rather than letting a zero length buffer reach the integrity check,
// where it surfaces as an unrelated complaint about the payload being too
// small to GMAC
throw new IllegalStateException("invalid TDF: segment has an encrypted size of "
+ segment.encryptedSegmentSize
+ ". the manifest supplied neither a per-segment encryptedSegmentSize"
+ " nor a usable encryptedSegmentSizeDefault");
}

if (segment.encryptedSegmentSize > Config.MAX_SEGMENT_SIZE) {
throw new IllegalStateException("Segment size " + segment.encryptedSegmentSize + " exceeded limit "
+ Config.MAX_SEGMENT_SIZE);
Expand Down
14 changes: 12 additions & 2 deletions sdk/src/main/java/io/opentdf/platform/sdk/ZipReader.java
Original file line number Diff line number Diff line change
Expand Up @@ -105,19 +105,25 @@ public CentralDirectoryRecord(long numEntries, long offsetToStart) {
CentralDirectoryRecord readEndOfCentralDirectory() throws IOException {
long eoCDRStart = zipChannel.size() - END_OF_CENTRAL_DIRECTORY_SIZE; // 22 is the minimum size of the EOCDR

boolean found = false;
while (eoCDRStart >= 0) {
zipChannel.position(eoCDRStart);
Integer signature = readInteger();
if (signature == null || signature == END_OF_CENTRAL_DIRECTORY_SIGNATURE) {
// a short read means there aren't four bytes here to compare against, which is not
// the same thing as having found the signature. treating it as a match let a
// truncated archive fall out of this loop and parse whatever followed as an end of
// central directory record
if (signature != null && signature == END_OF_CENTRAL_DIRECTORY_SIGNATURE) {
if (logger.isDebugEnabled()) {
logger.debug("Found end of central directory signature at {}", zipChannel.position() - Integer.BYTES);
}
found = true;
break;
}
eoCDRStart--;
}

if (eoCDRStart < 0) {
if (!found) {
throw new InvalidZipException("Didn't find the end of central directory");
}

Expand All @@ -141,6 +147,10 @@ CentralDirectoryRecord readEndOfCentralDirectory() throws IOException {
}

long zip64CentralDirectoryLocatorStart = zipChannel.size() - (ZIP64_END_OF_CENTRAL_DIRECTORY_LOCATOR_SIZE + END_OF_CENTRAL_DIRECTORY_SIZE + commentLength);
if (zip64CentralDirectoryLocatorStart < 0) {
throw new InvalidZipException(
"Archive is too small to hold the zip64 end of central directory locator it claims to have");
}
zipChannel.position(zip64CentralDirectoryLocatorStart);
return extractZIP64CentralDirectoryInfo();
}
Expand Down
54 changes: 41 additions & 13 deletions sdk/src/main/java/io/opentdf/platform/sdk/ZipWriter.java
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,17 @@ public class ZipWriter {
* {@link ZipReader}.
*/
static final long MAX_NON_ZIP64_VALUE = Integer.MAX_VALUE;

/**
* The largest entry count we will write into the 2-byte end of central directory field.
* {@code 0xFFFF} itself is the sentinel that sends the real count to the zip64 end of central
* directory record, so it cannot be used as a literal value.
*/
private static final long MAX_NON_ZIP64_ENTRY_COUNT = 0xFFFE;

/** The largest name we can describe in the 2-byte filename length field. */
private static final int MAX_FILENAME_LENGTH = 0xFFFF;

private static final long ZIP_64_END_OF_CD_RECORD_SIZE = 56;

private static final int ZIP_64_GLOBAL_EXTENDED_INFO_EXTRA_FIELD_SIZE = 28;
Expand Down Expand Up @@ -81,16 +92,30 @@ static void checkFitsInCentralDirectory(String name, long offset, long size) {
}
}

/**
* Encodes an entry name the way it is written to the archive. Zip filename lengths are
* counted in bytes, so anything derived from {@link String#length()} — which counts UTF-16
* code units — desyncs the central directory for a non-ASCII name.
*/
private static byte[] encodeFilename(String name) {
var bytes = name.getBytes(StandardCharsets.UTF_8);
if (bytes.length > MAX_FILENAME_LENGTH) {
throw new SDKException("zip entry name is " + bytes.length
+ " bytes when encoded as UTF-8, which does not fit in the "
+ MAX_FILENAME_LENGTH + " byte filename length field");
}
return bytes;
}

public OutputStream stream(String name) throws IOException {
var startPosition = out.position;
long fileTime, fileDate;
fileTime = fileDate = getTimeDateUnMSDosFormat();

var nameBytes = name.getBytes(StandardCharsets.UTF_8);
var nameBytes = encodeFilename(name);
LocalFileHeader localFileHeader = new LocalFileHeader();
localFileHeader.lastModifiedTime = (int) fileTime;
localFileHeader.lastModifiedDate = (int) fileDate;
localFileHeader.filenameLength = (short) nameBytes.length;
localFileHeader.crc32 = 0;
localFileHeader.generalPurposeBitFlag = (1 << 3) | (1 << 11); // we are using the data descriptor and we are using UTF-8
localFileHeader.compressedSize = ZIP_64_MAGIC_VAL;
Expand Down Expand Up @@ -170,12 +195,12 @@ private static void writeCentralDirectoryHeader(FileInfo fileInfo, OutputStream
checkFitsInCentralDirectory(fileInfo.filename, fileInfo.offset, fileInfo.size);
}

var nameBytes = encodeFilename(fileInfo.filename);
CDFileHeader cdFileHeader = new CDFileHeader();
cdFileHeader.generalPurposeBitFlag = fileInfo.flag;
cdFileHeader.lastModifiedTime = fileInfo.fileTime;
cdFileHeader.lastModifiedDate = fileInfo.fileDate;
cdFileHeader.crc32 = (int) fileInfo.crc;
cdFileHeader.filenameLength = (short) fileInfo.filename.length();
cdFileHeader.extraFieldLength = 0;
cdFileHeader.compressedSize = (int) fileInfo.size;
cdFileHeader.uncompressedSize = (int) fileInfo.size;
Expand All @@ -188,7 +213,7 @@ private static void writeCentralDirectoryHeader(FileInfo fileInfo, OutputStream
cdFileHeader.extraFieldLength = ZIP_64_GLOBAL_EXTENDED_INFO_EXTRA_FIELD_SIZE;
}

cdFileHeader.write(out, fileInfo.filename.getBytes(StandardCharsets.UTF_8));
cdFileHeader.write(out, nameBytes);

if (fileInfo.isZip64) {
Zip64GlobalExtendedInfoExtraField zip64ExtendedInfoExtraField = new Zip64GlobalExtendedInfoExtraField();
Expand All @@ -208,18 +233,17 @@ private FileInfo writeByteArray(String name, byte[] data, CountingOutputStream o
crc.update(data);
var crcValue = crc.getValue();

var nameBytes = name.getBytes(StandardCharsets.UTF_8);
var nameBytes = encodeFilename(name);
LocalFileHeader localFileHeader = new LocalFileHeader();
localFileHeader.lastModifiedTime = (int) fileTime;
localFileHeader.lastModifiedDate = (int) fileDate;
localFileHeader.filenameLength = (short) nameBytes.length;
localFileHeader.generalPurposeBitFlag = 0;
localFileHeader.crc32 = (int) crcValue;
localFileHeader.compressedSize = data.length;
localFileHeader.uncompressedSize = data.length;
localFileHeader.extraFieldLength = 0;

localFileHeader.write(out, name.getBytes(StandardCharsets.UTF_8));
localFileHeader.write(out, nameBytes);

out.write(data);

Expand All @@ -238,10 +262,14 @@ private FileInfo writeByteArray(String name, byte[] data, CountingOutputStream o


private void writeEndOfCentralDirectory(boolean hasZip64Entry, long numEntries, long startOfCentralDirectory, long sizeOfCentralDirectory, CountingOutputStream out) throws IOException {
// each of these corresponds to a field in the end of central directory record that has to
// carry a sentinel — and send its real value to the zip64 record — once the value stops
// fitting. the entry count is 2 bytes; the offset and size are 4 bytes each, and are held
// to the same 2 GiB ceiling as the per-entry fields so that readers which widen them with
// a signed read never see a negative value. see MAX_NON_ZIP64_VALUE.
var isZip64 = hasZip64Entry
|| (numEntries & ~0xFF) != 0
|| (startOfCentralDirectory & ~0xFFFF) != 0
|| (sizeOfCentralDirectory & ~0xFFFF) != 0;
|| numEntries > MAX_NON_ZIP64_ENTRY_COUNT
|| needsZip64(startOfCentralDirectory, sizeOfCentralDirectory);

if (isZip64) {
var endPosition = out.position;
Expand Down Expand Up @@ -322,7 +350,6 @@ private static class LocalFileHeader {
int compressedSize;
int uncompressedSize;

short filenameLength;
short extraFieldLength = 0;

void write(OutputStream out, byte[] filename) throws IOException {
Expand All @@ -337,7 +364,8 @@ void write(OutputStream out, byte[] filename) throws IOException {
buffer.putInt(crc32);
buffer.putInt(compressedSize);
buffer.putInt(uncompressedSize);
buffer.putShort(filenameLength);
// the length of the encoded bytes, never String.length()
buffer.putShort((short) filename.length);
buffer.putShort(extraFieldLength);
buffer.put(filename);

Expand Down Expand Up @@ -376,7 +404,6 @@ private static class CDFileHeader {
int crc32;
int compressedSize;
int uncompressedSize;
short filenameLength;
short extraFieldLength;
final short fileCommentLength = 0;
final short diskNumberStart = 0;
Expand All @@ -397,6 +424,7 @@ void write(OutputStream out, byte[] filename) throws IOException {
buffer.putInt(crc32);
buffer.putInt(compressedSize);
buffer.putInt(uncompressedSize);
// the length of the encoded bytes, never String.length()
buffer.putShort((short) filename.length);
buffer.putShort(extraFieldLength);
buffer.putShort(fileCommentLength);
Expand Down
Loading
Loading