Skip to content

fix: if a version < 4.3.0 is specified create an old-style TDF #234

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 15 commits into from
Apr 7, 2025
Merged
8 changes: 4 additions & 4 deletions cmdline/src/main/java/io/opentdf/platform/Command.java
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,8 @@ void encrypt(
@Option(names = {
"--encap-key-type" }, defaultValue = Option.NULL_VALUE, description = "Preferred key access key wrap algorithm, one of ${COMPLETION-CANDIDATES}") Optional<KeyType> encapKeyType,
@Option(names = { "--mime-type" }, defaultValue = Option.NULL_VALUE) Optional<String> mimeType,
@Option(names = { "--with-assertions" }, defaultValue = Option.NULL_VALUE) Optional<String> assertion)
@Option(names = { "--with-assertions" }, defaultValue = Option.NULL_VALUE) Optional<String> assertion,
@Option(names = { "--with-target-mode" }, defaultValue = Option.NULL_VALUE) Optional<String> targetMode)

throws IOException, JOSEException, AutoConfigureException, InterruptedException, ExecutionException, DecoderException {

Expand Down Expand Up @@ -214,9 +215,8 @@ void encrypt(
configs.add(Config.withAssertionConfig(assertionConfigs));
}

if (attributes.isPresent()) {
configs.add(Config.withDataAttributes(attributes.get().split(",")));
}
attributes.ifPresent(s -> configs.add(Config.withDataAttributes(s.split(","))));
targetMode.map(Config::withTargetMode).ifPresent(configs::add);
var tdfConfig = Config.newTDFConfig(configs.toArray(Consumer[]::new));
try (var in = file.isEmpty() ? new BufferedInputStream(System.in) : new FileInputStream(file.get())) {
try (var out = new BufferedOutputStream(System.out)) {
Expand Down
18 changes: 17 additions & 1 deletion sdk/src/main/java/io/opentdf/platform/sdk/Config.java
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,8 @@ public static class TDFConfig {
public String mimeType;
public List<Autoconfigure.KeySplitStep> splitPlan;
public KeyType wrappingKeyType;
public boolean hexEncodeRootAndSegmentHashes;
public boolean renderVersionInfoInManifest;

public TDFConfig() {
this.autoconfigure = true;
Expand All @@ -154,6 +156,8 @@ public TDFConfig() {
this.mimeType = DEFAULT_MIME_TYPE;
this.splitPlan = new ArrayList<>();
this.wrappingKeyType = KeyType.RSA2048Key;
this.hexEncodeRootAndSegmentHashes = false;
this.renderVersionInfoInManifest = true;
}
}

Expand Down Expand Up @@ -251,6 +255,18 @@ public static Consumer<TDFConfig> withAutoconfigure(boolean enable) {
};
}

// specify TDF version for TDF creation to target. Versions less than 4.3.0 will add a
// layer of hex encoding to their segment hashes and will not include version information
// in their manifests.
public static Consumer<TDFConfig> withTargetMode(String targetVersion) {
Version version = new Version(targetVersion == null ? "0.0.0" : targetVersion);
return (TDFConfig config) -> {
var legacyTDF = version.compareTo(new Version("4.3.0")) < 0;
config.renderVersionInfoInManifest = !legacyTDF;
config.hexEncodeRootAndSegmentHashes = legacyTDF;
};
}

public static Consumer<TDFConfig> WithWrappingKeyAlg(KeyType keyType) {
return (TDFConfig config) -> config.wrappingKeyType = keyType;
}
Expand Down Expand Up @@ -393,4 +409,4 @@ public synchronized void updateHeaderInfo(HeaderInfo headerInfo) {
this.notifyAll();
}
}
}
}
14 changes: 9 additions & 5 deletions sdk/src/main/java/io/opentdf/platform/sdk/TDF.java
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@
import io.opentdf.platform.sdk.nanotdf.ECKeyPair;
import org.apache.commons.codec.DecoderException;
import org.apache.commons.codec.binary.Hex;
import org.bouncycastle.crypto.digests.SHA256Digest;
import org.bouncycastle.jce.interfaces.ECPublicKey;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
Expand Down Expand Up @@ -222,7 +221,7 @@ PolicyObject createPolicyObject(List<Autoconfigure.AttributeValueFQN> attributes
private static final Base64.Encoder encoder = Base64.getEncoder();

private void prepareManifest(Config.TDFConfig tdfConfig, SDK.KAS kas) {
manifest.tdfVersion = TDF_VERSION;
manifest.tdfVersion = tdfConfig.renderVersionInfoInManifest ? TDF_VERSION : null;
manifest.encryptionInformation.keyAccessType = kSplitKeyType;
manifest.encryptionInformation.keyAccessObj = new ArrayList<>();

Expand Down Expand Up @@ -541,6 +540,9 @@ public TDFObject createTDF(InputStream payload,
payloadOutput.write(cipherData);

segmentSig = calculateSignature(cipherData, tdfObject.payloadKey, tdfConfig.segmentIntegrityAlgorithm);
if (tdfConfig.hexEncodeRootAndSegmentHashes) {
segmentSig = Hex.encodeHexString(segmentSig).getBytes(StandardCharsets.UTF_8);
}
segmentInfo.hash = Base64.getEncoder().encodeToString(segmentSig);

aggregateHash.write(segmentSig);
Expand All @@ -553,9 +555,11 @@ public TDFObject createTDF(InputStream payload,

Manifest.RootSignature rootSignature = new Manifest.RootSignature();

byte[] rootSig = calculateSignature(aggregateHash.toByteArray(),
tdfObject.payloadKey, tdfConfig.integrityAlgorithm);
rootSignature.signature = Base64.getEncoder().encodeToString(rootSig);
byte[] rootSig = calculateSignature(aggregateHash.toByteArray(), tdfObject.payloadKey, tdfConfig.integrityAlgorithm);
byte[] encodedRootSig = tdfConfig.hexEncodeRootAndSegmentHashes
? Hex.encodeHexString(rootSig).getBytes(StandardCharsets.UTF_8)
: rootSig;
rootSignature.signature = Base64.getEncoder().encodeToString(encodedRootSig);

String alg = kGmacIntegrityAlgorithm;
if (tdfConfig.integrityAlgorithm == Config.IntegrityAlgorithm.HS256) {
Expand Down
75 changes: 75 additions & 0 deletions sdk/src/main/java/io/opentdf/platform/sdk/Version.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package io.opentdf.platform.sdk;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.util.Objects;
import java.util.Optional;
import java.util.regex.Pattern;

class Version implements Comparable<Version> {
private final int major;
private final int minor;
private final int patch;
private final String prereleaseAndMetadata;
private static final Logger log = LoggerFactory.getLogger(Version.class);

Pattern SEMVER_PATTERN = Pattern.compile("^(?<major>0|[1-9]\\d*)\\.(?<minor>0|[1-9]\\d*)\\.(?<patch>0|[1-9]\\d*)(?<prereleaseAndMetadata>\\D.*)?$");

@Override
public String toString() {
return "Version{" +
"major=" + major +
", minor=" + minor +
", patch=" + patch +
", prereleaseAndMetadata='" + prereleaseAndMetadata + '\'' +
'}';
}

public Version(String semver) {
var matcher = SEMVER_PATTERN.matcher(semver);
if (!matcher.matches()) {
throw new IllegalArgumentException("Invalid version format: " + semver);
}
this.major = Integer.parseInt(matcher.group("major"));
this.minor = Optional.ofNullable(matcher.group("minor")).map(Integer::parseInt).orElse(0);
this.patch = Optional.ofNullable(matcher.group("patch")).map(Integer::parseInt).orElse(0);
this.prereleaseAndMetadata = matcher.group("prereleaseAndMetadata");
}

public Version(int major, int minor, int patch, @Nullable String prereleaseAndMetadata) {
this.major = major;
this.minor = minor;
this.patch = patch;
this.prereleaseAndMetadata = prereleaseAndMetadata;
}

@Override
public int compareTo(@Nonnull Version o) {
if (this.major != o.major) {
return Integer.compare(this.major, o.major);
}
if (this.minor != o.minor) {
return Integer.compare(this.minor, o.minor);
}
if (this.patch != o.patch) {
return Integer.compare(this.patch, o.patch);
}
log.debug("ignoring prerelease and buildmetadata during comparision this = {} o = {}", this, o);
return 0;
}

@Override
public boolean equals(Object o) {
if (o == null || getClass() != o.getClass()) return false;
Version version = (Version) o;
return major == version.major && minor == version.minor && patch == version.patch;
}

@Override
public int hashCode() {
return Objects.hash(major, minor, patch);
}
}
16 changes: 16 additions & 0 deletions sdk/src/test/java/io/opentdf/platform/sdk/ConfigTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.Assert.assertFalse;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;
Expand All @@ -18,6 +20,8 @@ void newTDFConfig_shouldCreateDefaultConfig() {
assertEquals(Config.IntegrityAlgorithm.GMAC, config.segmentIntegrityAlgorithm);
assertTrue(config.attributes.isEmpty());
assertTrue(config.kasInfoList.isEmpty());
assertTrue(config.renderVersionInfoInManifest);
assertFalse(config.hexEncodeRootAndSegmentHashes);
}

@Test
Expand Down Expand Up @@ -61,6 +65,18 @@ void withSegmentSize_shouldIgnoreSegmentSize() {
}
}

@Test
void withCompatibilityModeShouldSetFieldsCorrectly() {
Config.TDFConfig oldConfig = Config.newTDFConfig(Config.withTargetMode("1.0.1"));
assertThat(oldConfig.renderVersionInfoInManifest).isFalse();
assertThat(oldConfig.hexEncodeRootAndSegmentHashes).isTrue();

Config.TDFConfig newConfig = Config.newTDFConfig(Config.withTargetMode("100.0.1"));
assertThat(newConfig.renderVersionInfoInManifest).isTrue();
assertThat(newConfig.hexEncodeRootAndSegmentHashes).isFalse();
}


@Test
void withMimeType_shouldSetMimeType() {
final String mimeType = "application/pdf";
Expand Down
53 changes: 52 additions & 1 deletion sdk/src/test/java/io/opentdf/platform/sdk/TDFTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,29 +5,34 @@
import io.opentdf.platform.sdk.TDF.Reader;
import io.opentdf.platform.sdk.nanotdf.ECKeyPair;
import io.opentdf.platform.sdk.nanotdf.NanoTDFType;
import org.apache.commons.codec.DecoderException;
import org.apache.commons.compress.utils.SeekableInMemoryByteChannel;
import org.bouncycastle.jce.interfaces.ECPrivateKey;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;

import javax.annotation.Nonnull;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.security.KeyPair;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.text.ParseException;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import java.util.Random;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Predicate;
import java.util.stream.Collectors;

import static io.opentdf.platform.sdk.TDF.GLOBAL_KEY_SALT;
import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;

public class TDFTest {
Expand Down Expand Up @@ -492,6 +497,48 @@ public void testCreateTDFWithMimeType() throws Exception {
assertThat(reader.getManifest().payload.mimeType).isEqualTo(mimeType);
}

@Test
public void legacyTDFRoundTrips() throws DecoderException, IOException, ExecutionException, JOSEException, InterruptedException, ParseException, NoSuchAlgorithmException {
final String mimeType = "application/pdf";

Config.TDFConfig config = Config.newTDFConfig(
Config.withAutoconfigure(false),
Config.withKasInformation(getRSAKASInfos()),
Config.withTargetMode("4.2.1"),
Config.withMimeType(mimeType));

byte[] data = new byte[129];
new Random().nextBytes(data);
InputStream plainTextInputStream = new ByteArrayInputStream(data);
ByteArrayOutputStream tdfOutputStream = new ByteArrayOutputStream();

TDF tdf = new TDF();
tdf.createTDF(plainTextInputStream, tdfOutputStream, config, kas, null);

var dataOutputStream = new ByteArrayOutputStream();

var reader = tdf.loadTDF(new SeekableInMemoryByteChannel(tdfOutputStream.toByteArray()), kas);
var integrityInformation = reader.getManifest().encryptionInformation.integrityInformation;
assertThat(reader.getManifest().tdfVersion).isNull();
var decodedSignature = Base64.getDecoder().decode(integrityInformation.rootSignature.signature);
for (var b: decodedSignature) {
assertThat(isHexChar(b))
.withFailMessage("non-hex byte in signature: " + b)
.isTrue();
}
for (var s: integrityInformation.segments) {
var decodedSegmentSignature = Base64.getDecoder().decode(s.hash);
for (var b: decodedSegmentSignature) {
assertThat(isHexChar(b))
.withFailMessage("non-hex byte in segment signature: " + b)
.isTrue();
}
}
reader.readPayload(dataOutputStream);
assertThat(reader.getManifest().payload.mimeType).isEqualTo(mimeType);
assertArrayEquals(data, dataOutputStream.toByteArray(), "extracted data does not match");
}

@Nonnull
private static Config.KASInfo[] getKASInfos(Predicate<Integer> filter) {
var kasInfos = new ArrayList<Config.KASInfo>();
Expand All @@ -515,4 +562,8 @@ private static Config.KASInfo[] getRSAKASInfos() {
private static Config.KASInfo[] getECKASInfos() {
return getKASInfos(i -> i % 2 != 0);
}

private static boolean isHexChar(byte b) {
return (b >= 'a' && b <= 'f') || (b >= '0' && b <= '9');
}
}
28 changes: 28 additions & 0 deletions sdk/src/test/java/io/opentdf/platform/sdk/VersionTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package io.opentdf.platform.sdk;

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

class VersionTest {

@Test
public void testParsingVersions() {
assertThat(new Version("1.0.0")).isEqualTo(new Version(1, 0, 0, null));
assertThat(new Version("1.2.1-alpha")).isEqualTo(new Version(1, 2, 1, "alpha a build"));
// ignore anything but the version
assertThat(new Version("1.2.1-alpha+build.123")).isEqualTo(new Version(1, 2, 1, "beta build.1234"));
}

@Test
public void testComparingVersions() {
assertThat(new Version("1.0.0")).isLessThan(new Version("1.0.1"));
assertThat(new Version("1.0.1")).isGreaterThan(new Version("1.0.0"));

assertThat(new Version("500.0.1")).isLessThan(new Version("500.1.1"));
assertThat(new Version("500.1.1")).isGreaterThan(new Version("500.0.1"));

// ignore anything but the version
assertThat(new Version("1.0.1-alpha+thisbuild")).isEqualByComparingTo(new Version("1.0.1-beta+thatbuild"));
}
}
Loading