Skip to content
Open
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
110 changes: 110 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 @@ -2,14 +2,21 @@

import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonArray;
import com.google.gson.JsonDeserializationContext;
import com.google.gson.JsonDeserializer;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
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,18 @@ public JsonElement serialize(Object src, Type typeOfSrc, JsonSerializationContex

static public class Segment {
public String hash;
/**
* The plaintext length of this segment. Optional when parsing: a producer that leaves the
* key out means {@link IntegrityInformation#segmentSizeDefault}, which is filled in during
* deserialization. Always written on serialization, so a manifest read and re-emitted by
* this SDK carries the value explicitly.
*/
public long segmentSize;
/**
* The on-the-wire length of this segment. Optional when parsing the same way
* {@link #segmentSize} is, defaulting to
* {@link IntegrityInformation#encryptedSegmentSizeDefault}.
*/
public long encryptedSegmentSize;

@Override
Expand Down Expand Up @@ -167,6 +186,97 @@ 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. As of 2026-09,
* <a href="https://github.com/opentdf/spec/blob/main/schema/OpenTDF/json-schema/schema.json">the
* TDF schema</a> lists {@code segmentSizeDefault} and {@code encryptedSegmentSizeDefault} in
* {@code integrityInformation}'s {@code required} array but puts no {@code required} array on
* {@code segments/items}, and 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 and keeps the value/absence
* distinction out of the public API.
* <p>
* Two edge cases worth knowing: an explicit {@code null} is treated as absent and gets the
* default, while an explicit {@code 0} is a value and is left alone for the reader to reject.
* And if the manifest omits a <em>default</em> too then there is nothing to fall back to, so
* the segments keep their {@code 0}; {@code TDF.loadTDF} rejects that manifest when it checks
* the two defaults against each other.
*/
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.isJsonNull()) {
// no segments to default; readManifest rejects this with its own message
return;
}
JsonArray rawSegmentArray = rawSegments.getAsJsonArray();
if (segments.size() != rawSegmentArray.size()) {
// Gson's collection adapter is 1:1 with the JSON array, so this cannot happen
// today. asserting it rather than iterating the shorter of the two keeps a future
// filtering adapter from silently leaving the tail segments at 0
throw new IllegalStateException("internal error: deserialized " + segments.size()
+ " segments from a JSON array of " + rawSegmentArray.size());
}
for (int i = 0; i < segments.size(); i++) {
Segment segment = segments.get(i);
if (segment == null) {
// a null entry in the array; readManifest rejects it with its own message
continue;
}
JsonObject rawSegment = rawSegmentArray.get(i).getAsJsonObject();
if (!hasValue(rawSegment, SEGMENT_SIZE)) {
segment.segmentSize = integrityInformation.segmentSizeDefault;
}
if (!hasValue(rawSegment, ENCRYPTED_SEGMENT_SIZE)) {
segment.encryptedSegmentSize = integrityInformation.encryptedSegmentSizeDefault;
}
}
}

/** Whether {@code memberName} carries a value; an explicit {@code null} counts as absent. */
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
42 changes: 36 additions & 6 deletions sdk/src/main/java/io/opentdf/platform/sdk/TDF.java
Original file line number Diff line number Diff line change
Expand Up @@ -420,13 +420,9 @@ public void readPayload(OutputStream outputStream) throws SDK.SegmentSignatureMi
throw new IllegalStateException("error getting instance of SHA-256", e);
}

for (Manifest.Segment segment : manifest.encryptionInformation.integrityInformation.segments) {
if (segment.encryptedSegmentSize > Config.MAX_SEGMENT_SIZE) {
throw new IllegalStateException("Segment size " + segment.encryptedSegmentSize + " exceeded limit "
+ Config.MAX_SEGMENT_SIZE);
} // MIN_SEGMENT_SIZE NOT validated out due to tests needing small segment sizes
// with existing payloads
validateSegmentSizes();

for (Manifest.Segment segment : manifest.encryptionInformation.integrityInformation.segments) {
byte[] readBuf = new byte[(int) segment.encryptedSegmentSize];
int bytesRead = tdfReader.readPayloadBytes(readBuf);

Expand Down Expand Up @@ -467,6 +463,40 @@ public void readPayload(OutputStream outputStream) throws SDK.SegmentSignatureMi
}
}

