diff --git a/cmdline/src/main/java/io/opentdf/platform/Command.java b/cmdline/src/main/java/io/opentdf/platform/Command.java index 49b4e565..bacee3a4 100644 --- a/cmdline/src/main/java/io/opentdf/platform/Command.java +++ b/cmdline/src/main/java/io/opentdf/platform/Command.java @@ -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 encapKeyType, @Option(names = { "--mime-type" }, defaultValue = Option.NULL_VALUE) Optional mimeType, - @Option(names = { "--with-assertions" }, defaultValue = Option.NULL_VALUE) Optional assertion) + @Option(names = { "--with-assertions" }, defaultValue = Option.NULL_VALUE) Optional assertion, + @Option(names = { "--with-target-mode" }, defaultValue = Option.NULL_VALUE) Optional targetMode) throws IOException, JOSEException, AutoConfigureException, InterruptedException, ExecutionException, DecoderException { @@ -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)) { diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/Config.java b/sdk/src/main/java/io/opentdf/platform/sdk/Config.java index a06daaf0..3e46649d 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/Config.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/Config.java @@ -140,6 +140,8 @@ public static class TDFConfig { public String mimeType; public List splitPlan; public KeyType wrappingKeyType; + public boolean hexEncodeRootAndSegmentHashes; + public boolean renderVersionInfoInManifest; public TDFConfig() { this.autoconfigure = true; @@ -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; } } @@ -251,6 +255,18 @@ public static Consumer 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 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 WithWrappingKeyAlg(KeyType keyType) { return (TDFConfig config) -> config.wrappingKeyType = keyType; } @@ -393,4 +409,4 @@ public synchronized void updateHeaderInfo(HeaderInfo headerInfo) { this.notifyAll(); } } -} \ No newline at end of file +} 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 5862f00e..faa082e6 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java @@ -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; @@ -222,7 +221,7 @@ PolicyObject createPolicyObject(List 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<>(); @@ -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); @@ -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) { diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/Version.java b/sdk/src/main/java/io/opentdf/platform/sdk/Version.java new file mode 100644 index 00000000..f0d5f809 --- /dev/null +++ b/sdk/src/main/java/io/opentdf/platform/sdk/Version.java @@ -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 { + 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("^(?0|[1-9]\\d*)\\.(?0|[1-9]\\d*)\\.(?0|[1-9]\\d*)(?\\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); + } +} diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/ConfigTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/ConfigTest.java index b3bc3f77..70527133 100644 --- a/sdk/src/test/java/io/opentdf/platform/sdk/ConfigTest.java +++ b/sdk/src/test/java/io/opentdf/platform/sdk/ConfigTest.java @@ -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; @@ -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 @@ -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"; diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/TDFTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/TDFTest.java index 0c1ed084..e4031bcb 100644 --- a/sdk/src/test/java/io/opentdf/platform/sdk/TDFTest.java +++ b/sdk/src/test/java/io/opentdf/platform/sdk/TDFTest.java @@ -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 { @@ -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 filter) { var kasInfos = new ArrayList(); @@ -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'); + } } diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/VersionTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/VersionTest.java new file mode 100644 index 00000000..05b88211 --- /dev/null +++ b/sdk/src/test/java/io/opentdf/platform/sdk/VersionTest.java @@ -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")); + } +}