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
38 changes: 37 additions & 1 deletion cmdline/src/main/java/io/opentdf/platform/Command.java
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,26 @@ private void printFeatures(List<String> features) {
}
}

/**
* Accepts integrity algorithm names in any casing (`hs256`, `HS256`, `GMac`), so the
* flags behave the same as the equivalents in the other OpenTDF CLIs. Attached to the
* options directly rather than via
* {@code CommandLine#setCaseInsensitiveEnumValuesAllowed}, so parsing does not depend
* on how the CommandLine was configured.
*/
static class IntegrityAlgorithmConverter implements CommandLine.ITypeConverter<Config.IntegrityAlgorithm> {
@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<AssertionConfig.AssertionKey> {
@Override
public AssertionConfig.AssertionKey deserialize(JsonElement json, java.lang.reflect.Type typeOfT,
Expand Down Expand Up @@ -258,19 +278,35 @@ 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 = {
"--root-integrity-algorithm" }, defaultValue = Option.NULL_VALUE, converter = IntegrityAlgorithmConverter.class, description = "Algorithm for the TDF root signature, one of ${COMPLETION-CANDIDATES} (case-insensitive). Only HS256 is supported; GMAC cannot authenticate a root signature.") Optional<Config.IntegrityAlgorithm> rootIntegrityAlgorithm,
@Option(names = {
"--segment-integrity-algorithm" }, defaultValue = Option.NULL_VALUE, converter = IntegrityAlgorithmConverter.class, description = "Algorithm for per-segment hashes, one of ${COMPLETION-CANDIDATES} (case-insensitive). Defaults to GMAC.") Optional<Config.IntegrityAlgorithm> segmentIntegrityAlgorithm,
@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, AutoConfigureException {

// Validated before buildSDK() so an unsupported algorithm is reported as a usage
// error rather than after a platform round trip.
List<Consumer<Config.TDFConfig>> 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();
ki.URL = k;
return ki;
}).toArray(Config.KASInfo[]::new);

List<Consumer<Config.TDFConfig>> configs = new ArrayList<>();
List<Consumer<Config.TDFConfig>> configs = new ArrayList<>(integrityConfigs);
configs.add(Config.withKasInformation(kasInfos));
metadata.map(Config::withMetaData).ifPresent(configs::add);
configs.add(Config.withSystemMetadataAssertion());
Expand Down
83 changes: 83 additions & 0 deletions cmdline/src/test/java/io/opentdf/platform/CommandTest.java
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
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.ValueSource;
import picocli.CommandLine;

import java.io.ByteArrayOutputStream;
Expand Down Expand Up @@ -137,4 +140,84 @@ void supports_unknownFeature_json_false() {
assertThat(out.trim()).isEqualTo("{\"unknown_feature\":false}");
}

/**
* xtest detects these features by grepping the encrypt help, so the literal flag
* names have to survive help rendering.
*/
@Test
void encryptHelp_advertisesIntegrityAlgorithmFlags() {
String help = new CommandLine(new Command()).getSubcommands().get("encrypt").getUsageMessage(
CommandLine.Help.Ansi.OFF);

assertThat(help).contains("root-integrity-algorithm");
assertThat(help).contains("segment-integrity-algorithm");
assertThat(help).contains("HS256", "GMAC");
}

@ParameterizedTest
@ValueSource(strings = { "gmac", "GMAC", "GMac" })
void encrypt_rootIntegrityAlgorithmGmac_isRejected(String value) {
StringWriter err = new StringWriter();
CommandLine cli = new CommandLine(new Command());
cli.setErr(new PrintWriter(err));

int code = cli.execute("encrypt", "-k", "https://kas.example.com", "-f", "/dev/null",
"--root-integrity-algorithm", value);

assertThat(code).isNotZero();
assertThat(err.toString()).contains("unsupported root integrity algorithm");
}

@ParameterizedTest
@ValueSource(strings = { "hs256", "HS256" })
void encrypt_rootIntegrityAlgorithmHs256_isAccepted(String value) {
// Accepted at parse/validation time; the command still stops at the missing
// credentials, which is a different (usage) failure than the GMAC rejection.
StringWriter err = new StringWriter();
CommandLine cli = new CommandLine(new Command());
cli.setErr(new PrintWriter(err));

int code = cli.execute("encrypt", "-k", "https://kas.example.com", "-f", "/dev/null",
"--root-integrity-algorithm", value);

assertThat(code).isEqualTo(CommandLine.ExitCode.USAGE);
assertThat(err.toString()).doesNotContain("unsupported root integrity algorithm");
assertThat(err.toString()).contains("Missing required option: '--platform-endpoint=<platformEndpoint>'");
}

@ParameterizedTest
@ValueSource(strings = { "gmac", "GMAC", "hs256", "HS256", "HS256 " })
void encrypt_segmentIntegrityAlgorithm_acceptsBothValuesInAnyCasing(String value) {
StringWriter err = new StringWriter();
CommandLine cli = new CommandLine(new Command());
cli.setErr(new PrintWriter(err));

int code = cli.execute("encrypt", "-k", "https://kas.example.com", "-f", "/dev/null",
"--segment-integrity-algorithm", value);

assertThat(code).isEqualTo(CommandLine.ExitCode.USAGE);
assertThat(err.toString()).contains("Missing required option: '--platform-endpoint=<platformEndpoint>'");
}

@Test
void encrypt_unknownIntegrityAlgorithm_isRejected() {
StringWriter err = new StringWriter();
CommandLine cli = new CommandLine(new Command());
cli.setErr(new PrintWriter(err));

int code = cli.execute("encrypt", "-k", "https://kas.example.com", "-f", "/dev/null",
"--segment-integrity-algorithm", "md5");

assertThat(code).isEqualTo(CommandLine.ExitCode.USAGE);
assertThat(err.toString()).contains("--segment-integrity-algorithm");
}

@Test
void integrityAlgorithmConverter_isCaseInsensitive() {
var converter = new Command.IntegrityAlgorithmConverter();

assertThat(converter.convert("gmac")).isEqualTo(Config.IntegrityAlgorithm.GMAC);
assertThat(converter.convert("HS256")).isEqualTo(Config.IntegrityAlgorithm.HS256);
assertThat(converter.convert("hS256")).isEqualTo(Config.IntegrityAlgorithm.HS256);
}
}
30 changes: 30 additions & 0 deletions sdk/src/main/java/io/opentdf/platform/sdk/Config.java
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,36 @@ public static Consumer<TDFConfig> withMimeType(String mimeType) {
return (TDFConfig config) -> config.mimeType = mimeType;
}

/**
* Selects the algorithm recorded in each segment's {@code hash}. Both algorithms
* authenticate the segment's ciphertext under the payload key: {@code GMAC} (the
* default) reuses the AES-GCM tag the cipher already produced over those exact
* bytes, {@code HS256} HMACs them.
*/
public static Consumer<TDFConfig> 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.
* <p>
* {@code GMAC} is rejected: the root signature covers the aggregate of the segment
* hashes, which never passes through AES-GCM, so there is no authentication tag to
* recover from it. A "GMAC" root signature is a copy of the last segment hash —
* keyless, and forgeable by anyone who can edit the manifest.
*
* @throws IllegalArgumentException if {@code algorithm} is not HS256
*/
public static Consumer<TDFConfig> 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<TDFConfig> withSystemMetadataAssertion() {
return (TDFConfig config) -> config.systemMetadataAssertion = true;
}
Expand Down
Loading