From 37d2f45f51a0fb2a25e941c3087b4b0da8d17303 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Thu, 10 Sep 2026 11:37:59 -0400 Subject: [PATCH] feat(sdk,cmdline): settable segment and root integrity algorithms (DSPX-4736) Adds explicit controls for the two integrity algorithms a ZTDF writer picks, and makes an unsupported choice fail loudly instead of being silently accepted. The two are not interchangeable: - A segment hash is computed over ciphertext AES-GCM actually produced, so "GMAC" there means reading back a real authentication tag. Both HS256 and GMAC are valid. - The root signature covers the aggregate of the segment hashes, which never passes through AES-GCM. There is no tag to recover, so HS256 is the only meaningful choice. sdk: - Config.withSegmentIntegrityAlgorithm(HS256|GMAC) - Config.withRootIntegrityAlgorithm(HS256), which throws IllegalArgumentException for anything else. The option exists so callers can state the choice explicitly and a CLI can surface the refusal, not to widen it. cmdline: - `encrypt --root-integrity-algorithm` and `--segment-integrity-algorithm`, with a case-insensitive converter so the flags behave like the equivalents in the other OpenTDF CLIs, and ${COMPLETION-CANDIDATES} in the description so the accepted values appear in help. - The root algorithm is validated before buildSDK(), so an unsupported value is reported as a picocli usage error (exit 2) rather than after a platform round trip. - A test pins the literal flag names into the rendered encrypt help, because the cross-SDK xtest feature detectors find them by grepping it. This is the control surface for evaluating DSPX-4703. It does not itself change how a manifest that already declares a GMAC root is verified on read. Signed-off-by: Dave Mihalcik chore: reduce verbosity of documentation --- .../java/io/opentdf/platform/Command.java | 33 +++++++++++- .../java/io/opentdf/platform/CommandTest.java | 53 +++++++++++++++++++ .../java/io/opentdf/platform/sdk/Config.java | 24 +++++++++ .../io/opentdf/platform/sdk/ConfigTest.java | 15 ++++++ 4 files changed, 124 insertions(+), 1 deletion(-) diff --git a/cmdline/src/main/java/io/opentdf/platform/Command.java b/cmdline/src/main/java/io/opentdf/platform/Command.java index 7def8b2e..5f0aa844 100644 --- a/cmdline/src/main/java/io/opentdf/platform/Command.java +++ b/cmdline/src/main/java/io/opentdf/platform/Command.java @@ -114,6 +114,22 @@ private void printFeatures(List features) { } } + /** + * Loosely converts string representations of the integrity algorithms to the allowed enum values. + */ + static class IntegrityAlgorithmConverter implements CommandLine.ITypeConverter { + @Override + public Config.IntegrityAlgorithm convert(String value) { + for (Config.IntegrityAlgorithm algorithm : Config.IntegrityAlgorithm.values()) { + if (algorithm.name().equalsIgnoreCase(value.trim())) { + return algorithm; + } + } + throw new CommandLine.TypeConversionException( + "expected one of [HS256, GMAC] (case-insensitive) but was '" + value + "'"); + } + } + private static class AssertionKeyDeserializer implements JsonDeserializer { @Override public AssertionConfig.AssertionKey deserialize(JsonElement json, java.lang.reflect.Type typeOfT, @@ -258,11 +274,26 @@ 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 = { + "--root-integrity-algorithm" }, defaultValue = Option.NULL_VALUE, converter = IntegrityAlgorithmConverter.class, description = "Algorithm for the TDF root signature. Only HS256 is supported.") Optional rootIntegrityAlgorithm, + @Option(names = { + "--segment-integrity-algorithm" }, defaultValue = Option.NULL_VALUE, converter = IntegrityAlgorithmConverter.class, description = "Algorithm for segment hashes, one of ${COMPLETION-CANDIDATES}") Optional segmentIntegrityAlgorithm, @Option(names = { "--with-assertions" }, defaultValue = Option.NULL_VALUE) Optional assertion, @Option(names = { "--with-target-mode" }, defaultValue = Option.NULL_VALUE) Optional targetMode) throws IOException, AutoConfigureException { + // Additional command line argument validation + List> integrityConfigs = new ArrayList<>(); + segmentIntegrityAlgorithm.map(Config::withSegmentIntegrityAlgorithm).ifPresent(integrityConfigs::add); + rootIntegrityAlgorithm.ifPresent(alg -> { + try { + integrityConfigs.add(Config.withRootIntegrityAlgorithm(alg)); + } catch (IllegalArgumentException e) { + throw new CommandLine.ParameterException(spec.commandLine(), e.getMessage(), e); + } + }); + var sdk = buildSDK(); var kasInfos = kas.stream().map(k -> { var ki = new Config.KASInfo(); @@ -270,7 +301,7 @@ void encrypt( return ki; }).toArray(Config.KASInfo[]::new); - List> configs = new ArrayList<>(); + List> configs = new ArrayList<>(integrityConfigs); configs.add(Config.withKasInformation(kasInfos)); metadata.map(Config::withMetaData).ifPresent(configs::add); configs.add(Config.withSystemMetadataAssertion()); diff --git a/cmdline/src/test/java/io/opentdf/platform/CommandTest.java b/cmdline/src/test/java/io/opentdf/platform/CommandTest.java index 8995271d..6567b16a 100644 --- a/cmdline/src/test/java/io/opentdf/platform/CommandTest.java +++ b/cmdline/src/test/java/io/opentdf/platform/CommandTest.java @@ -1,7 +1,11 @@ package io.opentdf.platform; +import io.opentdf.platform.sdk.Config; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; import picocli.CommandLine; import java.io.ByteArrayOutputStream; @@ -9,6 +13,9 @@ import java.io.PrintWriter; import java.io.StringWriter; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; import static org.assertj.core.api.Assertions.assertThat; @@ -137,4 +144,50 @@ void supports_unknownFeature_json_false() { assertThat(out.trim()).isEqualTo("{\"unknown_feature\":false}"); } + /** Runs `encrypt` with the given extra options, asserts it exited USAGE, and returns stderr. */ + private String encryptErr(String... opts) { + StringWriter err = new StringWriter(); + CommandLine cli = new CommandLine(new Command()); + cli.setErr(new PrintWriter(err)); + + List args = new ArrayList<>(List.of("encrypt", "-k", "https://kas.example.com", "-f", "/dev/null")); + Collections.addAll(args, opts); + int code = cli.execute(args.toArray(new String[0])); + + assertThat(code).isEqualTo(CommandLine.ExitCode.USAGE); + return err.toString(); + } + + @ParameterizedTest + @CsvSource({ "gmac,GMAC", "GMAC,GMAC", "GMac,GMAC", "hs256,HS256", "HS256,HS256", "'HS256 ',HS256" }) + void integrityAlgorithmConverter_parsesAnyCasingAndTrims(String in, Config.IntegrityAlgorithm expected) { + assertThat(new Command.IntegrityAlgorithmConverter().convert(in)).isEqualTo(expected); + } + + @ParameterizedTest + @ValueSource(strings = { "gmac", "GMAC", "GMac" }) + void encrypt_rootIntegrityAlgorithmGmac_isRejected(String value) { + assertThat(encryptErr("--root-integrity-algorithm", value)) + .contains("unsupported root integrity algorithm"); + } + + @ParameterizedTest + @ValueSource(strings = { "gmac", "GMAC", "hs256", "HS256" }) + void encrypt_segmentIntegrityAlgorithm_acceptsBothValuesInAnyCasing(String value) { + assertThat(encryptErr("--segment-integrity-algorithm", value)) + .contains("Missing required option: '--platform-endpoint='"); + } + + @Test + void encrypt_unknownIntegrityAlgorithm_isRejected() { + assertThat(encryptErr("--segment-integrity-algorithm", "md5")).contains("--segment-integrity-algorithm"); + } + + @Test + void encryptHelp_listsIntegrityFlags() { + String help = new CommandLine(new Command()).getSubcommands().get("encrypt") + .getUsageMessage(CommandLine.Help.Ansi.OFF); + + assertThat(help).contains("--root-integrity-algorithm", "--segment-integrity-algorithm"); + } } 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 f4dbc4f5..eb46f563 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/Config.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/Config.java @@ -34,7 +34,9 @@ public enum TDFFormat { } public enum IntegrityAlgorithm { + /** Use the HMAC algorithm with the DEK to build the hash. */ HS256, + /** For blocks encrypted with AES-GCM, extract the auth tag and use that as the hash. */ GMAC } @@ -337,6 +339,28 @@ public static Consumer withMimeType(String mimeType) { return (TDFConfig config) -> config.mimeType = mimeType; } + /** + * Selects the algorithm recorded in each segment's {@code hash}. + */ + public static Consumer withSegmentIntegrityAlgorithm(IntegrityAlgorithm algorithm) { + Objects.requireNonNull(algorithm, "segment integrity algorithm"); + return (TDFConfig config) -> config.segmentIntegrityAlgorithm = algorithm; + } + + /** + * Selects the algorithm used for {@code rootSignature}. {@code HS256} only, which is + * also the default. + * + * @throws IllegalArgumentException if {@code algorithm} is not HS256 + */ + public static Consumer withRootIntegrityAlgorithm(IntegrityAlgorithm algorithm) { + if (algorithm != IntegrityAlgorithm.HS256) { + throw new IllegalArgumentException("unsupported root integrity algorithm: " + algorithm + + "; the root signature must be " + IntegrityAlgorithm.HS256); + } + return (TDFConfig config) -> config.integrityAlgorithm = algorithm; + } + public static Consumer withSystemMetadataAssertion() { return (TDFConfig config) -> config.systemMetadataAssertion = true; } 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 70527133..b7e942cd 100644 --- a/sdk/src/test/java/io/opentdf/platform/sdk/ConfigTest.java +++ b/sdk/src/test/java/io/opentdf/platform/sdk/ConfigTest.java @@ -5,6 +5,7 @@ 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.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; @@ -24,6 +25,20 @@ void newTDFConfig_shouldCreateDefaultConfig() { assertFalse(config.hexEncodeRootAndSegmentHashes); } + @Test + void withSegmentIntegrityAlgorithm_setsSegmentOnly() { + Config.TDFConfig config = Config.newTDFConfig( + Config.withSegmentIntegrityAlgorithm(Config.IntegrityAlgorithm.HS256)); + assertEquals(Config.IntegrityAlgorithm.HS256, config.segmentIntegrityAlgorithm); + assertEquals(Config.IntegrityAlgorithm.HS256, config.integrityAlgorithm); + } + + @Test + void withRootIntegrityAlgorithm_rejectsGmac() { + assertThrows(IllegalArgumentException.class, + () -> Config.withRootIntegrityAlgorithm(Config.IntegrityAlgorithm.GMAC)); + } + @Test void withDataAttributes_shouldAddAttributes() throws AutoConfigureException { Config.TDFConfig config = Config.newTDFConfig(Config.withDataAttributes("https://example.com/attr/attr1/value/value1", "https://example.com/attr/attr2/value/value2"));