Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions sdk/src/main/java/io/opentdf/platform/sdk/Manifest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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}.
* <p>
* 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;

Expand Down Expand Up @@ -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.
* <p>
* {@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.
* <p>
* 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.
* <p>
* 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.
* <p>
* 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.
* <p>
* 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 <T> TypeAdapter<T> create(Gson gson, TypeToken<T> type) {
if (!Manifest.class.equals(type.getRawType())) {
return null;
}
final TypeAdapter<T> delegate = gson.getDelegateAdapter(this, type);
final TypeAdapter<JsonElement> elementAdapter = gson.getAdapter(JsonElement.class);
return new TypeAdapter<T>() {
@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;
Expand Down
122 changes: 122 additions & 0 deletions sdk/src/test/java/io/opentdf/platform/sdk/ManifestTest.java
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<Arguments> 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<Arguments> nonAlignedPlacements() {
return Stream.of(
Arguments.of("root", "", ",\"tdf_spec_version\":\"4.3.0\""),
Arguments.of("payload", ",\"tdf_spec_version\":\"4.3.0\"", ""));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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");
}
Expand Down
Loading