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..45481810 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/Manifest.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/Manifest.java @@ -1232,7 +1232,7 @@ private static void validateManifest(Manifest result) { var encryptionInformation = result.encryptionInformation; requireManifestField(encryptionInformation.integrityInformation, "Manifest with null integrityInformation"); - requireManifestField(encryptionInformation.keyAccessObj, "Manifest with null keyAccessObj"); + requireManifestField(encryptionInformation.keyAccessObj, "Manifest with no key access objects: keyAccess is absent"); requireManifestField(encryptionInformation.policy, "Manifest with null policy"); var integrityInformation = encryptionInformation.integrityInformation; 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..f5a61423 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java @@ -850,6 +850,12 @@ Reader loadTDF(SeekableByteChannel tdf, Config.TDFReaderConfig tdfReaderConfig) TDFReader tdfReader = TDFReader.open(tdf); Manifest manifest = tdfReader.readManifest(); + // The payload key is assembled only from key access objects; without one there is no key, + // so refuse before any KAS, integrity or assertion step. + if (manifest.encryptionInformation.keyAccessObj.isEmpty()) { + throw new SDK.MalformedTDFException( + "tdf manifest is invalid: encryptionInformation.keyAccess has no key access objects"); + } byte[] payloadKey = new byte[GCM_KEY_SIZE]; String unencryptedMetadata = null; @@ -928,6 +934,10 @@ Reader loadTDF(SeekableByteChannel tdf, Config.TDFReaderConfig tdfReaderConfig) throw new SDK.SplitKeyException(combinedMessage.toString()); } + // never continue with the all-zero starting value of the payload key + if (foundSplits.isEmpty()) { + throw new SDK.SplitKeyException("unable to reconstruct split key: no key access objects yielded a split"); + } } // Validate root signature diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/TDFKeyAccessTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/TDFKeyAccessTest.java new file mode 100644 index 00000000..a83b302a --- /dev/null +++ b/sdk/src/test/java/io/opentdf/platform/sdk/TDFKeyAccessTest.java @@ -0,0 +1,165 @@ +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 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.Arrays; +import java.util.Base64; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * The payload key is built only from key access objects. A manifest without any is refused as + * malformed when the TDF is loaded, before any KAS, integrity or assertion step. + */ +class TDFKeyAccessTest { + + private static final String KAS_URL = "https://example.com/kas0"; + private static final byte[] PLAINTEXT = "key access objects".getBytes(StandardCharsets.UTF_8); + private static final byte[] ZERO_KEY = new byte[32]; + + private static KeyPair kasKeyPair; + private static final AtomicInteger unwraps = new AtomicInteger(); + + private static final SDK.KAS KAS = new SDK.KAS() { + @Override + public void close() { + // nothing to release + } + + @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) { + unwraps.incrementAndGet(); + return new AsymDecryption(kasKeyPair.getPrivate()).decrypt(Base64.getDecoder().decode(keyAccess.wrappedKey)); + } + + @Override + public KASKeyCache getKeyCache() { + return new KASKeyCache(); + } + }; + + @BeforeAll + static void generateKasKey() { + kasKeyPair = CryptoUtils.generateRSAKeypair(); + } + + private static TDF tdf() { + return new TDF(new FakeServicesBuilder().setKas(KAS).build()); + } + + private static byte[] createTdf() throws IOException { + var kasInfo = new Config.KASInfo(); + kasInfo.URL = KAS_URL; + var out = new ByteArrayOutputStream(); + tdf().createTDF(new ByteArrayInputStream(PLAINTEXT), out, + Config.newTDFConfig(Config.withAutoconfigure(false), Config.withKasInformation(kasInfo))); + return out.toByteArray(); + } + + private static TDF.Reader load(byte[] tdfBytes) throws IOException { + return tdf().loadTDF(new SeekableInMemoryByteChannel(tdfBytes), + Config.newTDFReaderConfig(Config.WithIgnoreKasAllowlist(true))); + } + + 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()) { + return data.readAllBytes(); + } + } + } + throw new IllegalArgumentException("no entry named " + name); + } + + private static byte[] zip(JsonObject manifest, byte[] payload) throws IOException { + var out = new ByteArrayOutputStream(); + var writer = new TDFWriter(out); + try (var payloadOut = writer.payload()) { + payloadOut.write(payload); + } + try (var manifestOut = writer.manifest()) { + manifestOut.write(new Gson().toJson(manifest).getBytes(StandardCharsets.UTF_8)); + } + writer.finish(); + return out.toByteArray(); + } + + /** + * The TDF with its manifest edited by {@code edit}, and its payload, segment hash and root + * signature redone under the all-zero key, the value the payload key starts from before any + * split is folded in. Only the missing key access objects make it unreadable. + */ + private static byte[] underTheZeroKey(byte[] tdfBytes, Consumer edit) throws IOException { + var manifest = JsonParser.parseString(new String(entry(tdfBytes, TDFWriter.TDF_MANIFEST_FILE_NAME), + StandardCharsets.UTF_8)).getAsJsonObject(); + var integrity = manifest.getAsJsonObject("encryptionInformation").getAsJsonObject("integrityInformation"); + assertThat(integrity.get("segmentHashAlg").getAsString()).isEqualTo("GMAC"); + JsonArray segments = integrity.getAsJsonArray("segments"); + assertThat(segments).hasSize(1); + + byte[] encrypted = new AesGcm(ZERO_KEY).encrypt(PLAINTEXT, 0, PLAINTEXT.length).asBytes(); + byte[] tag = Arrays.copyOfRange(encrypted, encrypted.length - AesGcm.GCM_TAG_LENGTH, encrypted.length); + segments.get(0).getAsJsonObject().addProperty("hash", Base64.getEncoder().encodeToString(tag)); + integrity.getAsJsonObject("rootSignature").addProperty("sig", + Base64.getEncoder().encodeToString(CryptoUtils.CalculateSHA256Hmac(ZERO_KEY, tag))); + edit.accept(manifest); + return zip(manifest, encrypted); + } + + @Test + void aTdfWithKeyAccessObjectsLoads() throws IOException { + unwraps.set(0); + var reader = load(createTdf()); + var out = new ByteArrayOutputStream(); + reader.readPayload(out); + assertThat(out.toByteArray()).isEqualTo(PLAINTEXT); + assertThat(unwraps).hasValue(1); + } + + @Test + void anEmptyKeyAccessListIsMalformed() throws IOException { + var withoutKeyAccess = underTheZeroKey(createTdf(), manifest -> + manifest.getAsJsonObject("encryptionInformation").add("keyAccess", new JsonArray())); + unwraps.set(0); + assertThatThrownBy(() -> load(withoutKeyAccess)) + .isInstanceOf(SDK.MalformedTDFException.class) + .hasMessageContaining("no key access objects"); + assertThat(unwraps).hasValue(0); + } + + @Test + void anAbsentKeyAccessListIsMalformed() throws IOException { + var withoutKeyAccess = underTheZeroKey(createTdf(), manifest -> + manifest.getAsJsonObject("encryptionInformation").remove("keyAccess")); + unwraps.set(0); + assertThatThrownBy(() -> load(withoutKeyAccess)) + .isInstanceOf(SDK.MalformedTDFException.class) + .hasMessageContaining("no key access objects"); + assertThat(unwraps).hasValue(0); + } +}