Skip to content
Merged
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
135 changes: 90 additions & 45 deletions cmdline/src/main/java/io/opentdf/platform/Command.java
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,16 @@
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.io.PrintWriter;
import java.net.URI;
import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.security.KeyFactory;
import java.security.NoSuchAlgorithmException;
Expand Down Expand Up @@ -77,7 +81,8 @@ class Command {
static class Supports implements Callable<Integer> {
// Static, always-on capabilities of this build. There is no "known but
// unsupported" feature here: anything not in this list is simply unrecognized.
private static final List<String> FEATURES = List.of("dpop", "dpop_nonce_challenge");
private static final List<String> FEATURES = List.of("dpop", "dpop_nonce_challenge",
"partial_decrypt", "http_range_source");

@CommandLine.Parameters(index = "0", arity = "0..1", description = "Feature to check (e.g., dpop). Omit to list all supported features.")
private String feature;
Expand Down Expand Up @@ -354,7 +359,10 @@ private SDK buildSDK() {

@CommandLine.Command(name = "decrypt")
void decrypt(
@Option(names = { "-f", "--file" }, required = true) Path tdfPath,
@Option(names = { "-f", "--file" }, required = true, description = "TDF file path, or an http(s):// URL read with range requests") String tdfFile,
@Option(names = { "-o", "--output" }, defaultValue = Option.NULL_VALUE, description = "Write plaintext here, only on success (default: stdout)") Optional<Path> output,
@Option(names = { "--offset" }, defaultValue = "0", description = "Plaintext byte offset to start decrypting from") long offset,
@Option(names = { "--length" }, defaultValue = "-1", description = "Plaintext bytes to decrypt from --offset; negative decrypts to the end") long length,
@Option(names = {
"--rewrap-key-type" }, defaultValue = Option.NULL_VALUE, description = "Preferred rewrap algorithm, one of ${COMPLETION-CANDIDATES}") Optional<KeyType> rewrapKeyType,
@Option(names = {
Expand All @@ -367,60 +375,97 @@ void decrypt(
throws Exception {
try (var sdk = buildSDK()) {
var opts = new ArrayList<Consumer<Config.TDFReaderConfig>>();
try (var in = FileChannel.open(tdfPath, StandardOpenOption.READ)) {
try (var stdout = new BufferedOutputStream(System.out)) {
if (assertionVerification.isPresent()) {
var assertionVerificationInput = assertionVerification.get();
Gson gson = buildGson();

Config.AssertionVerificationKeys assertionVerificationKeys;
try {
assertionVerificationKeys = gson.fromJson(assertionVerificationInput,
Config.AssertionVerificationKeys.class);
} catch (JsonSyntaxException e) {
// try it as a file path
try {
String fileJson = Files.readString(Paths.get(assertionVerificationInput));
assertionVerificationKeys = gson.fromJson(fileJson,
Config.AssertionVerificationKeys.class);
} catch (JsonSyntaxException e2) {
throw new RuntimeException("Failed to parse assertion verification keys from file", e2);
} catch (Exception e3) {
throw new RuntimeException(
"Could not parse assertion verification keys as json string or path to file",
e3);
}
}

for (Map.Entry<String, AssertionConfig.AssertionKey> entry : assertionVerificationKeys.keys
.entrySet()) {
try {
Object correctedKey = correctKeyType(entry.getValue().alg, entry.getValue().key, true);
entry.setValue(new AssertionConfig.AssertionKey(entry.getValue().alg, correctedKey));
} catch (Exception e) {
throw new RuntimeException("Error with assertion verification key: " + e.getMessage(),
e);
}
}
opts.add(Config.withAssertionVerificationKeys(assertionVerificationKeys));
if (assertionVerification.isPresent()) {
var assertionVerificationInput = assertionVerification.get();
Gson gson = buildGson();

Config.AssertionVerificationKeys assertionVerificationKeys;
try {
assertionVerificationKeys = gson.fromJson(assertionVerificationInput,
Config.AssertionVerificationKeys.class);
} catch (JsonSyntaxException e) {
// try it as a file path
try {
String fileJson = Files.readString(Paths.get(assertionVerificationInput));
assertionVerificationKeys = gson.fromJson(fileJson,
Config.AssertionVerificationKeys.class);
} catch (JsonSyntaxException e2) {
throw new RuntimeException("Failed to parse assertion verification keys from file", e2);
} catch (Exception e3) {
throw new RuntimeException(
"Could not parse assertion verification keys as json string or path to file",
e3);
}
}

if (disableAssertionVerification) {
opts.add(Config.withDisableAssertionVerification(true));
for (Map.Entry<String, AssertionConfig.AssertionKey> entry : assertionVerificationKeys.keys
.entrySet()) {
try {
Object correctedKey = correctKeyType(entry.getValue().alg, entry.getValue().key, true);
entry.setValue(new AssertionConfig.AssertionKey(entry.getValue().alg, correctedKey));
} catch (Exception e) {
throw new RuntimeException("Error with assertion verification key: " + e.getMessage(),
e);
}
rewrapKeyType.map(Config::WithSessionKeyType).ifPresent(opts::add);
}
opts.add(Config.withAssertionVerificationKeys(assertionVerificationKeys));
}

if (disableAssertionVerification) {
opts.add(Config.withDisableAssertionVerification(true));
}
rewrapKeyType.map(Config::WithSessionKeyType).ifPresent(opts::add);

ignoreAllowlist.ifPresent(aBoolean -> opts.add(Config.WithIgnoreKasAllowlist(aBoolean)));
kasAllowlistStr.ifPresent(s -> opts.add(Config.WithKasAllowlist(s.split(","))));

ignoreAllowlist.ifPresent(aBoolean -> opts.add(Config.WithIgnoreKasAllowlist(aBoolean)));
kasAllowlistStr.ifPresent(s -> opts.add(Config.WithKasAllowlist(s.split(","))));
var readerConfig = Config.newTDFReaderConfig(opts.toArray(new Consumer[0]));

var readerConfig = Config.newTDFReaderConfig(opts.toArray(new Consumer[0]));
if (tdfFile.startsWith("http://") || tdfFile.startsWith("https://")) {
var reader = sdk.loadTDF(URI.create(tdfFile), readerConfig);
writePlaintext(out -> reader.readPayload(out, offset, length), output);
} else {
try (var in = FileChannel.open(Path.of(tdfFile), StandardOpenOption.READ)) {
var reader = sdk.loadTDF(in, readerConfig);
reader.readPayload(stdout);
writePlaintext(out -> reader.readPayload(out, offset, length), output);
}
}
}
}

// With an output file, write to a sibling temp file and move it into place only on
// success, so a failed decrypt (e.g. an offset past the end) leaves no file behind.
private interface PlaintextWriter {
void writeTo(OutputStream out) throws Exception;
}

private static void writePlaintext(PlaintextWriter plaintext, Optional<Path> output) throws Exception {
if (output.isEmpty()) {
try (var stdout = new BufferedOutputStream(System.out)) {
plaintext.writeTo(stdout);
}
return;
}
Path target = output.get().toAbsolutePath();
Path tmp = Files.createTempFile(target.getParent(), target.getFileName() + ".", ".tmp");
Comment thread
sujankota marked this conversation as resolved.
try {
try (OutputStream out = new BufferedOutputStream(Files.newOutputStream(tmp))) {
plaintext.writeTo(out);
}
moveIntoPlace(tmp, target);
} finally {
Files.deleteIfExists(tmp);
}
}
Comment thread
sujankota marked this conversation as resolved.

static void moveIntoPlace(Path source, Path target) throws IOException {
try {
Files.move(source, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
} catch (AtomicMoveNotSupportedException e) {
Files.move(source, target, StandardCopyOption.REPLACE_EXISTING);
}
}

@CommandLine.Command(name = "metadata")
void readMetadata(
@Option(names = { "-f", "--file" }, required = true) Path tdfPath,
Expand Down
34 changes: 32 additions & 2 deletions cmdline/src/test/java/io/opentdf/platform/CommandTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,19 @@

import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import picocli.CommandLine;

import java.io.ByteArrayOutputStream;
import java.io.PrintStream;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;

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

Expand Down Expand Up @@ -44,6 +50,12 @@ void supports_dpop_nonce_challenge_exits_0() {
assertThat(code).isEqualTo(0);
}

@Test
void supports_partial_decrypt_and_http_range_source_exit_0() {
assertThat(new CommandLine(new Command()).execute("supports", "partial_decrypt")).isEqualTo(0);
assertThat(new CommandLine(new Command()).execute("supports", "http_range_source")).isEqualTo(0);
}

@Test
void supports_unknown_feature_exits_1() {
int code = new CommandLine(new Command()).execute("supports", "unknown_feature");
Expand Down Expand Up @@ -95,7 +107,8 @@ void supports_noArgs_listsFeatures_exits0() {
String out = captureStdout(() -> code[0] = new CommandLine(new Command()).execute("supports"));

assertThat(code[0]).isEqualTo(0);
assertThat(out.lines()).containsExactlyInAnyOrder("dpop", "dpop_nonce_challenge");
assertThat(out.lines()).containsExactlyInAnyOrder("dpop", "dpop_nonce_challenge",
"partial_decrypt", "http_range_source");
}

@Test
Expand All @@ -104,7 +117,8 @@ void supports_noArgs_json_listsFeatures() {
String out = captureStdout(() -> code[0] = new CommandLine(new Command()).execute("supports", "--json"));

assertThat(code[0]).isEqualTo(0);
assertThat(out.trim()).isEqualTo("{\"dpop\":true,\"dpop_nonce_challenge\":true}");
assertThat(out.trim()).isEqualTo("{\"dpop\":true,\"dpop_nonce_challenge\":true,"
+ "\"partial_decrypt\":true,\"http_range_source\":true}");
}

@Test
Expand Down Expand Up @@ -137,4 +151,20 @@ void supports_unknownFeature_json_false() {
assertThat(out.trim()).isEqualTo("{\"unknown_feature\":false}");
}

@Test
void moveIntoPlaceFallsBackWhenAtomicMoveIsUnsupported(@TempDir Path tempDir) throws Exception {
Path source = Files.writeString(tempDir.resolve("plaintext.tmp"), "plaintext");
Path archive = tempDir.resolve("output.zip");

try (var zip = FileSystems.newFileSystem(
URI.create("jar:" + archive.toUri()), Map.of("create", "true"))) {
Path target = zip.getPath("/plaintext.txt");

Command.moveIntoPlace(source, target);

assertThat(Files.readString(target)).isEqualTo("plaintext");
assertThat(source).doesNotExist();
}
}

}
Loading
Loading