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.