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..f0268e97 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java @@ -411,7 +411,7 @@ public Manifest getManifest() { this.unencryptedMetadata = unencryptedMetadata; } - public void readPayload(OutputStream outputStream) throws SDK.SegmentSignatureMismatch, IOException { + public void readPayload(OutputStream outputStream) throws SDK.TamperException, IOException { MessageDigest digest = null; try { @@ -438,13 +438,10 @@ public void readPayload(OutputStream outputStream) throws SDK.SegmentSignatureMi var isLegacyTdf = manifest.tdfVersion == null || manifest.tdfVersion.isEmpty(); if (manifest.payload.isEncrypted) { - String segHashAlg = manifest.encryptionInformation.integrityInformation.segmentHashAlg; - Config.IntegrityAlgorithm sigAlg = Config.IntegrityAlgorithm.HS256; - if (segHashAlg.compareToIgnoreCase(kGmacIntegrityAlgorithm) == 0) { - sigAlg = Config.IntegrityAlgorithm.GMAC; - } + var sigAlg = segmentIntegrityAlgorithmFromManifest( + manifest.encryptionInformation.integrityInformation.segmentHashAlg); - var payloadSig = calculateSignature(readBuf, payloadKey, sigAlg); + var payloadSig = segmentIntegrity(readBuf, payloadKey, sigAlg); if (isLegacyTdf) { payloadSig = Hex.encodeHexString(payloadSig).getBytes(StandardCharsets.UTF_8); } @@ -470,22 +467,164 @@ public void readPayload(OutputStream outputStream) throws SDK.SegmentSignatureMi public PolicyObject readPolicyObject() { return tdfReader.readPolicyObject(); } - } - private static byte[] calculateSignature(byte[] data, byte[] secret, Config.IntegrityAlgorithm algorithm) { - if (algorithm == Config.IntegrityAlgorithm.HS256) { - return CryptoUtils.CalculateSHA256Hmac(secret, data); + /** + * Resolves {@code segmentHashAlg} as read from the manifest. Both algorithms are + * allowed: a GMAC segment hash proves nothing by itself, but unlike the root it is + * bracketed by keyed checks that do (see {@link TDF#aeadTag}). An unrecognized name + * is still refused rather than defaulted. Contrast + * {@link TDF#rootIntegrityAlgorithmFromManifest}, where only HS256 is meaningful. + */ + private static Config.IntegrityAlgorithm segmentIntegrityAlgorithmFromManifest(String declared) { + if (declared != null) { + String name = declared.trim(); + if (kGmacIntegrityAlgorithm.equalsIgnoreCase(name)) { + return Config.IntegrityAlgorithm.GMAC; + } + if (kHmacIntegrityAlgorithm.equalsIgnoreCase(name)) { + return Config.IntegrityAlgorithm.HS256; + } + } + // Not a SegmentSignatureMismatch: no signature was compared. Still a + // TamperException, because segmentHashAlg is not covered by the root + // signature and so is something an attacker can freely rewrite. + throw new SDK.TamperException("unsupported segment integrity algorithm: " + declared); } + } - if (kGMACPayloadLength > data.length) { + /** + * Recovers the trailing AES-GCM authentication tag from a segment's ciphertext. + *
+ * Recovering a tag is not verifying one. These are bytes whoever supplied the input + * already holds, so comparing them against a manifest value is keyless and on its own + * proves nothing — an attacker can re-chunk a payload and write each chunk's own + * trailing sixteen bytes into its {@code segment.hash}. What makes a GMAC segment hash + * trustworthy is the keyed checks around it: {@code loadTDF} has already validated the + * whole list of segment hashes against the HS256 root signature, and {@code readPayload} + * follows the comparison with a real AES-GCM tag check under the payload key. + *
+ * The root signature has neither backstop — it is the outermost check, so a "GMAC root" + * is a keyless comparison with nothing behind it. The asymmetry is therefore structural, + * not a property of the bytes, and it is why {@link #rootIntegrity} does not offer this + * algorithm. + */ + private static byte[] aeadTag(byte[] ciphertext) { + if (kGMACPayloadLength > ciphertext.length) { throw new IllegalArgumentException("tried to calculate GMAC on too small a payload. payload is " - + data.length + "bytes while GMAC is " + kGMACPayloadLength + " bytes"); + + ciphertext.length + " bytes while GMAC is " + kGMACPayloadLength + " bytes"); + } + + return Arrays.copyOfRange(ciphertext, ciphertext.length - kGMACPayloadLength, ciphertext.length); + } + + /** + * The integrity value recorded in a segment's {@code hash}. + * + * @param ciphertext the AES-GCM output for this segment, whole and unmodified + * @param key the payload key + * @param algorithm {@code GMAC} to reuse the segment's own AEAD tag, or + * {@code HS256} to HMAC the segment ciphertext + * @throws IllegalArgumentException if {@code algorithm} is null or unsupported + */ + static byte[] segmentIntegrity(byte[] ciphertext, byte[] key, Config.IntegrityAlgorithm algorithm) { + requireSupportedSegmentIntegrityAlgorithm(algorithm); + switch (algorithm) { + case HS256: + return CryptoUtils.CalculateSHA256Hmac(key, ciphertext); + case GMAC: + return aeadTag(ciphertext); + default: + throw new IllegalArgumentException("unsupported segment integrity algorithm: " + algorithm); + } + } + + /** + * The integrity value recorded in {@code rootSignature.sig}, over the concatenated + * segment hashes. + *
+ * HS256 only. The aggregate hash never passes through the AEAD, so there is no tag + * to recover from it; a "GMAC" root signature is just a copy of the last segment + * hash, which is attacker-controlled manifest data. Accepting one would let anyone + * truncate, reorder, duplicate or drop segments without holding a key, since nothing + * else binds a segment to its index or to the segment count. + * + * @throws IllegalArgumentException if {@code algorithm} is anything but HS256 + */ + static byte[] rootIntegrity(byte[] aggregateHash, byte[] key, Config.IntegrityAlgorithm algorithm) { + requireSupportedRootIntegrityAlgorithm(algorithm); + return CryptoUtils.CalculateSHA256Hmac(key, aggregateHash); + } + + /** + * The segment counterpart to {@link #requireSupportedRootIntegrityAlgorithm}. Both + * algorithms are legal in this position, so this exists to reject {@code null} and any + * future enum value in {@code createTDF} rather than partway through the payload: + * TDFConfig's fields are public, so a field left unset arrives here as {@code null} and + * would otherwise surface as a {@link NullPointerException} at the switch below, after + * segments had already been written to the output stream. + * + * @throws IllegalArgumentException if {@code algorithm} cannot hash a segment + */ + static void requireSupportedSegmentIntegrityAlgorithm(Config.IntegrityAlgorithm algorithm) { + if (algorithm != Config.IntegrityAlgorithm.HS256 && algorithm != Config.IntegrityAlgorithm.GMAC) { + throw new IllegalArgumentException("unsupported segment integrity algorithm: " + algorithm); + } + } + + /** + * The write-path gate, and a second checkpoint inside {@link #rootIntegrity}. + *
+ * An {@link IllegalArgumentException} rather than a {@link SDK.TamperException}, on + * purpose. Reading, this is unreachable: {@link #rootIntegrityAlgorithmFromManifest} + * has already narrowed the manifest's declaration to HS256 or thrown + * {@link SDK.RootSignatureValidationException} trying. So if it ever does fire on a + * read, the cause is a bug in this class rather than a hostile file, and it should + * escape {@code loadTDF} uncaught instead of being reported to callers as tamper — + * fail loud, and do not let a defect hide inside an exception type that callers + * routinely handle. + * + * @throws IllegalArgumentException if {@code algorithm} cannot authenticate a root + * signature + */ + static void requireSupportedRootIntegrityAlgorithm(Config.IntegrityAlgorithm algorithm) { + if (algorithm != Config.IntegrityAlgorithm.HS256) { + throw new IllegalArgumentException("unsupported root integrity algorithm: " + algorithm + + "; the root signature must be " + kHmacIntegrityAlgorithm); } + } - return Arrays.copyOfRange(data, data.length - kGMACPayloadLength, data.length); + /** + * Resolves {@code rootSignature.alg} as read from the (unauthenticated) manifest. + *
+ * An allowlist, deliberately: anything other than HS256 — GMAC, an unknown name, an
+ * empty string — is refused rather than being defaulted to HS256. Defaulting would
+ * validate a downgraded manifest against an algorithm it does not declare.
+ */
+ private static Config.IntegrityAlgorithm rootIntegrityAlgorithmFromManifest(String declared) {
+ if (declared != null && kHmacIntegrityAlgorithm.equalsIgnoreCase(declared.trim())) {
+ return Config.IntegrityAlgorithm.HS256;
+ }
+ throw new SDK.RootSignatureValidationException("unsupported root integrity algorithm: " + declared
+ + "; the root signature must be " + kHmacIntegrityAlgorithm);
}
+ /**
+ * @throws IllegalArgumentException if {@code tdfConfig} selects an integrity algorithm
+ * that cannot be written. Unchecked and not an
+ * {@link SDKException}, matching how the config layer
+ * already reports out-of-range values (see
+ * {@link Config#withSegmentSize}): this is a caller
+ * mistake to fix in code, not a condition to handle
+ * alongside I/O and tamper failures.
+ */
TDFObject createTDF(InputStream payload, OutputStream outputStream, Config.TDFConfig tdfConfig) throws SDKException, IOException {
+ // Checked before anything is written so an unusable algorithm cannot produce a
+ // partial TDF. There are no setters for these -- the config defaults to an HS256
+ // root and GMAC segments -- but TDFConfig's fields are public, so re-check what
+ // was actually set.
+ requireSupportedRootIntegrityAlgorithm(tdfConfig.integrityAlgorithm);
+ requireSupportedSegmentIntegrityAlgorithm(tdfConfig.segmentIntegrityAlgorithm);
+
Planner planner = new Planner(tdfConfig, services, Autoconfigure::createGranter);
Map
+ * A "GMAC" root signature is not a MAC. GMAC over a segment's ciphertext recovers the
+ * tag AES-GCM already produced over those exact bytes, which is a genuine authenticator;
+ * over the aggregate hash there is no such tag, and the trailing sixteen bytes are just
+ * a copy of the last segment hash — attacker-supplied manifest data, no key involved.
+ * Since {@code rootSignature.alg} is itself read from the unauthenticated manifest, any
+ * reader that honours GMAC there can be downgraded onto that branch by someone holding
+ * no key at all, and can then be fed a truncated or reordered segment list.
+ *
+ * These tests pin that boundary. The controls establish that tampering is caught in the
+ * ordinary HS256 case — without them the exploit cases would prove nothing — and the
+ * exploit cases establish that the keyless downgrade is now refused.
+ */
+class TDFRootSignatureTest {
+
+ /** Small segments keep the fixtures cheap while still giving several of them. */
+ private static final int SEGMENT_SIZE = Config.MIN_SEGMENT_SIZE;
+ private static final String KAS_URL = "https://example.com/kas0";
+
+ private static KeyPair kasKeyPair;
+
+ private static final SDK.KAS KAS = new SDK.KAS() {
+ @Override
+ public void close() {
+ // no-op: nothing to release in this fake
+ }
+
+ @Override
+ public Config.KASInfo getPublicKey(Config.KASInfo kasInfo) {
+ var resolved = new Config.KASInfo();
+ resolved.URL = kasInfo.URL;
+ resolved.KID = "r1";
+ resolved.PublicKey = CryptoUtils.getPublicKeyPEM(kasKeyPair.getPublic());
+ return resolved;
+ }
+
+ @Override
+ public byte[] unwrap(Manifest.KeyAccess keyAccess, String policy, KeyType sessionKeyType) {
+ return new AsymDecryption(kasKeyPair.getPrivate())
+ .decrypt(Base64.getDecoder().decode(keyAccess.wrappedKey));
+ }
+
+ @Override
+ public KASKeyCache getKeyCache() {
+ return new KASKeyCache();
+ }
+ };
+
+ @BeforeAll
+ static void generateKasKeyPair() {
+ kasKeyPair = CryptoUtils.generateRSAKeypair();
+ }
+
+ // ---------------------------------------------------------------- controls
+
+ @Test
+ void untouchedTdfRoundTrips() throws IOException {
+ // Also a control on the test harness itself: unzipping and rezipping a TDF
+ // without editing it must not disturb anything the reader checks.
+ var plaintext = fourSegmentPlaintext();
+ var rewritten = rewrite(createTdf(plaintext), manifest -> {
+ }, UnaryOperator.identity());
+
+ assertThat(decrypt(rewritten)).containsExactly(plaintext);
+ }
+
+ @Test
+ void truncationUnderHs256IsCaught() throws IOException {
+ var tampered = rewrite(createTdf(fourSegmentPlaintext()),
+ manifest -> keepSegments(manifest, 2),
+ UnaryOperator.identity());
+
+ assertThatThrownBy(() -> decrypt(tampered))
+ .isInstanceOf(SDK.RootSignatureValidationException.class);
+ }
+
+ @Test
+ void segmentHashEditUnderHs256IsCaught() throws IOException {
+ var tampered = rewrite(createTdf(fourSegmentPlaintext()), manifest -> {
+ var first = segments(manifest).get(0).getAsJsonObject();
+ var hash = Base64.getDecoder().decode(first.get("hash").getAsString());
+ hash[0] ^= 0xFF;
+ first.addProperty("hash", Base64.getEncoder().encodeToString(hash));
+ }, UnaryOperator.identity());
+
+ assertThatThrownBy(() -> decrypt(tampered))
+ .isInstanceOf(SDK.RootSignatureValidationException.class);
+ }
+
+ @Test
+ void reorderUnderHs256IsCaught() throws IOException {
+ var original = createTdf(fourSegmentPlaintext());
+ var sizes = encryptedSegmentSizes(original);
+
+ var tampered = rewrite(original,
+ TDFRootSignatureTest::reverseSegments,
+ payload -> reverseChunks(payload, sizes));
+
+ assertThatThrownBy(() -> decrypt(tampered))
+ .isInstanceOf(SDK.RootSignatureValidationException.class);
+ }
+
+ @Test
+ void gmacDowngradeWithoutForgedSignatureIsCaught() throws IOException {
+ // Isolates the downgrade itself from the forged signature: flipping `alg` alone
+ // must not validate.
+ var tampered = rewrite(createTdf(fourSegmentPlaintext()),
+ manifest -> rootSignature(manifest).addProperty("alg", "GMAC"),
+ UnaryOperator.identity());
+
+ assertThatThrownBy(() -> decrypt(tampered))
+ .isInstanceOf(SDK.RootSignatureValidationException.class);
+ }
+
+ @ParameterizedTest
+ @EnumSource(Config.IntegrityAlgorithm.class)
+ void segmentTagTamperIsCaughtBySegmentHash(Config.IntegrityAlgorithm algorithm) throws IOException {
+ // The manifest is untouched, so the root signature still verifies; the segment
+ // hash is what has to catch this. Flipping the final payload byte hits the last
+ // segment's GCM tag, which is the segment hash itself under GMAC and is covered
+ // by the HMAC under HS256.
+ var tampered = rewrite(createTdf(fourSegmentPlaintext(), withSegmentAlgorithm(algorithm)),
+ manifest -> {
+ }, payload -> flipByte(payload, payload.length - 1));
+
+ assertThatThrownBy(() -> decrypt(tampered))
+ .isInstanceOf(SDK.SegmentSignatureMismatch.class);
+ }
+
+ // ---------------------------------------------------------------- exploits
+
+ @Test
+ void gmacRootIsRejected() throws IOException {
+ // The whole segment list is intact and the signature is exactly what the GMAC
+ // branch used to compute, so this is the best-case forgery. It must still fail:
+ // a GMAC root signature carries no authentication at all.
+ var tampered = rewrite(createTdf(fourSegmentPlaintext()),
+ manifest -> forgeGmacRootSignature(manifest, "GMAC"),
+ UnaryOperator.identity());
+
+ assertThatThrownBy(() -> decrypt(tampered))
+ .isInstanceOf(SDK.RootSignatureValidationException.class);
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = { "GMAC", "gmac", "GMac", " GMAC " })
+ void gmacRootIsRejectedInAnyCasing(String declaredAlgorithm) throws IOException {
+ // Casing is the cheapest way around a case-sensitive rejection, so pin it.
+ var tampered = rewrite(createTdf(fourSegmentPlaintext()),
+ manifest -> forgeGmacRootSignature(manifest, declaredAlgorithm),
+ UnaryOperator.identity());
+
+ assertThatThrownBy(() -> decrypt(tampered))
+ .isInstanceOf(SDK.RootSignatureValidationException.class);
+ }
+
+ @Test
+ void gmacDowngradeWithTruncatedSegmentsIsRejected() throws IOException {
+ // The exploit: a keyless attacker declares GMAC, drops the trailing segments,
+ // and recomputes the "signature" from manifest data it already controls. The
+ // payload entry still holds every segment's ciphertext; the reader walks the
+ // manifest, so the trailing bytes are simply never read.
+ var plaintext = fourSegmentPlaintext();
+ var tampered = rewrite(createTdf(plaintext), manifest -> {
+ keepSegments(manifest, 2);
+ forgeGmacRootSignature(manifest, "GMAC");
+ }, UnaryOperator.identity());
+
+ assertThatThrownBy(() -> decrypt(tampered))
+ .isInstanceOf(SDK.RootSignatureValidationException.class);
+ }
+
+ @Test
+ void gmacDowngradeWithReorderedSegmentsIsRejected() throws IOException {
+ // Same downgrade applied to segment order. Reordering needs the ciphertext moved
+ // too, since the reader walks the payload sequentially -- but that is still a
+ // keyless edit, and every segment keeps its own valid GCM tag. Nothing in AES-GCM
+ // binds a segment to its index, so per-segment authentication cannot notice the
+ // permutation.
+ var original = createTdf(fourSegmentPlaintext());
+ var sizes = encryptedSegmentSizes(original);
+
+ var tampered = rewrite(original, manifest -> {
+ reverseSegments(manifest);
+ forgeGmacRootSignature(manifest, "GMAC");
+ }, payload -> reverseChunks(payload, sizes));
+
+ assertThatThrownBy(() -> decrypt(tampered))
+ .isInstanceOf(SDK.RootSignatureValidationException.class);
+ }
+
+ @Test
+ void unknownRootAlgorithmIsRejected() throws IOException {
+ // Fail closed: an algorithm the reader does not implement must be refused rather
+ // than quietly treated as HS256.
+ var tampered = rewrite(createTdf(fourSegmentPlaintext()),
+ manifest -> rootSignature(manifest).addProperty("alg", "HS512"),
+ UnaryOperator.identity());
+
+ assertThatThrownBy(() -> decrypt(tampered))
+ .isInstanceOf(SDK.RootSignatureValidationException.class);
+ }
+
+ @Test
+ void unknownSegmentAlgorithmIsRejected() throws IOException {
+ // `segmentHashAlg` is not covered by the root signature, so it has to be
+ // allowlisted on its own. Exactly a TamperException, not the
+ // SegmentSignatureMismatch subtype: nothing here compared a signature.
+ var tampered = rewrite(createTdf(fourSegmentPlaintext()),
+ manifest -> integrityInformation(manifest).addProperty("segmentHashAlg", "MD5"),
+ UnaryOperator.identity());
+
+ assertThatThrownBy(() -> decrypt(tampered))
+ .isExactlyInstanceOf(SDK.TamperException.class);
+ }
+
+ // ------------------------------------------------------- segment algorithms
+
+ @ParameterizedTest
+ @EnumSource(Config.IntegrityAlgorithm.class)
+ void segmentAlgorithmRoundTrips(Config.IntegrityAlgorithm algorithm) throws IOException {
+ // GMAC segments must keep working: every TDF in existence uses them.
+ var plaintext = fourSegmentPlaintext();
+ var tdfBytes = createTdf(plaintext, withSegmentAlgorithm(algorithm));
+
+ var integrityInformation = integrityInformation(JsonParser.parseString(manifestOf(tdfBytes))
+ .getAsJsonObject());
+ assertThat(integrityInformation.get("segmentHashAlg").getAsString()).isEqualTo(algorithm.name());
+ assertThat(integrityInformation.getAsJsonObject("rootSignature").get("alg").getAsString())
+ .isEqualTo(Config.IntegrityAlgorithm.HS256.name());
+
+ int expectedHashLength = algorithm == Config.IntegrityAlgorithm.GMAC ? 16 : 32;
+ for (var segment : integrityInformation.getAsJsonArray("segments")) {
+ assertThat(Base64.getDecoder().decode(segment.getAsJsonObject().get("hash").getAsString()))
+ .hasSize(expectedHashLength);
+ }
+
+ assertThat(decrypt(tdfBytes)).containsExactly(plaintext);
+ }
+
+ @ParameterizedTest
+ @EnumSource(Config.IntegrityAlgorithm.class)
+ void segmentBodyTamperIsCaughtUnderEitherSegmentAlgorithm(Config.IntegrityAlgorithm algorithm) throws IOException {
+ // A flip in the middle of a segment's ciphertext is caught in different places
+ // depending on the algorithm -- by the segment hash under HS256, and by AES-GCM's
+ // own tag check at decryption time under GMAC, where the segment hash is that
+ // same tag and so is unchanged. Either way the read fails.
+ var tdfBytes = createTdf(fourSegmentPlaintext(), withSegmentAlgorithm(algorithm));
+ var tampered = rewrite(tdfBytes, manifest -> {
+ }, payload -> flipByte(payload, payload.length / 2));
+
+ assertThatThrownBy(() -> decrypt(tampered)).isInstanceOf(SDKException.class);
+ }
+
+ @Test
+ void defaultsAreHs256RootAndGmacSegments() throws IOException {
+ var integrityInformation = integrityInformation(
+ JsonParser.parseString(manifestOf(createTdf(fourSegmentPlaintext()))).getAsJsonObject());
+
+ assertThat(integrityInformation.getAsJsonObject("rootSignature").get("alg").getAsString())
+ .isEqualTo("HS256");
+ assertThat(integrityInformation.get("segmentHashAlg").getAsString()).isEqualTo("GMAC");
+ }
+
+ // ------------------------------------------------------------ legacy 4.2.2
+
+ @ParameterizedTest
+ @EnumSource(Config.IntegrityAlgorithm.class)
+ void legacyHexEncodedRootStillValidates(Config.IntegrityAlgorithm segmentAlgorithm) throws IOException {
+ // 4.2.x files hex-encode the root signature and each segment hash before base64.
+ // Splitting calculateSignature must not have disturbed that.
+ var plaintext = fourSegmentPlaintext();
+ var tdfBytes = createTdf(plaintext,
+ Config.withTargetMode("4.2.2"),
+ withSegmentAlgorithm(segmentAlgorithm));
+
+ var manifest = JsonParser.parseString(manifestOf(tdfBytes)).getAsJsonObject();
+ assertThat(manifest.has("schemaVersion")).isFalse();
+ var integrityInformation = integrityInformation(manifest);
+ var rootSignature = Base64.getDecoder()
+ .decode(integrityInformation.getAsJsonObject("rootSignature").get("sig").getAsString());
+ assertThat(new String(rootSignature, StandardCharsets.UTF_8)).matches("[0-9a-f]{64}");
+
+ assertThat(decrypt(tdfBytes)).containsExactly(plaintext);
+ }
+
+ @Test
+ void legacyGmacRootIsRejected() throws IOException {
+ // The legacy hex-encoding path must not become a way around the allowlist.
+ var tampered = rewrite(createTdf(fourSegmentPlaintext(), Config.withTargetMode("4.2.2")),
+ manifest -> rootSignature(manifest).addProperty("alg", "GMAC"),
+ UnaryOperator.identity());
+
+ assertThatThrownBy(() -> decrypt(tampered))
+ .isInstanceOf(SDK.RootSignatureValidationException.class);
+ }
+
+ // ------------------------------------------------------------------ config
+
+ @Test
+ void createTdfRefusesAGmacRootSetDirectlyOnTheConfig() {
+ // Config offers no way to select the root algorithm, but TDFConfig's fields are
+ // public, so the writer re-checks rather than trusting the default.
+ var config = tdfConfig();
+ config.integrityAlgorithm = Config.IntegrityAlgorithm.GMAC;
+
+ var tdf = tdf();
+ var payload = new ByteArrayInputStream("hello".getBytes(StandardCharsets.UTF_8));
+ var output = new ByteArrayOutputStream();
+
+ assertThatThrownBy(() -> tdf.createTDF(payload, output, config))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("unsupported root integrity algorithm");
+ }
+
+ @Test
+ void createTdfRefusesAnUnsetSegmentAlgorithm() {
+ // Same reasoning as above, for the segment field: public and therefore nullable.
+ // Caught before any output is written rather than as a NullPointerException
+ // partway through the payload.
+ var config = tdfConfig();
+ config.segmentIntegrityAlgorithm = null;
+
+ var tdf = tdf();
+ var payload = new ByteArrayInputStream("hello".getBytes(StandardCharsets.UTF_8));
+ var output = new ByteArrayOutputStream();
+
+ assertThatThrownBy(() -> tdf.createTDF(payload, output, config))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("unsupported segment integrity algorithm");
+ assertThat(output.size()).isZero();
+ }
+
+ // ------------------------------------------------------- guards, called directly
+
+ /*
+ * The manifest resolvers above are what a hostile file actually meets, and the
+ * round-trip tests cover them. These call the inner guards directly because nothing
+ * else does: with the resolvers in place a GMAC root cannot reach `rootIntegrity`,
+ * so without these a regression that reintroduced tag extraction there would leave
+ * the whole suite green. Defence in depth is only depth if the inner layer is held
+ * to its contract independently.
+ */
+
+ @Test
+ void rootIntegrityRefusesGmacWhenCalledDirectly() {
+ assertThatThrownBy(() -> TDF.rootIntegrity(new byte[64], new byte[32], Config.IntegrityAlgorithm.GMAC))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("unsupported root integrity algorithm");
+ }
+
+ @Test
+ void rootIntegrityRefusesNullWhenCalledDirectly() {
+ assertThatThrownBy(() -> TDF.rootIntegrity(new byte[64], new byte[32], null))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("unsupported root integrity algorithm");
+ }
+
+ @Test
+ void rootIntegrityAcceptsHs256() {
+ assertThat(TDF.rootIntegrity(new byte[64], new byte[32], Config.IntegrityAlgorithm.HS256))
+ .hasSize(32);
+ }
+
+ @Test
+ void segmentIntegrityRefusesNullWhenCalledDirectly() {
+ assertThatThrownBy(() -> TDF.segmentIntegrity(new byte[64], new byte[32], null))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("unsupported segment integrity algorithm");
+ }
+
+ // ----------------------------------------------------------------- fixtures
+
+ /**
+ * Three full segments and a partial one, each filled with a distinct byte so a
+ * reordered or truncated decryption would be visible in the output and not only in
+ * an error. Deliberately not an exact multiple of the segment size, which would add
+ * a trailing empty segment.
+ */
+ private static byte[] fourSegmentPlaintext() {
+ var plaintext = new byte[3 * SEGMENT_SIZE + SEGMENT_SIZE / 2];
+ for (int index = 0; index < plaintext.length; index++) {
+ plaintext[index] = (byte) ('A' + index / SEGMENT_SIZE);
+ }
+ return plaintext;
+ }
+
+ private static TDF tdf() {
+ return new TDF(new FakeServicesBuilder().setKas(KAS).build());
+ }
+
+ /**
+ * Writers get GMAC segment hashes and nothing else -- Config has no setter for this.
+ * These tests reach past that to cover the HS256 segments other implementations
+ * write and this SDK must still read.
+ */
+ private static Consumer