/**
* Rejects segment sizes that cannot describe a real segment, before any of the payload is
* decrypted. Run as a pre-pass so an invalid size on a later segment does not leave the
* caller holding the plaintext of the earlier ones.
* <p>
* A too-small size otherwise reaches the integrity check as an unrelated signature
* mismatch, or under GMAC as a complaint about the payload being too small to hash. A
* negative one reaches {@code new byte[...]} as a {@link NegativeArraySizeException}.
*/
private void validateSegmentSizes() {
// an encrypted segment carries an IV and an auth tag on top of its plaintext, so it
// can never be shorter than the two of them together. an unencrypted payload is stored
// as-is, where the only impossible length is a non-positive one
long minEncryptedSegmentSize = manifest.payload.isEncrypted ? kGcmIvSize + AesGcm.GCM_TAG_LENGTH : 1;

List<Manifest.Segment> segments = manifest.encryptionInformation.integrityInformation.segments;
for (int i = 0; i < segments.size(); i++) {
long encryptedSegmentSize = segments.get(i).encryptedSegmentSize;

if (encryptedSegmentSize < minEncryptedSegmentSize) {
throw new IllegalStateException("invalid TDF: segment " + i
+ " declares an encryptedSegmentSize of " + encryptedSegmentSize
+ ", but a segment of this payload cannot be shorter than "
+ minEncryptedSegmentSize + " bytes");
}

if (encryptedSegmentSize > Config.MAX_SEGMENT_SIZE) {
throw new IllegalStateException("Segment size " + encryptedSegmentSize + " exceeded limit "
+ Config.MAX_SEGMENT_SIZE);
} // MIN_SEGMENT_SIZE NOT validated out due to tests needing small segment sizes
// with existing payloads
}
}

public PolicyObject readPolicyObject() {
return tdfReader.readPolicyObject();
}
Expand Down
106 changes: 106 additions & 0 deletions sdk/src/test/java/io/opentdf/platform/sdk/ManifestTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@
import static org.junit.jupiter.api.Assertions.assertEquals;

