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 20fbb13e..0e8b044c 100644
--- a/sdk/src/main/java/io/opentdf/platform/sdk/Manifest.java
+++ b/sdk/src/main/java/io/opentdf/platform/sdk/Manifest.java
@@ -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;
@@ -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;
@@ -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
@@ -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.
+ *
+ * The per-segment values are optional overrides. As of 2026-09,
+ * the
+ * TDF schema 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.
+ *
+ * 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.
+ *
+ * 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 default 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 TypeAdapter create(Gson gson, TypeToken type) {
+ if (!IntegrityInformation.class.equals(type.getRawType())) {
+ return null;
+ }
+ final TypeAdapter delegate = gson.getDelegateAdapter(this, type);
+ final TypeAdapter elementAdapter = gson.getAdapter(JsonElement.class);
+ return new TypeAdapter() {
+ @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 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;
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 b54901b9..c6d6cdf8 100644
--- a/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java
+++ b/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java
@@ -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);
@@ -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.
+ *
+ * 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 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();
}
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 220ca6d1..5e57937d 100644
--- a/sdk/src/test/java/io/opentdf/platform/sdk/ManifestTest.java
+++ b/sdk/src/test/java/io/opentdf/platform/sdk/ManifestTest.java
@@ -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" +
@@ -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;
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 3fbb3eac..725ecbd2 100644
--- a/sdk/src/test/java/io/opentdf/platform/sdk/TDFTest.java
+++ b/sdk/src/test/java/io/opentdf/platform/sdk/TDFTest.java
@@ -7,6 +7,7 @@
import com.nimbusds.jose.jwk.JWK;
import com.google.gson.Gson;
import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
import io.opentdf.platform.policy.KeyAccessServer;
import io.opentdf.platform.policy.kasregistry.KeyAccessServerRegistryServiceClient;
import io.opentdf.platform.policy.kasregistry.ListKeyAccessServersRequest;
@@ -15,6 +16,8 @@
import org.apache.commons.compress.utils.SeekableInMemoryByteChannel;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
import javax.annotation.Nonnull;
import java.io.ByteArrayInputStream;
@@ -34,6 +37,7 @@
import java.util.Random;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
+import java.util.function.Consumer;
import java.util.function.Predicate;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
@@ -46,6 +50,7 @@
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
public class TDFTest {
protected static KeyAccessServerRegistryServiceClient kasRegistryService;
@@ -731,6 +736,189 @@ public void testCreatingTDFWithMultipleSegments() throws Exception {
}
+ /**
+ * A segment size small enough that the test payloads below span several segments. Matches
+ * web-sdk's {@code DEFAULT_SEGMENT_SIZE}, though nothing here depends on the exact value.
+ */
+ private static final int WEB_SDK_DEFAULT_SEGMENT_SIZE = 1024 * 1024;
+
+ /**
+ * web-sdk drops {@code segmentSize} and {@code encryptedSegmentSize} from a segment whenever
+ * they equal the manifest level defaults, which is every full segment of a payload larger
+ * than one segment. Reproduces that encoding on a java-produced TDF rather than carrying a
+ * web-sdk fixture, so the test stays self-contained. Before the fallback existed the reader
+ * allocated a zero length buffer for those segments and failed deep inside the integrity
+ * check.
+ *
+ * @param trailingBytes bytes past the last full segment, so both the "trailing partial
+ * segment" and the "exact multiple of the segment size" shapes are
+ * covered. The latter omits both keys on every segment, which is where
+ * an off-by-one in the fixup's index loop would show up.
+ */
+ @ParameterizedTest
+ @ValueSource(ints = { 4242, 0 })
+ public void testReadingATDFThatOmitsDefaultedSegmentSizes(int trailingBytes) throws Exception {
+ // two full segments, the shape that first exposed this
+ var data = new byte[2 * WEB_SDK_DEFAULT_SEGMENT_SIZE + trailingBytes];
+ new Random(4589).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(WEB_SDK_DEFAULT_SEGMENT_SIZE)));
+ assertThat(tdfObject.getManifest().encryptionInformation.integrityInformation.segments)
+ .withFailMessage("the test needs more than one segment to be meaningful")
+ .hasSizeGreaterThan(1);
+
+ var rewritten = rewriteManifest(original.toByteArray(), manifest -> {
+ var integrityInformation = manifest.getAsJsonObject("encryptionInformation")
+ .getAsJsonObject("integrityInformation");
+ var segmentSizeDefault = integrityInformation.get("segmentSizeDefault").getAsLong();
+ var encryptedSegmentSizeDefault = integrityInformation.get("encryptedSegmentSizeDefault").getAsLong();
+
+ int omittedSegmentSizes = 0;
+ int omittedEncryptedSegmentSizes = 0;
+ for (var element : integrityInformation.getAsJsonArray("segments")) {
+ var segment = element.getAsJsonObject();
+ if (segment.get("segmentSize").getAsLong() == segmentSizeDefault) {
+ segment.remove("segmentSize");
+ omittedSegmentSizes++;
+ }
+ if (segment.get("encryptedSegmentSize").getAsLong() == encryptedSegmentSizeDefault) {
+ segment.remove("encryptedSegmentSize");
+ omittedEncryptedSegmentSizes++;
+ }
+ }
+ assertThat(omittedSegmentSizes)
+ .withFailMessage("no segment matched segmentSizeDefault, so nothing was omitted")
+ .isGreaterThan(0);
+ // this is the omission the reader actually depends on: plaintext segmentSize is
+ // write-only in this SDK, so without this assertion the test could go vacuous
+ assertThat(omittedEncryptedSegmentSizes)
+ .withFailMessage("no segment matched encryptedSegmentSizeDefault, so nothing was omitted")
+ .isGreaterThan(0);
+ });
+
+ var unwrapped = new ByteArrayOutputStream();
+ tdf.loadTDF(new SeekableInMemoryByteChannel(rewritten), platformUrl).readPayload(unwrapped);
+
+ assertThat(unwrapped.toByteArray())
+ .withFailMessage("extracted data does not match")
+ .containsExactly(data);
+ }
+
+ /**
+ * A per-segment {@code encryptedSegmentSize} too small to hold an IV and a tag is a corrupt
+ * manifest rather than an omitted default, and has to say so instead of reaching the integrity
+ * check, where it surfaces as a signature mismatch or, under GMAC, as a complaint that the
+ * payload is too small to hash. A negative one would reach {@code new byte[...]} as a
+ * {@link NegativeArraySizeException}.
+ *
+ * @param encryptedSegmentSize zero; a positive value under the 28 byte IV-plus-tag floor; and
+ * a negative one
+ */
+ @ParameterizedTest
+ @ValueSource(ints = { 0, 20, -1 })
+ public void testUndersizedSegmentIsRejectedWithAClearError(int encryptedSegmentSize) throws Exception {
+ var data = "some data to encrypt".getBytes(StandardCharsets.UTF_8);
+ var tdf = new TDF(new FakeServicesBuilder().setKas(kas)
+ .setKeyAccessServerRegistryService(kasRegistryService).build());
+ var original = new ByteArrayOutputStream();
+ tdf.createTDF(new ByteArrayInputStream(data), original,
+ Config.newTDFConfig(
+ Config.withAutoconfigure(false),
+ Config.withKasInformation(getRSAKASInfos())));
+
+ var rewritten = rewriteManifest(original.toByteArray(), manifest -> manifest
+ .getAsJsonObject("encryptionInformation")
+ .getAsJsonObject("integrityInformation")
+ .getAsJsonArray("segments")
+ .get(0).getAsJsonObject()
+ .addProperty("encryptedSegmentSize", encryptedSegmentSize));
+
+ var unwrapped = new ByteArrayOutputStream();
+ var reader = tdf.loadTDF(new SeekableInMemoryByteChannel(rewritten), platformUrl);
+ assertThatThrownBy(() -> reader.readPayload(unwrapped))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("segment 0 declares an encryptedSegmentSize of " + encryptedSegmentSize)
+ .hasMessageContaining("cannot be shorter than 28 bytes");
+ }
+
+ /**
+ * The size check is a pre-pass, so a bad segment in the middle of a payload fails before any
+ * of the earlier segments are decrypted. Otherwise the caller is handed truncated plaintext
+ * from a manifest already known to be invalid.
+ */
+ @Test
+ public void testUndersizedSegmentIsRejectedBeforeAnyPlaintextIsWritten() throws Exception {
+ var data = new byte[2 * WEB_SDK_DEFAULT_SEGMENT_SIZE + 4242];
+ new Random(4589).nextBytes(data);
+
+ var tdf = new TDF(new FakeServicesBuilder().setKas(kas)
+ .setKeyAccessServerRegistryService(kasRegistryService).build());
+ var original = new ByteArrayOutputStream();
+ tdf.createTDF(new ByteArrayInputStream(data), original,
+ Config.newTDFConfig(
+ Config.withAutoconfigure(false),
+ Config.withKasInformation(getRSAKASInfos()),
+ Config.withSegmentSize(WEB_SDK_DEFAULT_SEGMENT_SIZE)));
+
+ // corrupt the *last* segment, so a naive in-loop check would already have emitted the rest
+ var rewritten = rewriteManifest(original.toByteArray(), manifest -> {
+ var segments = manifest.getAsJsonObject("encryptionInformation")
+ .getAsJsonObject("integrityInformation")
+ .getAsJsonArray("segments");
+ segments.get(segments.size() - 1).getAsJsonObject().addProperty("encryptedSegmentSize", 0);
+ });
+
+ var unwrapped = new ByteArrayOutputStream();
+ var reader = tdf.loadTDF(new SeekableInMemoryByteChannel(rewritten), platformUrl);
+ assertThatThrownBy(() -> reader.readPayload(unwrapped))
+ .isInstanceOf(IllegalStateException.class);
+ assertThat(unwrapped.size())
+ .withFailMessage("plaintext was written before the invalid manifest was rejected")
+ .isZero();
+ }
+
+ /** Rebuilds a TDF with its manifest edited in place, leaving the payload bytes untouched. */
+ private static byte[] rewriteManifest(byte[] tdfBytes, Consumer edit) throws IOException {
+ final JsonObject manifest;
+ final byte[] payload;
+ try (var channel = new SeekableInMemoryByteChannel(tdfBytes)) {
+ var reader = new ZipReader(channel);
+ manifest = JsonParser
+ .parseString(readZipEntry(reader, TDFWriter.TDF_MANIFEST_FILE_NAME)
+ .toString(StandardCharsets.UTF_8))
+ .getAsJsonObject();
+ payload = readZipEntry(reader, TDFWriter.TDF_PAYLOAD_FILE_NAME).toByteArray();
+ }
+
+ edit.accept(manifest);
+
+ var out = new ByteArrayOutputStream();
+ var writer = new TDFWriter(out);
+ try (var payloadStream = writer.payload()) {
+ payloadStream.write(payload);
+ }
+ writer.appendManifest(manifest.toString());
+ writer.finish();
+ return out.toByteArray();
+ }
+
+ private static ByteArrayOutputStream readZipEntry(ZipReader reader, String name) throws IOException {
+ var entry = reader.getEntries().stream()
+ .filter(e -> e.getName().equals(name))
+ .findFirst()
+ .orElseThrow(() -> new AssertionError("no entry named " + name));
+ var data = new ByteArrayOutputStream();
+ entry.getData().transferTo(data);
+ return data;
+ }
+
/**
* The unsigned 96-bit big-endian encoding of {@code value}, for asserting on
* expected IVs.