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");
}