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 0e8b044c..ae03a1d5 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/Manifest.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/Manifest.java @@ -64,7 +64,17 @@ public class Manifest { private static final Gson gson = new GsonBuilder() .registerTypeAdapter(AssertionConfig.Statement.class, new AssertionValueAdapter()) .registerTypeAdapterFactory(new IntegrityInformationAdapterFactory()) + .registerTypeAdapterFactory(new SpecVersionAdapterFactory()) .create(); + + /** + * The TDF spec version the manifest records. Written as {@code schemaVersion} only; on read + * it may also come from the non-aligned {@code tdf_spec_version} name, see + * {@link SpecVersionAdapterFactory}. + *

+ * The reader uses it to choose how the integrity digests are encoded: hex when no version is + * recorded (pre-4.3.0), raw bytes otherwise. + */ @SerializedName(value = "schemaVersion") String tdfVersion; @@ -277,6 +287,91 @@ private static boolean hasValue(JsonObject object, String memberName) { } } + /** + * Reads {@code tdf_spec_version}, a non-aligned name for the spec-version field, when the + * canonical {@code schemaVersion} is absent, so that files written with that name stay + * readable. + *

+ * {@code schemaVersion} is the canonical name. {@code tdf_spec_version} is not a former + * spelling that was renamed -- it entered some specification drafts and some older OpenTDF + * documentation in error, and writers built from those drafts emitted it. We read it so + * those files stay usable; we never write it. + *

+ * Precedence is {@code schemaVersion}, then {@code tdf_spec_version} at the root, then + * {@code tdf_spec_version} under {@code payload}. Nothing is written back under the + * non-aligned name -- serializing a manifest always emits {@code schemaVersion} only, so a + * round trip normalizes the name rather than propagating it. + *

+ * Both placements are probed because both occur in archival files. The root is where the + * spec's own manifest.md has always documented the field, and where web-sdk both wrote it + * and still reads it. Under {@code payload} is where revisions of the JSON schema declared it + * in error, which led at least one writer to emit the key there with a {@code null} value. + *

+ * Only a non-empty JSON string counts. Any other value -- {@code null}, a number, an object + * -- is skipped rather than failing the decode: reporting malformed manifests is schema + * validation's job, not the decoder's. + *

+ * Gson's {@code @SerializedName(alternate = ...)} cannot do this: it cannot reach into + * {@code payload}, and when both names are present it keeps whichever comes last in the + * document instead of preferring {@code schemaVersion}. + */ + private static class SpecVersionAdapterFactory implements TypeAdapterFactory { + private static final String NON_ALIGNED_SPEC_VERSION = "tdf_spec_version"; + private static final String PAYLOAD = "payload"; + + @Override + public TypeAdapter create(Gson gson, TypeToken type) { + if (!Manifest.class.equals(type.getRawType())) { + return null; + } + final TypeAdapter delegate = gson.getDelegateAdapter(this, type); + final TypeAdapter elementAdapter = gson.getAdapter(JsonElement.class); + return new TypeAdapter() { + @Override + public void write(JsonWriter out, T value) throws IOException { + delegate.write(out, value); + } + + @Override + public T read(JsonReader in) throws IOException { + JsonElement tree = elementAdapter.read(in); + T value = delegate.fromJsonTree(tree); + if (value instanceof Manifest && tree != null && tree.isJsonObject()) { + Manifest manifest = (Manifest) value; + if (manifest.tdfVersion == null || manifest.tdfVersion.isEmpty()) { + String nonAligned = nonAlignedSpecVersion(tree.getAsJsonObject()); + if (nonAligned != null) { + manifest.tdfVersion = nonAligned; + } + } + } + return value; + } + }; + } + + /** The first non-empty string under the non-aligned name, root before payload, or null. */ + private static String nonAlignedSpecVersion(JsonObject root) { + String atRoot = nonEmptyString(root.get(NON_ALIGNED_SPEC_VERSION)); + if (atRoot != null) { + return atRoot; + } + JsonElement payload = root.get(PAYLOAD); + if (payload != null && payload.isJsonObject()) { + return nonEmptyString(payload.getAsJsonObject().get(NON_ALIGNED_SPEC_VERSION)); + } + return null; + } + + private static String nonEmptyString(JsonElement element) { + if (element == null || !element.isJsonPrimitive() || !element.getAsJsonPrimitive().isString()) { + return null; + } + String value = element.getAsString(); + return value.isEmpty() ? null : value; + } + } + static public class PolicyBinding { public String alg; public String hash; diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/ManifestTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/ManifestTest.java index 5e57937d..45de6945 100644 --- a/sdk/src/test/java/io/opentdf/platform/sdk/ManifestTest.java +++ b/sdk/src/test/java/io/opentdf/platform/sdk/ManifestTest.java @@ -1,12 +1,17 @@ package io.opentdf.platform.sdk; import com.google.gson.Gson; +import com.google.gson.JsonParser; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Map; +import java.util.stream.Stream; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -285,4 +290,121 @@ void testReadingManifestWithObjectStatementValue() throws IOException { ) ); } + + /** + * A minimal but valid manifest with extra members spliced into the {@code payload} object + * and into the manifest root. Each extra, when not empty, must begin with a comma. + */ + private static String manifestWithExtras(String payloadExtra, String rootExtra) { + return "{\n" + + " \"encryptionInformation\": {\n" + + " \"integrityInformation\": {\n" + + " \"encryptedSegmentSizeDefault\": " + ENCRYPTED_SEGMENT_SIZE_DEFAULT + ",\n" + + " \"segmentSizeDefault\": " + SEGMENT_SIZE_DEFAULT + ",\n" + + " \"rootSignature\": { \"alg\": \"HS256\", \"sig\": \"c2ln\" },\n" + + " \"segmentHashAlg\": \"GMAC\",\n" + + " \"segments\": [ { \"hash\": \"aGFzaDA=\" } ]\n" + + " },\n" + + " \"keyAccess\": [ { \"protocol\": \"kas\", \"type\": \"wrapped\"," + + " \"url\": \"http://localhost:65432/kas\", \"wrappedKey\": \"a2V5\" } ],\n" + + " \"method\": { \"algorithm\": \"AES-256-GCM\", \"isStreamable\": true, \"iv\": \"aXY=\" },\n" + + " \"policy\": \"cG9saWN5\",\n" + + " \"type\": \"split\"\n" + + " },\n" + + " \"payload\": { \"isEncrypted\": true, \"protocol\": \"zip\"," + + " \"type\": \"reference\", \"url\": \"0.payload\"" + payloadExtra + " }" + + rootExtra + "\n" + + "}"; + } + + static Stream specVersionCases() { + return Stream.of( + Arguments.of("schemaVersion at root", "", ",\"schemaVersion\":\"4.3.0\"", "4.3.0"), + // where the spec prose documents it, and where web-sdk writes it + Arguments.of("tdf_spec_version at root", "", ",\"tdf_spec_version\":\"4.3.0\"", "4.3.0"), + // where revisions of the JSON schema declared it in error + Arguments.of("tdf_spec_version under payload", ",\"tdf_spec_version\":\"4.3.0\"", "", "4.3.0"), + Arguments.of("schemaVersion wins over payload tdf_spec_version", + ",\"tdf_spec_version\":\"4.2.0\"", ",\"schemaVersion\":\"4.3.0\"", "4.3.0"), + Arguments.of("schemaVersion wins over root tdf_spec_version, whatever the key order", + "", ",\"tdf_spec_version\":\"4.2.0\",\"schemaVersion\":\"4.3.0\"", "4.3.0"), + Arguments.of("schemaVersion wins over root tdf_spec_version", + "", ",\"schemaVersion\":\"4.3.0\",\"tdf_spec_version\":\"4.2.0\"", "4.3.0"), + // the root is the placement with the better provenance, so it decides when the + // two copies disagree + Arguments.of("root tdf_spec_version wins over the payload copy", + ",\"tdf_spec_version\":\"4.2.0\"", ",\"tdf_spec_version\":\"4.3.0\"", "4.3.0"), + // a null root copy is not a value, so the payload copy still applies + Arguments.of("null root tdf_spec_version falls through to payload", + ",\"tdf_spec_version\":\"4.3.0\"", ",\"tdf_spec_version\":null", "4.3.0"), + Arguments.of("numeric root tdf_spec_version falls through to payload", + ",\"tdf_spec_version\":\"4.3.0\"", ",\"tdf_spec_version\":430", "4.3.0"), + Arguments.of("empty root tdf_spec_version falls through to payload", + ",\"tdf_spec_version\":\"4.3.0\"", ",\"tdf_spec_version\":\"\"", "4.3.0"), + // an empty schemaVersion is not a value, so the fallback still applies + Arguments.of("empty schemaVersion falls back to tdf_spec_version", + ",\"tdf_spec_version\":\"4.3.0\"", ",\"schemaVersion\":\"\"", "4.3.0"), + Arguments.of("null schemaVersion falls back to tdf_spec_version", + "", ",\"schemaVersion\":null,\"tdf_spec_version\":\"4.3.0\"", "4.3.0"), + Arguments.of("no version at all", "", "", null), + // non-string values are schema validation's problem to report, not the decoder's + // to choke on. the key is known in the wild carrying null + Arguments.of("null tdf_spec_version is ignored", ",\"tdf_spec_version\":null", "", null), + Arguments.of("numeric tdf_spec_version is ignored", ",\"tdf_spec_version\":430", "", null), + Arguments.of("boolean tdf_spec_version is ignored", "", ",\"tdf_spec_version\":true", null), + Arguments.of("object tdf_spec_version is ignored", + ",\"tdf_spec_version\":{\"major\":4}", ",\"tdf_spec_version\":{\"major\":4}", null), + Arguments.of("array tdf_spec_version is ignored", ",\"tdf_spec_version\":[\"4.3.0\"]", "", null)); + } + + /** + * {@code schemaVersion} is the name of the spec-version field. {@code tdf_spec_version} is a + * non-aligned name that entered some specification drafts and some older OpenTDF documentation + * in error; it is read, at the root and then under {@code payload}, only so that files written + * with it stay usable. + */ + @ParameterizedTest(name = "{0}") + @MethodSource("specVersionCases") + void testSpecVersionIsReadFromAllThreePlaces(String name, String payloadExtra, String rootExtra, String want) { + Manifest manifest = Manifest.readManifest(manifestWithExtras(payloadExtra, rootExtra)); + + assertThat(manifest.tdfVersion).isEqualTo(want); + + // the version lookup must not disturb anything else in the document + assertThat(manifest.payload.url).isEqualTo("0.payload"); + assertThat(manifest.payload.isEncrypted).isTrue(); + assertThat(manifest.encryptionInformation.keyAccessType).isEqualTo("split"); + assertThat(manifest.encryptionInformation.policy).isEqualTo("cG9saWN5"); + var integrityInformation = manifest.encryptionInformation.integrityInformation; + assertThat(integrityInformation.segmentHashAlg).isEqualTo("GMAC"); + assertThat(integrityInformation.rootSignature.signature).isEqualTo("c2ln"); + // and the segment-size fixup, which lives in its own adapter, still runs + assertThat(integrityInformation.segments.get(0).encryptedSegmentSize).isEqualTo(ENCRYPTED_SEGMENT_SIZE_DEFAULT); + } + + /** + * The writer names the field {@code schemaVersion} and never the non-aligned + * {@code tdf_spec_version}, at the root or under {@code payload}. Reading a manifest that + * used the non-aligned name and writing it back out therefore normalizes the name rather + * than propagating it. + */ + @ParameterizedTest(name = "{0}") + @MethodSource("nonAlignedPlacements") + void testRoundTripEmitsSchemaVersionOnly(String name, String payloadExtra, String rootExtra) { + Manifest manifest = Manifest.readManifest(manifestWithExtras(payloadExtra, rootExtra)); + assertThat(manifest.tdfVersion).isEqualTo("4.3.0"); + + var written = JsonParser.parseString(Manifest.toJson(manifest)).getAsJsonObject(); + assertThat(written.get("schemaVersion").getAsString()).isEqualTo("4.3.0"); + assertThat(written.has("tdf_spec_version")).isFalse(); + assertThat(written.getAsJsonObject("payload").has("tdf_spec_version")).isFalse(); + + assertEquals(manifest, Manifest.readManifest(written.toString())); + } + + static Stream nonAlignedPlacements() { + return Stream.of( + Arguments.of("root", "", ",\"tdf_spec_version\":\"4.3.0\""), + Arguments.of("payload", ",\"tdf_spec_version\":\"4.3.0\"", "")); + } } diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/TDFRootSignatureTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/TDFRootSignatureTest.java index b6a4c8de..f8ccc0c3 100644 --- a/sdk/src/test/java/io/opentdf/platform/sdk/TDFRootSignatureTest.java +++ b/sdk/src/test/java/io/opentdf/platform/sdk/TDFRootSignatureTest.java @@ -330,6 +330,70 @@ void legacyGmacRootIsRejected() throws IOException { .isInstanceOf(SDK.RootSignatureValidationException.class); } + // ------------------------------------------------------------------ spec version + + /* + * The manifest's spec-version field decides the digest encoding: no version means hex + * (pre-4.3.0), any version means raw. A current file whose version is recorded only under + * the non-aligned tdf_spec_version name, at the root or under payload, must still be read + * as raw. + */ + + @ParameterizedTest + @ValueSource(strings = { "root", "payload" }) + void currentFileWithVersionOnlyUnderTheNonAlignedNameDecrypts(String placement) throws IOException { + var plaintext = fourSegmentPlaintext(); + var rewritten = rewrite(createTdf(plaintext, Config.withAssertionConfig(assertionConfig())), + manifest -> moveVersionToNonAlignedName(manifest, placement), + UnaryOperator.identity()); + + assertThat(Manifest.readManifest(manifestOf(rewritten)).tdfVersion).isEqualTo(TDF.TDF_SPEC_VERSION); + assertThat(decrypt(rewritten)).containsExactly(plaintext); + } + + @Test + void legacyHexDigestFileWithAnAssertionStillDecrypts() throws IOException { + var plaintext = fourSegmentPlaintext(); + var tdfBytes = createTdf(plaintext, + Config.withTargetMode("4.2.2"), + Config.withAssertionConfig(assertionConfig())); + assertThat(JsonParser.parseString(manifestOf(tdfBytes)).getAsJsonObject().has("schemaVersion")).isFalse(); + + assertThat(decrypt(tdfBytes)).containsExactly(plaintext); + } + + @ParameterizedTest + @ValueSource(strings = { "root", "payload" }) + void tamperingIsStillCaughtWhenTheVersionIsUnderTheNonAlignedName(String placement) throws IOException { + var original = createTdf(fourSegmentPlaintext(), withSegmentAlgorithm(Config.IntegrityAlgorithm.HS256)); + + var editedSegmentHash = rewrite(original, manifest -> { + moveVersionToNonAlignedName(manifest, placement); + 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(editedSegmentHash)) + .isInstanceOf(SDK.RootSignatureValidationException.class); + + var editedRootSignature = rewrite(original, manifest -> { + moveVersionToNonAlignedName(manifest, placement); + var sig = Base64.getDecoder().decode(rootSignature(manifest).get("sig").getAsString()); + sig[0] ^= 0xFF; + rootSignature(manifest).addProperty("sig", Base64.getEncoder().encodeToString(sig)); + }, UnaryOperator.identity()); + assertThatThrownBy(() -> decrypt(editedRootSignature)) + .isInstanceOf(SDK.RootSignatureValidationException.class); + + var editedSegmentBody = rewrite(original, + manifest -> moveVersionToNonAlignedName(manifest, placement), + payload -> flipByte(payload, payload.length / 2)); + assertThatThrownBy(() -> decrypt(editedSegmentBody)) + .isInstanceOf(SDK.SegmentSignatureMismatch.class); + } + + // ------------------------------------------------------------------ config @Test @@ -464,8 +528,38 @@ private static byte[] decrypt(byte[] tdfBytes) throws IOException { return plaintext.toByteArray(); } + /** An assertion signed with the default HS256 payload key, so reads verify it. */ + private static AssertionConfig assertionConfig() { + var assertionConfig = new AssertionConfig(); + assertionConfig.id = "assertion1"; + 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="; + return assertionConfig; + } + // ------------------------------------------------------- manifest surgery + /** + * Rewrites the spec version the way a writer built from the non-aligned name emits it: + * {@code schemaVersion} removed, the same value recorded as {@code tdf_spec_version} at the + * manifest root or under {@code payload}. The root signature covers the segment hashes, not + * the JSON, so the result is still internally consistent. + */ + private static void moveVersionToNonAlignedName(JsonObject manifest, String placement) { + var version = manifest.remove("schemaVersion"); + assertThat(version).withFailMessage("fixture should have been written with schemaVersion").isNotNull(); + var target = "root".equals(placement) ? manifest : manifest.getAsJsonObject("payload"); + target.add("tdf_spec_version", version); + } + + + + private static JsonObject integrityInformation(JsonObject manifest) { return manifest.getAsJsonObject("encryptionInformation").getAsJsonObject("integrityInformation"); }