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(); + } +}