From a8584c85d744b3f210666b1f6acc4861ea2415aa Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Sun, 8 Nov 2026 17:51:51 -0500 Subject: [PATCH] fix(sdk): DSPX-4748 reject TDFs that declare an unencrypted payload loadTDF now rejects a manifest whose payload.isEncrypted is not true (false, null or absent) with MalformedTDFException, immediately after the manifest is parsed and before any key access, integrity or assertion processing. Segments and the root signature are always checked with the payload key, segments are always decrypted, and the segment-hash aggregate is always built from base64-decoded hashes. The writer continues to set isEncrypted to true. Signed-off-by: Dave Mihalcik --- .../io/opentdf/platform/sdk/Manifest.java | 24 +- .../java/io/opentdf/platform/sdk/TDF.java | 213 ++++++------- .../io/opentdf/platform/sdk/SegmentsTest.java | 7 +- .../sdk/TDFUnencryptedPayloadTest.java | 283 ++++++++++++++++++ 4 files changed, 380 insertions(+), 147 deletions(-) create mode 100644 sdk/src/test/java/io/opentdf/platform/sdk/TDFUnencryptedPayloadTest.java 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 5b5edc39..cc84e9d7 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/Manifest.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/Manifest.java @@ -326,26 +326,17 @@ private static int trailingPadding(String hash) { } /** - * Every segment hash concatenated, base64-decoded if {@code decodeBase64} - * and taken as raw ASCII otherwise. This is the aggregate hash that the root - * signature and the assertion signatures are computed over. + * Every segment hash base64-decoded and concatenated. This is the aggregate + * hash that the root signature and the assertion signatures are computed over. */ - byte[] aggregate(boolean decodeBase64) { + byte[] aggregate() { if (size() == 0) { return new byte[0]; } if (mutableSegments != null || !materializedSegments.isEmpty()) { - return aggregateMaterialized(decodeBase64); + return aggregateMaterialized(); } byte[] scratch = new byte[stride]; - if (!decodeBase64) { - byte[] aggregate = new byte[Math.multiplyExact(count, stride)]; - for (int i = 0; i < count; i++) { - copyHash(i, aggregate, i * stride); - } - return aggregate; - } - copyHash(0, scratch, 0); int decodedStride = Base64.getDecoder().decode(scratch).length; byte[] aggregate = new byte[Math.multiplyExact(count, decodedStride)]; @@ -358,13 +349,10 @@ byte[] aggregate(boolean decodeBase64) { return aggregate; } - private byte[] aggregateMaterialized(boolean decodeBase64) { + private byte[] aggregateMaterialized() { ByteArrayOutputStream aggregate = new ByteArrayOutputStream(); for (int i = 0; i < size(); i++) { - String hash = getForRead(i).hash; - byte[] bytes = decodeBase64 - ? Base64.getDecoder().decode(hash) - : hash.getBytes(StandardCharsets.US_ASCII); + byte[] bytes = Base64.getDecoder().decode(getForRead(i).hash); aggregate.write(bytes, 0, bytes.length); } return aggregate.toByteArray(); 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 dd000a8d..5b017995 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java @@ -433,13 +433,6 @@ public void readPayload(OutputStream outputStream, long offset, long length) return; } - MessageDigest digest = null; - try { - digest = MessageDigest.getInstance("SHA-256"); - } catch (NoSuchAlgorithmException e) { - throw new IllegalStateException("error getting instance of SHA-256", e); - } - try (var payload = tdfReader.payload()) { long plainStart = 0; for (int i = 0; i < segments.size(); i++) { @@ -455,7 +448,7 @@ public void readPayload(OutputStream outputStream, long offset, long length) + segment.encryptedSegmentSize + " bytes"); } } else { - byte[] plain = readSegment(payload, segment, digest); + byte[] plain = readSegment(payload, segment); int from = (int) Math.max(0, offset - plainStart); int to = (int) Math.min(plain.length, end - plainStart); outputStream.write(plain, from, to - from); @@ -468,11 +461,10 @@ public void readPayload(OutputStream outputStream, long offset, long length) // Derived from the ciphertext length rather than trusting the manifest's segmentSize, // so a range lands on the bytes decryption actually produces. private long plaintextSize(Manifest.Segment segment) { - return segment.encryptedSegmentSize - - (manifest.payload.isEncrypted ? kGcmIvSize + AesGcm.GCM_TAG_LENGTH : 0); + return segment.encryptedSegmentSize - (kGcmIvSize + AesGcm.GCM_TAG_LENGTH); } - private byte[] readSegment(InputStream payload, Manifest.Segment segment, MessageDigest digest) { + private byte[] readSegment(InputStream payload, Manifest.Segment segment) { byte[] readBuf = new byte[(int) segment.encryptedSegmentSize]; int bytesRead = TDFReader.readPayloadBytes(payload, readBuf); @@ -483,28 +475,19 @@ private byte[] readSegment(InputStream payload, Manifest.Segment segment, Messag var isLegacyTdf = manifest.tdfVersion == null || manifest.tdfVersion.isEmpty(); - if (manifest.payload.isEncrypted) { - var sigAlg = segmentIntegrityAlgorithmFromManifest( - manifest.encryptionInformation.integrityInformation.segmentHashAlg); - - var payloadSig = calculateSignature(readBuf, payloadKey, sigAlg); - if (isLegacyTdf) { - payloadSig = Hex.encodeHexString(payloadSig).getBytes(StandardCharsets.UTF_8); - } + var sigAlg = segmentIntegrityAlgorithmFromManifest( + manifest.encryptionInformation.integrityInformation.segmentHashAlg); - if (segment.hash.compareTo(Base64.getEncoder().encodeToString(payloadSig)) != 0) { - throw new SDK.SegmentSignatureMismatch("segment signature miss match"); - } - - return aesGcm.decrypt(new AesGcm.Encrypted(readBuf)); - } else { - String segmentSig = Hex.encodeHexString(digest.digest(readBuf)); - if (segment.hash.compareTo(segmentSig) != 0) { - throw new SDK.SegmentSignatureMismatch("segment signature miss match"); - } + var payloadSig = calculateSignature(readBuf, payloadKey, sigAlg); + if (isLegacyTdf) { + payloadSig = Hex.encodeHexString(payloadSig).getBytes(StandardCharsets.UTF_8); + } - return readBuf; + if (segment.hash.compareTo(Base64.getEncoder().encodeToString(payloadSig)) != 0) { + throw new SDK.SegmentSignatureMismatch("segment signature miss match"); } + + return aesGcm.decrypt(new AesGcm.Encrypted(readBuf)); } /** @@ -518,9 +501,8 @@ private byte[] readSegment(InputStream payload, Manifest.Segment segment, Messag */ 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; + // can never be shorter than the two of them together + long minEncryptedSegmentSize = kGcmIvSize + AesGcm.GCM_TAG_LENGTH; List segments = manifest.encryptionInformation.integrityInformation.segments; for (int i = 0; i < segments.size(); i++) { @@ -573,15 +555,13 @@ private static Config.IntegrityAlgorithm segmentIntegrityAlgorithmFromManifest(S * Concatenates every segment hash, which is what the root signature and the * assertion signatures are computed over. */ - private static byte[] aggregateSegmentHashes(List segments, boolean isEncrypted) { + private static byte[] aggregateSegmentHashes(List segments) { if (segments instanceof Manifest.Segments) { - return ((Manifest.Segments) segments).aggregate(isEncrypted); + return ((Manifest.Segments) segments).aggregate(); } var aggregateHash = new ByteArrayOutputStream(); for (Manifest.Segment segment : segments) { - aggregateHash.writeBytes(isEncrypted - ? Base64.getDecoder().decode(segment.hash) - : segment.hash.getBytes()); + aggregateHash.writeBytes(Base64.getDecoder().decode(segment.hash)); } return aggregateHash.toByteArray(); } @@ -727,7 +707,7 @@ TDFObject createTDF(InputStream payload, OutputStream outputStream, Config.TDFCo // Materialized once and reused by the root signature and every assertion below; // it is proportional to the number of segments. - byte[] aggregateHash = segments.aggregate(true); + byte[] aggregateHash = segments.aggregate(); Manifest.RootSignature rootSignature = new Manifest.RootSignature(); @@ -851,6 +831,14 @@ Reader loadTDF(SeekableByteChannel tdf, Config.TDFReaderConfig tdfReaderConfig) TDFReader tdfReader = TDFReader.open(tdf); Manifest manifest = tdfReader.readManifest(); + // Only encrypted payloads are supported. A manifest that does not declare one, whether + // the flag is false, null or absent (Gson leaves an absent primitive at false), is + // refused here, before any key access, integrity or assertion processing. + if (!manifest.payload.isEncrypted) { + throw new SDK.MalformedTDFException( + "tdf manifest is invalid: payload.isEncrypted must be true; unencrypted payloads are not supported"); + } + byte[] payloadKey = new byte[GCM_KEY_SIZE]; String unencryptedMetadata = null; @@ -859,75 +847,73 @@ Reader loadTDF(SeekableByteChannel tdf, Config.TDFReaderConfig tdfReaderConfig) Map skippedSplits = new HashMap<>(); - if (manifest.payload.isEncrypted) { - for (Manifest.KeyAccess keyAccess : manifest.encryptionInformation.keyAccessObj) { - String splitId = keyAccess.sid == null || keyAccess.sid.isEmpty() ? EMPTY_SPLIT_ID : keyAccess.sid; - Autoconfigure.KeySplitStep ss = new Autoconfigure.KeySplitStep(keyAccess.url, splitId); - byte[] unwrappedKey; - if (foundSplits.contains(ss.splitID)) { - continue; - } - knownSplits.add(ss.splitID); - try { - var realAddress = Config.getKasAddress(keyAccess.url); - if (tdfReaderConfig.ignoreKasAllowlist) { - logger.warn("Ignoring KasAllowlist for url {}", realAddress); - } else if (tdfReaderConfig.kasAllowlist == null || tdfReaderConfig.kasAllowlist.isEmpty()) { - logger.error( - "KasAllowlist: No KAS allowlist provided and no KeyAccessServerRegistry available, {} is not allowed", - realAddress); - throw new SDK.KasAllowlistException( - "No KAS allowlist provided and no KeyAccessServerRegistry available"); - } else if (!tdfReaderConfig.kasAllowlist.contains(realAddress)) { - logger.error("KasAllowlist: kas url {} is not allowed", realAddress); - throw new SDK.KasAllowlistException("KasAllowlist: kas url " + realAddress + " is not allowed"); - } - unwrappedKey = services.kas().unwrap(keyAccess, manifest.encryptionInformation.policy, - tdfReaderConfig.sessionKeyType); - } catch (Exception e) { - skippedSplits.put(ss, e); - continue; + for (Manifest.KeyAccess keyAccess : manifest.encryptionInformation.keyAccessObj) { + String splitId = keyAccess.sid == null || keyAccess.sid.isEmpty() ? EMPTY_SPLIT_ID : keyAccess.sid; + Autoconfigure.KeySplitStep ss = new Autoconfigure.KeySplitStep(keyAccess.url, splitId); + byte[] unwrappedKey; + if (foundSplits.contains(ss.splitID)) { + continue; + } + knownSplits.add(ss.splitID); + try { + var realAddress = Config.getKasAddress(keyAccess.url); + if (tdfReaderConfig.ignoreKasAllowlist) { + logger.warn("Ignoring KasAllowlist for url {}", realAddress); + } else if (tdfReaderConfig.kasAllowlist == null || tdfReaderConfig.kasAllowlist.isEmpty()) { + logger.error( + "KasAllowlist: No KAS allowlist provided and no KeyAccessServerRegistry available, {} is not allowed", + realAddress); + throw new SDK.KasAllowlistException( + "No KAS allowlist provided and no KeyAccessServerRegistry available"); + } else if (!tdfReaderConfig.kasAllowlist.contains(realAddress)) { + logger.error("KasAllowlist: kas url {} is not allowed", realAddress); + throw new SDK.KasAllowlistException("KasAllowlist: kas url " + realAddress + " is not allowed"); } + unwrappedKey = services.kas().unwrap(keyAccess, manifest.encryptionInformation.policy, + tdfReaderConfig.sessionKeyType); + } catch (Exception e) { + skippedSplits.put(ss, e); + continue; + } - for (int index = 0; index < unwrappedKey.length; index++) { - payloadKey[index] ^= unwrappedKey[index]; - } - foundSplits.add(ss.splitID); + for (int index = 0; index < unwrappedKey.length; index++) { + payloadKey[index] ^= unwrappedKey[index]; + } + foundSplits.add(ss.splitID); - if (keyAccess.encryptedMetadata != null && !keyAccess.encryptedMetadata.isEmpty()) { - AesGcm aesGcm = new AesGcm(unwrappedKey); + if (keyAccess.encryptedMetadata != null && !keyAccess.encryptedMetadata.isEmpty()) { + AesGcm aesGcm = new AesGcm(unwrappedKey); - String decodedMetadata = new String(Base64.getDecoder().decode(keyAccess.encryptedMetadata), - StandardCharsets.UTF_8); - EncryptedMetadata encryptedMetadata = gson.fromJson(decodedMetadata, EncryptedMetadata.class); + String decodedMetadata = new String(Base64.getDecoder().decode(keyAccess.encryptedMetadata), + StandardCharsets.UTF_8); + EncryptedMetadata encryptedMetadata = gson.fromJson(decodedMetadata, EncryptedMetadata.class); - var encryptedData = new AesGcm.Encrypted( - decoder.decode(encryptedMetadata.ciphertext)); + var encryptedData = new AesGcm.Encrypted( + decoder.decode(encryptedMetadata.ciphertext)); - byte[] decrypted = aesGcm.decrypt(encryptedData); - // this is a little bit weird... the last unencrypted metadata we get from a KAS - // is the one - // that we return to the user. This is OK because we can't have different - // metadata per-KAS - unencryptedMetadata = new String(decrypted, StandardCharsets.UTF_8); - } + byte[] decrypted = aesGcm.decrypt(encryptedData); + // this is a little bit weird... the last unencrypted metadata we get from a KAS + // is the one + // that we return to the user. This is OK because we can't have different + // metadata per-KAS + unencryptedMetadata = new String(decrypted, StandardCharsets.UTF_8); } + } - if (knownSplits.size() > foundSplits.size()) { - List exceptionList = new ArrayList<>(skippedSplits.size() + 1); - exceptionList.add(new Exception("splitKey.unable to reconstruct split key: " + skippedSplits)); - - for (Map.Entry entry : skippedSplits.entrySet()) { - exceptionList.add(entry.getValue()); - } + if (knownSplits.size() > foundSplits.size()) { + List exceptionList = new ArrayList<>(skippedSplits.size() + 1); + exceptionList.add(new Exception("splitKey.unable to reconstruct split key: " + skippedSplits)); - StringBuilder combinedMessage = new StringBuilder(); - for (Exception e : exceptionList) { - combinedMessage.append(e.getMessage()).append("\n"); - } + for (Map.Entry entry : skippedSplits.entrySet()) { + exceptionList.add(entry.getValue()); + } - throw new SDK.SplitKeyException(combinedMessage.toString()); + StringBuilder combinedMessage = new StringBuilder(); + for (Exception e : exceptionList) { + combinedMessage.append(e.getMessage()).append("\n"); } + + throw new SDK.SplitKeyException(combinedMessage.toString()); } // Validate root signature @@ -935,37 +921,16 @@ Reader loadTDF(SeekableByteChannel tdf, Config.TDFReaderConfig tdfReaderConfig) String rootSignature = manifest.encryptionInformation.integrityInformation.rootSignature.signature; byte[] aggregateHash = aggregateSegmentHashes( - manifest.encryptionInformation.integrityInformation.segments, manifest.payload.isEncrypted); + manifest.encryptionInformation.integrityInformation.segments); - String rootSigValue; boolean isLegacyTdf = manifest.tdfVersion == null || manifest.tdfVersion.isEmpty(); - if (manifest.payload.isEncrypted) { - var sigAlg = rootIntegrityAlgorithmFromManifest(rootAlgorithm); - - var sig = calculateSignature(aggregateHash, 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"); - } catch (NoSuchAlgorithmException e) { - throw new IllegalStateException("error getting instance of SHA-256 digest", e); - } + var sigAlg = rootIntegrityAlgorithmFromManifest(rootAlgorithm); - // the round trip through the platform default charset is a no-op for the - // hex hashes this branch sees, and is kept so the digest is unchanged - rootSigValue = Base64.getEncoder().encodeToString(digest.digest(new String(aggregateHash).getBytes())); + var sig = calculateSignature(aggregateHash, payloadKey, sigAlg); + if (isLegacyTdf) { + sig = Hex.encodeHexString(sig).getBytes(); } + String rootSigValue = Base64.getEncoder().encodeToString(sig); if (rootSignature.compareTo(rootSigValue) != 0) { throw new SDK.RootSignatureValidationException("root signature validation failed"); diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/SegmentsTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/SegmentsTest.java index 9333d57c..3e838eaa 100644 --- a/sdk/src/test/java/io/opentdf/platform/sdk/SegmentsTest.java +++ b/sdk/src/test/java/io/opentdf/platform/sdk/SegmentsTest.java @@ -134,9 +134,7 @@ void aggregateConcatenatesEveryHash() { for (int i = 0; i < 3; i++) { System.arraycopy(Base64.getDecoder().decode(hash(i)), 0, decoded, i * 16, 16); } - assertThat(segments.aggregate(true)).isEqualTo(decoded); - assertThat(segments.aggregate(false)) - .isEqualTo((hash(0) + hash(1) + hash(2)).getBytes(java.nio.charset.StandardCharsets.US_ASCII)); + assertThat(segments.aggregate()).isEqualTo(decoded); } @Test @@ -166,8 +164,7 @@ void parsedCompactSegmentsPreserveElementAndListMutations() { @Test void emptySegmentsAggregateToNothing() { - assertThat(new Manifest.Segments().aggregate(true)).isEmpty(); - assertThat(new Manifest.Segments().aggregate(false)).isEmpty(); + assertThat(new Manifest.Segments().aggregate()).isEmpty(); } @Test diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/TDFUnencryptedPayloadTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/TDFUnencryptedPayloadTest.java new file mode 100644 index 00000000..c37c0924 --- /dev/null +++ b/sdk/src/test/java/io/opentdf/platform/sdk/TDFUnencryptedPayloadTest.java @@ -0,0 +1,283 @@ +package io.opentdf.platform.sdk; + +import com.google.gson.JsonNull; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.nimbusds.jose.KeyLengthException; +import org.apache.commons.codec.binary.Hex; +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 org.mockito.AdditionalAnswers; +import org.mockito.Mockito; + +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.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Base64; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; + +/** + * The reader only supports encrypted payloads. A manifest that does not declare one is refused + * as malformed, before any key access, integrity or assertion processing. + */ +class TDFUnencryptedPayloadTest { + + private static final int SEGMENT_SIZE = Config.MIN_SEGMENT_SIZE; + private static final String KAS_URL = "https://example.com/kas0"; + private static final String ASSERTION_ID = "assertion1"; + /** What the reader's payload key starts as before any split is folded in. */ + private static final byte[] ZERO_KEY = new byte[32]; + + private static KeyPair kasKeyPair; + + private static final SDK.KAS FAKE_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(); + } + + @Test + void untouchedTdfLoads() throws IOException { + // Positive control, for the TDF and for the KAS spy: the negative cases below + // only mean something if this one unwraps through the spy and reads back. + var plaintext = plaintext('A'); + var kas = kasSpy(); + + var reader = load(createTdf(plaintext), kas); + + assertThat(readAll(reader)).containsExactly(plaintext); + assertThat(reader.getManifest().payload.isEncrypted).isTrue(); + Mockito.verify(kas, Mockito.atLeastOnce()).unwrap(any(), any(), any()); + } + + @ParameterizedTest + @ValueSource(strings = { "false", "absent", "null" }) + void manifestNotDeclaringAnEncryptedPayloadIsRejected(String flag) throws IOException { + // Every other value in the manifest is consistent with a payload stored as-is, so + // the flag is the only thing that can cause the rejection. + var unencrypted = rewriteAsUnencrypted(createTdf(plaintext('A')), plaintext('Z'), flag); + var kas = kasSpy(); + + assertThatThrownBy(() -> load(unencrypted, kas)) + .isInstanceOf(SDK.MalformedTDFException.class) + .hasMessageContaining("isEncrypted"); + Mockito.verifyNoInteractions(kas); + } + + @Test + void flippingTheFlagAloneIsRejectedAsMalformed() throws IOException { + // Nothing else edited, so the rejection is the flag check itself and not a later + // integrity failure. + var tdf = createTdf(plaintext('A')); + var manifest = JsonParser.parseString(manifestOf(tdf)).getAsJsonObject(); + manifest.getAsJsonObject("payload").addProperty("isEncrypted", false); + var tampered = zip(manifest.toString(), entry(tdf, TDFWriter.TDF_PAYLOAD_FILE_NAME)); + var kas = kasSpy(); + + assertThatThrownBy(() -> load(tampered, kas)) + .isInstanceOf(SDK.MalformedTDFException.class) + .hasMessageContaining("isEncrypted"); + Mockito.verifyNoInteractions(kas); + } + + // ----------------------------------------------------------------- fixtures + + private static byte[] plaintext(char fill) { + var plaintext = new byte[2 * SEGMENT_SIZE + SEGMENT_SIZE / 2]; + Arrays.fill(plaintext, (byte) fill); + return plaintext; + } + + private static SDK.KAS kasSpy() { + return Mockito.mock(SDK.KAS.class, AdditionalAnswers.delegatesTo(FAKE_KAS)); + } + + private static byte[] createTdf(byte[] plaintext) throws IOException { + var kasInfo = new Config.KASInfo(); + kasInfo.URL = KAS_URL; + + var assertionConfig = new AssertionConfig(); + assertionConfig.id = ASSERTION_ID; + assertionConfig.type = AssertionConfig.Type.BaseAssertion; + assertionConfig.scope = AssertionConfig.Scope.TrustedDataObj; + assertionConfig.appliesToState = AssertionConfig.AppliesToState.Unencrypted; + assertionConfig.statement = new AssertionConfig.Statement(); + assertionConfig.statement.format = "base64binary"; + assertionConfig.statement.schema = "text"; + assertionConfig.statement.value = "ICAgIDxlZGoOkVkaD4="; + + var config = Config.newTDFConfig( + Config.withAutoconfigure(false), + Config.withKasInformation(kasInfo), + Config.withSegmentSize(SEGMENT_SIZE), + Config.withAssertionConfig(assertionConfig)); + + var tdfBytes = new ByteArrayOutputStream(); + new TDF(new FakeServicesBuilder().setKas(FAKE_KAS).build()) + .createTDF(new ByteArrayInputStream(plaintext), tdfBytes, config); + return tdfBytes.toByteArray(); + } + + /** Loads through the same entry point {@link SDK#loadTDF} uses, with an allowlist so no registry is consulted. */ + private static TDF.Reader load(byte[] tdfBytes, SDK.KAS kas) throws IOException { + return new TDF(new FakeServicesBuilder().setKas(kas).build()).loadTDF( + new SeekableInMemoryByteChannel(tdfBytes), + Config.newTDFReaderConfig(Config.WithKasAllowlist(KAS_URL)), + KAS_URL); + } + + private static byte[] readAll(TDF.Reader reader) throws IOException { + var out = new ByteArrayOutputStream(); + reader.readPayload(out); + return out.toByteArray(); + } + + // ------------------------------------------------------- manifest surgery + + /** + * Replaces the payload with {@code chosen}, stored as-is, recomputes the segment hashes, + * root signature and assertion binding to be consistent with an unencrypted payload, and + * sets {@code payload.isEncrypted} as {@code flag} says. + */ + private static byte[] rewriteAsUnencrypted(byte[] tdfBytes, byte[] chosen, String flag) throws IOException { + Manifest manifest = Manifest.readManifest(manifestOf(tdfBytes)); + var integrity = manifest.encryptionInformation.integrityInformation; + + var segments = new ArrayList(); + var aggregate = new ByteArrayOutputStream(); + for (int offset = 0; offset < chosen.length; offset += SEGMENT_SIZE) { + var bytes = Arrays.copyOfRange(chosen, offset, Math.min(chosen.length, offset + SEGMENT_SIZE)); + var segment = new Manifest.Segment(); + segment.hash = Hex.encodeHexString(sha256(bytes)); + segment.segmentSize = bytes.length; + segment.encryptedSegmentSize = bytes.length; + segments.add(segment); + aggregate.writeBytes(segment.hash.getBytes(StandardCharsets.US_ASCII)); + } + integrity.segments = segments; + integrity.rootSignature.signature = Base64.getEncoder().encodeToString(sha256(aggregate.toByteArray())); + rebindAssertions(manifest, aggregate.toByteArray(), ZERO_KEY); + + var json = JsonParser.parseString(Manifest.toJson(manifest)).getAsJsonObject(); + setFlag(json.getAsJsonObject("payload"), flag); + return zip(json.toString(), chosen); + } + + private static void setFlag(JsonObject payload, String flag) { + switch (flag) { + case "false": + payload.addProperty("isEncrypted", false); + break; + case "absent": + payload.remove("isEncrypted"); + break; + case "null": + payload.add("isEncrypted", JsonNull.INSTANCE); + break; + default: + throw new IllegalArgumentException(flag); + } + } + + /** Re-binds every assertion over {@code aggregate} with HS256 under {@code key}, as the writer would. */ + private static void rebindAssertions(Manifest manifest, byte[] aggregate, byte[] key) throws IOException { + assertThat(manifest.assertions).extracting(a -> a.id).containsExactly(ASSERTION_ID); + for (var assertion : manifest.assertions) { + assertion.binding = null; + var assertionHash = assertion.hash(); + byte[] assertionHashBytes; + try { + assertionHashBytes = Hex.decodeHex(assertionHash); + } catch (org.apache.commons.codec.DecoderException e) { + throw new IllegalStateException(e); + } + var signed = new byte[aggregate.length + assertionHashBytes.length]; + System.arraycopy(aggregate, 0, signed, 0, aggregate.length); + System.arraycopy(assertionHashBytes, 0, signed, aggregate.length, assertionHashBytes.length); + try { + assertion.sign( + new Manifest.Assertion.HashValues(assertionHash, Base64.getEncoder().encodeToString(signed)), + new AssertionConfig.AssertionKey(AssertionConfig.AssertionKeyAlg.HS256, key)); + } catch (KeyLengthException e) { + throw new IllegalStateException(e); + } + } + } + + private static byte[] sha256(byte[] data) { + try { + return MessageDigest.getInstance("SHA-256").digest(data); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + } + + // ------------------------------------------------------------ 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); + } + + private static byte[] zip(String manifestJson, byte[] payload) throws IOException { + var rewritten = new ByteArrayOutputStream(); + var writer = new TDFWriter(rewritten); + try (var payloadOut = writer.payload()) { + payloadOut.write(payload); + } + writer.appendManifest(manifestJson); + writer.finish(); + return rewritten.toByteArray(); + } +}