public class ManifestTest {
private static final long SEGMENT_SIZE_DEFAULT = 1048576;
private static final long ENCRYPTED_SEGMENT_SIZE_DEFAULT = 1048604;

@Test
void testManifestMarshalAndUnMarshal() {
String kManifestJsonFromTDF = "{\n" +
Expand Down Expand Up @@ -149,6 +152,109 @@ void testAssertionNull() {
assertEquals(manifest.assertions.size(), 0);
}

/** A minimal but valid manifest wrapped around whatever {@code segments} array you give it. */
private static String manifestWithSegments(String segmentsJson) {
return manifestWithSegments(segmentsJson,
" \"encryptedSegmentSizeDefault\": " + ENCRYPTED_SEGMENT_SIZE_DEFAULT + ",\n"
+ " \"segmentSizeDefault\": " + SEGMENT_SIZE_DEFAULT + ",\n");
}

/** As {@link #manifestWithSegments(String)}, but with the two default declarations spelled out. */
private static String manifestWithSegments(String segmentsJson, String defaultsJson) {
return "{\n" +
" \"encryptionInformation\": {\n" +
" \"integrityInformation\": {\n" +
defaultsJson +
" \"rootSignature\": { \"alg\": \"HS256\", \"sig\": \"c2ln\" },\n" +
" \"segmentHashAlg\": \"GMAC\",\n" +
" \"segments\": [" + segmentsJson + "]\n" +
" },\n" +
" \"keyAccess\": [ { \"protocol\": \"kas\", \"type\": \"wrapped\"," +
" \"url\": \"http://localhost:65432/kas\", \"wrappedKey\": \"a2V5\" } ],\n" +
" \"method\": { \"algorithm\": \"AES-256-GCM\", \"isStreamable\": true, \"iv\": \"aXY=\" },\n" +
" \"policy\": \"cG9saWN5\",\n" +
" \"type\": \"split\"\n" +
" },\n" +
" \"payload\": { \"isEncrypted\": true, \"protocol\": \"zip\"," +
" \"type\": \"reference\", \"url\": \"0.payload\" }\n" +
"}";
}

/**
* web-sdk leaves {@code segmentSize} and {@code encryptedSegmentSize} out of a segment
* whenever they equal the manifest level defaults. That is legal, and an absent one means
* "the default" rather than zero -- see {@code IntegrityInformationAdapterFactory} in
* {@link Manifest} for why.
*/
@Test
void testAbsentSegmentSizesFallBackToTheManifestDefaults() {
Manifest manifest = Manifest.readManifest(manifestWithSegments(
"{ \"hash\": \"aGFzaDA=\" },"
+ "{ \"hash\": \"aGFzaDE=\", \"segmentSize\": 12 },"
+ "{ \"hash\": \"aGFzaDI=\", \"segmentSize\": 3, \"encryptedSegmentSize\": 31 }"));

var segments = manifest.encryptionInformation.integrityInformation.segments;
assertThat(segments).hasSize(3);

assertThat(segments.get(0).segmentSize).isEqualTo(SEGMENT_SIZE_DEFAULT);
assertThat(segments.get(0).encryptedSegmentSize).isEqualTo(ENCRYPTED_SEGMENT_SIZE_DEFAULT);

assertThat(segments.get(1).segmentSize).isEqualTo(12);
assertThat(segments.get(1).encryptedSegmentSize).isEqualTo(ENCRYPTED_SEGMENT_SIZE_DEFAULT);

assertThat(segments.get(2).segmentSize).isEqualTo(3);
assertThat(segments.get(2).encryptedSegmentSize).isEqualTo(31);

// and the values we filled in survive a round trip through the serializer
assertEquals(manifest, Manifest.readManifest(Manifest.toJson(manifest)));
}

/**
* An explicit zero is a value rather than an absent key, so parsing leaves it alone; silently
* rewriting it to the default would hide a corrupt manifest. {@code TDF.Reader} is what
* rejects a zero {@code encryptedSegmentSize}, in
* {@code TDFTest#testZeroLengthSegmentIsRejectedWithAClearError}. A zero {@code segmentSize}
* is not checked anywhere, because nothing on the read path consumes it.
*/
@Test
void testExplicitZeroSegmentSizeIsNotTreatedAsAbsent() {
Manifest manifest = Manifest.readManifest(manifestWithSegments(
"{ \"hash\": \"aGFzaDA=\", \"segmentSize\": 0, \"encryptedSegmentSize\": 0 }"));

var segment = manifest.encryptionInformation.integrityInformation.segments.get(0);
assertThat(segment.segmentSize).isZero();
assertThat(segment.encryptedSegmentSize).isZero();
}

/**
* An explicit {@code null} carries no value, so unlike an explicit zero it is treated as
* absent and picks up the default.
*/
@Test
void testNullSegmentSizeIsTreatedAsAbsent() {
Manifest manifest = Manifest.readManifest(manifestWithSegments(
"{ \"hash\": \"aGFzaDA=\", \"segmentSize\": null, \"encryptedSegmentSize\": null }"));

var segment = manifest.encryptionInformation.integrityInformation.segments.get(0);
assertThat(segment.segmentSize).isEqualTo(SEGMENT_SIZE_DEFAULT);
assertThat(segment.encryptedSegmentSize).isEqualTo(ENCRYPTED_SEGMENT_SIZE_DEFAULT);
}

/**
* With no defaults declared there is nothing to fall back to, so the segments keep their
* zeroes rather than the fixup inventing a size. {@code TDF.loadTDF} is what rejects such a
* manifest, when it checks the two defaults against each other.
*/
@Test
void testAbsentDefaultsLeaveSegmentSizesAtZero() {
Manifest manifest = Manifest.readManifest(
manifestWithSegments("{ \"hash\": \"aGFzaDA=\" }", ""));

var segment = manifest.encryptionInformation.integrityInformation.segments.get(0);
assertThat(segment.segmentSize).isZero();
assertThat(segment.encryptedSegmentSize).isZero();
}

@Test
void testReadingManifestWithObjectStatementValue() throws IOException {
final Manifest manifest;
Expand Down
Loading
Loading