From 66b25e2b58b8dcaf689cc7c6972004dac752fbb0 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Thu, 10 Sep 2026 11:37:59 -0400 Subject: [PATCH] fix(sdk): reject GMAC root signatures on read (DSPX-4703) A ZTDF's root signature is the only thing that authenticates the manifest's ordered list of segment hashes. AES-GCM tags bind a segment's own bytes and nothing about its index, its neighbours, or how many segments there are, so segment-level integrity cannot notice a truncated, reordered, or duplicated segment list. One signature routine served both jobs. For a segment, "GMAC" correctly means reading back the AES-GCM tag the cipher just computed over that segment's ciphertext. For the root it means nothing: the aggregate hash never passes through AES-GCM, so there is no tag to recover and the code returned a copy of the trailing bytes of its own input, i.e. the last segment hash. Manifest data compared against manifest data, with the payload key never used. The algorithm was read from `rootSignature.alg` in the manifest, which is not authenticated. An attacker with no key could therefore take an HS256-rooted TDF, rewrite the root to "GMAC" with a signature copied from the last segment hash, and then truncate, reorder, or duplicate segments with the file still verifying. Changes: - Split the routine into segmentIntegrity (HS256 or GMAC) and rootIntegrity (HS256 only), so tag extraction can no longer be pointed at a non-AEAD input. - rootIntegrityAlgorithmFromManifest resolves the root algorithm against an allowlist and throws SDK.RootSignatureValidationException for anything else, instead of coercing unknown values to HS256. - segmentIntegrityAlgorithmFromManifest stays permissive, since both algorithms are meaningful over ciphertext. - createTDF validates the configured root algorithm too, so the SDK will not write a file it would refuse to read. rootIntegrity validates its own argument in addition to its caller checking first. The redundancy is deliberate: the check is what makes the function safe, so it belongs with the function rather than only at today's call sites. Tests cover truncation, reordering, GMAC in several casings, an unknown algorithm that must not be coerced, and controls that must keep passing. Verified by mutation: restoring the old tag-extraction branch inside rootIntegrity turns exactly the 8 exploit tests red and leaves the other 21 green. Refs: DSPX-4703, and the write-side controls in DSPX-4736. Signed-off-by: Dave Mihalcik --- .../java/io/opentdf/platform/sdk/TDF.java | 194 +++++- .../platform/sdk/TDFRootSignatureTest.java | 585 ++++++++++++++++++ 2 files changed, 753 insertions(+), 26 deletions(-) create mode 100644 sdk/src/test/java/io/opentdf/platform/sdk/TDFRootSignatureTest.java 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> splits = planner.getSplits(); @@ -526,7 +665,7 @@ TDFObject createTDF(InputStream payload, OutputStream outputStream, Config.TDFCo readBuf, 0, readThisLoop); payloadOutput.write(cipherData); - segmentSig = calculateSignature(cipherData, tdfObject.payloadKey, tdfConfig.segmentIntegrityAlgorithm); + segmentSig = segmentIntegrity(cipherData, tdfObject.payloadKey, tdfConfig.segmentIntegrityAlgorithm); if (tdfConfig.hexEncodeRootAndSegmentHashes) { segmentSig = Hex.encodeHexString(segmentSig).getBytes(StandardCharsets.UTF_8); } @@ -542,18 +681,17 @@ TDFObject createTDF(InputStream payload, OutputStream outputStream, Config.TDFCo Manifest.RootSignature rootSignature = new Manifest.RootSignature(); - byte[] rootSig = calculateSignature(aggregateHash.toByteArray(), tdfObject.payloadKey, + byte[] rootSig = rootIntegrity(aggregateHash.toByteArray(), tdfObject.payloadKey, tdfConfig.integrityAlgorithm); byte[] encodedRootSig = tdfConfig.hexEncodeRootAndSegmentHashes ? Hex.encodeHexString(rootSig).getBytes(StandardCharsets.UTF_8) : rootSig; rootSignature.signature = Base64.getEncoder().encodeToString(encodedRootSig); - String alg = kGmacIntegrityAlgorithm; - if (tdfConfig.integrityAlgorithm == Config.IntegrityAlgorithm.HS256) { - alg = kHmacIntegrityAlgorithm; - } - rootSignature.algorithm = alg; + // Unconditional. createTDF refuses any other root algorithm before a byte is + // written and rootIntegrity would refuse it again; selecting on tdfConfig here + // would leave a path that emits alg="GMAC" should either check ever be relaxed. + rootSignature.algorithm = kHmacIntegrityAlgorithm; tdfObject.manifest.encryptionInformation.integrityInformation.rootSignature = rootSignature; tdfObject.manifest.encryptionInformation.integrityInformation.segmentSizeDefault = tdfConfig.defaultSegmentSize; @@ -757,17 +895,21 @@ Reader loadTDF(SeekableByteChannel tdf, Config.TDFReaderConfig tdfReaderConfig) String rootSigValue; boolean isLegacyTdf = manifest.tdfVersion == null || manifest.tdfVersion.isEmpty(); if (manifest.payload.isEncrypted) { - Config.IntegrityAlgorithm sigAlg = Config.IntegrityAlgorithm.HS256; - if (rootAlgorithm.compareToIgnoreCase(kGmacIntegrityAlgorithm) == 0) { - sigAlg = Config.IntegrityAlgorithm.GMAC; - } + var sigAlg = rootIntegrityAlgorithmFromManifest(rootAlgorithm); - var sig = calculateSignature(aggregateHash.toByteArray(), payloadKey, sigAlg); + var sig = rootIntegrity(aggregateHash.toByteArray(), payloadKey, sigAlg); if (isLegacyTdf) { sig = Hex.encodeHexString(sig).getBytes(); } rootSigValue = Base64.getEncoder().encodeToString(sig); } else { + // KNOWN GAP, untouched by this change and tracked separately: this branch is a + // bare SHA-256, so it authenticates nothing. `payload.isEncrypted` is itself + // unauthenticated manifest data, so flipping it to false selects a keyless + // verification path that anyone can satisfy -- and readPayload then emits the + // segment bytes without decrypting them. Same downgrade shape as a GMAC root. + // Left alone here because closing it means deciding whether unencrypted TDFs + // are supported at all, which is a wider question than this fix. MessageDigest digest; try { digest = MessageDigest.getInstance("SHA-256"); diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/TDFRootSignatureTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/TDFRootSignatureTest.java new file mode 100644 index 00000000..b6a4c8de --- /dev/null +++ b/sdk/src/test/java/io/opentdf/platform/sdk/TDFRootSignatureTest.java @@ -0,0 +1,585 @@ +package io.opentdf.platform.sdk; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +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.EnumSource; +import org.junit.jupiter.params.provider.ValueSource; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.security.KeyPair; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Collections; +import java.util.List; +import java.util.function.Consumer; +import java.util.function.UnaryOperator; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * The root signature is the only thing in a ZTDF that authenticates the + * manifest's description of the payload: the ordered list of segment hashes. + * Per-segment AES-GCM tags authenticate each segment's bytes in isolation, but nothing + * in a segment binds it to its index or to the total segment count, so a manifest that + * lies about the segment list is only caught by the root signature. + *

+ * 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 withSegmentAlgorithm(Config.IntegrityAlgorithm algorithm) { + return config -> config.segmentIntegrityAlgorithm = algorithm; + } + + @SafeVarargs + private static Config.TDFConfig tdfConfig(Consumer... extras) { + var kasInfo = new Config.KASInfo(); + kasInfo.URL = KAS_URL; + + var options = new ArrayList>(List.of( + Config.withAutoconfigure(false), + Config.withKasInformation(kasInfo), + Config.withSegmentSize(SEGMENT_SIZE))); + Collections.addAll(options, extras); + + @SuppressWarnings("unchecked") + Consumer[] asArray = options.toArray(new Consumer[0]); + return Config.newTDFConfig(asArray); + } + + @SafeVarargs + private static byte[] createTdf(byte[] plaintext, Consumer... extras) throws IOException { + var tdfBytes = new ByteArrayOutputStream(); + tdf().createTDF(new ByteArrayInputStream(plaintext), tdfBytes, tdfConfig(extras)); + return tdfBytes.toByteArray(); + } + + private static byte[] decrypt(byte[] tdfBytes) throws IOException { + var reader = tdf().loadTDF(new SeekableInMemoryByteChannel(tdfBytes), + Config.newTDFReaderConfig(Config.WithIgnoreKasAllowlist(true))); + var plaintext = new ByteArrayOutputStream(); + reader.readPayload(plaintext); + return plaintext.toByteArray(); + } + + // ------------------------------------------------------- manifest surgery + + private static JsonObject integrityInformation(JsonObject manifest) { + return manifest.getAsJsonObject("encryptionInformation").getAsJsonObject("integrityInformation"); + } + + private static JsonObject rootSignature(JsonObject manifest) { + return integrityInformation(manifest).getAsJsonObject("rootSignature"); + } + + private static JsonArray segments(JsonObject manifest) { + return integrityInformation(manifest).getAsJsonArray("segments"); + } + + private static void keepSegments(JsonObject manifest, int count) { + var kept = new JsonArray(); + var all = segments(manifest); + for (int index = 0; index < count; index++) { + kept.add(all.get(index)); + } + integrityInformation(manifest).add("segments", kept); + } + + private static void reverseSegments(JsonObject manifest) { + var reversed = new JsonArray(); + var all = segments(manifest); + for (int index = all.size() - 1; index >= 0; index--) { + reversed.add(all.get(index)); + } + integrityInformation(manifest).add("segments", reversed); + } + + /** + * Rewrites the root signature exactly the way an attacker with no key can: declare + * GMAC, then emit the trailing sixteen bytes of the aggregate hash. Every input is + * manifest data the attacker already controls. + */ + private static void forgeGmacRootSignature(JsonObject manifest, String declaredAlgorithm) { + var aggregate = new ByteArrayOutputStream(); + for (var segment : segments(manifest)) { + var hash = Base64.getDecoder().decode(segment.getAsJsonObject().get("hash").getAsString()); + aggregate.write(hash, 0, hash.length); + } + + var bytes = aggregate.toByteArray(); + var forged = new byte[16]; + System.arraycopy(bytes, bytes.length - forged.length, forged, 0, forged.length); + + var rootSignature = rootSignature(manifest); + rootSignature.addProperty("alg", declaredAlgorithm); + rootSignature.addProperty("sig", Base64.getEncoder().encodeToString(forged)); + } + + private static List encryptedSegmentSizes(byte[] tdfBytes) throws IOException { + var sizes = new ArrayList(); + for (var segment : segments(JsonParser.parseString(manifestOf(tdfBytes)).getAsJsonObject())) { + sizes.add(segment.getAsJsonObject().get("encryptedSegmentSize").getAsInt()); + } + return sizes; + } + + private static byte[] flipByte(byte[] payload, int index) { + var copy = payload.clone(); + copy[index] ^= 0xFF; + return copy; + } + + private static byte[] reverseChunks(byte[] payload, List sizes) { + var reversed = new byte[payload.length]; + int destination = 0; + int sourceEnd = payload.length; + for (int index = sizes.size() - 1; index >= 0; index--) { + int size = sizes.get(index); + System.arraycopy(payload, sourceEnd - size, reversed, destination, size); + destination += size; + sourceEnd -= size; + } + return reversed; + } + + // ------------------------------------------------------------ zip plumbing + + private static String manifestOf(byte[] tdfBytes) throws IOException { + return new String(entry(tdfBytes, TDFWriter.TDF_MANIFEST_FILE_NAME), StandardCharsets.UTF_8); + } + + private static byte[] entry(byte[] tdfBytes, String name) throws IOException { + for (var entry : new ZipReader(new SeekableInMemoryByteChannel(tdfBytes)).getEntries()) { + if (entry.getName().equals(name)) { + try (InputStream data = entry.getData()) { + var out = new ByteArrayOutputStream(); + data.transferTo(out); + return out.toByteArray(); + } + } + } + throw new IllegalArgumentException("no entry named " + name); + } + + /** + * Unzips a TDF, applies the given edits to its manifest and payload, and zips it back + * up. Everything here is available to an attacker who has the file and no key. + */ + private static byte[] rewrite(byte[] tdfBytes, Consumer editManifest, + UnaryOperator editPayload) throws IOException { + var manifest = JsonParser.parseString(manifestOf(tdfBytes)).getAsJsonObject(); + editManifest.accept(manifest); + var payload = editPayload.apply(entry(tdfBytes, TDFWriter.TDF_PAYLOAD_FILE_NAME)); + + var rewritten = new ByteArrayOutputStream(); + var writer = new TDFWriter(rewritten); + try (var payloadOut = writer.payload()) { + payloadOut.write(payload); + } + writer.appendManifest(new Gson().toJson(manifest)); + writer.finish(); + return rewritten.toByteArray(); + } +}