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
126 changes: 81 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,15 @@
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.io.PrintWriter;
import java.nio.channels.FileChannel;
import java.net.URI;
import java.nio.charset.StandardCharsets;
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 +80,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 +358,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 +374,89 @@ 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");
try {
try (OutputStream out = new BufferedOutputStream(Files.newOutputStream(tmp))) {
plaintext.writeTo(out);
}
Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
} finally {
Files.deleteIfExists(tmp);
}
}

@CommandLine.Command(name = "metadata")
void readMetadata(
@Option(names = { "-f", "--file" }, required = true) Path tdfPath,
Expand Down
12 changes: 10 additions & 2 deletions cmdline/src/test/java/io/opentdf/platform/CommandTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,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 +101,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 +111,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
152 changes: 152 additions & 0 deletions sdk/src/main/java/io/opentdf/platform/sdk/HttpRangeChannel.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
package io.opentdf.platform.sdk;

import okhttp3.OkHttpClient;
import okhttp3.Request;

import java.io.IOException;
import java.net.URI;
import java.nio.ByteBuffer;
import java.nio.channels.ClosedChannelException;
import java.nio.channels.NonWritableChannelException;
import java.nio.channels.SeekableByteChannel;
import java.util.regex.Pattern;

/**
* A read-only {@link SeekableByteChannel} over a remote resource, where every read is one
* HTTP range request. The size is learned by probing {@code bytes=0-0}, and every later
* response must report the same total size, so a resource that changes underneath the
* reader is rejected rather than spliced together.
* <p>
* Redirects are not followed: the client handed in must have redirects disabled, and a 3xx
* is reported as an error so the caller can use the final URL.
*/
// ponytail: no read cache, so ZipReader's small header reads cost a few dozen requests per
// load; add a read-ahead window if that shows up over a real network
class HttpRangeChannel implements SeekableByteChannel {
private static final Pattern CONTENT_RANGE = Pattern.compile("bytes (\\d+)-(\\d+)/(\\d+)");

private final OkHttpClient client;
private final URI url;
private final long size;
private long position;
private boolean open = true;

HttpRangeChannel(OkHttpClient client, URI url) throws IOException {
this.client = client;
this.url = url;
this.size = fetch(ByteBuffer.allocate(1), 0);
}

@Override
public int read(ByteBuffer dst) throws IOException {
ensureOpen();
if (position >= size) {
return -1;
}
int len = (int) Math.min(dst.remaining(), size - position);
if (len == 0) {
return 0;
}
var window = dst.slice();
window.limit(len);
long total = fetch(window, position);
if (total != size) {
throw new IOException("remote resource size changed from " + size + " to " + total + " bytes");
}
dst.position(dst.position() + len);
position += len;
return len;
}

/**
* Fills {@code dst} with the bytes starting at {@code offset} and returns the total size
* of the resource as reported by the server.
*/
private long fetch(ByteBuffer dst, long offset) throws IOException {
long end = offset + dst.remaining() - 1;
var request = new Request.Builder()
.url(url.toString())
.header("Range", "bytes=" + offset + "-" + end)
.build();
try (var response = client.newCall(request).execute()) {
int status = response.code();
if (status == 200) {
throw new SDK.RangeNotSupportedException(
"status 200 for bytes " + offset + "-" + end);
}
if (status >= 300 && status < 400) {
throw new IOException("range request redirected (" + status
+ "); redirects are not followed, use the final URL");
}
if (status != 206) {
throw new IOException("range request for bytes " + offset + "-" + end + " failed: " + status);
}
String contentRange = response.header("Content-Range");
var m = contentRange == null ? null : CONTENT_RANGE.matcher(contentRange);
if (m == null || !m.matches()) {
throw new SDK.RangeNotSupportedException("invalid Content-Range " + contentRange);
}
long gotStart = Long.parseLong(m.group(1));
long gotEnd = Long.parseLong(m.group(2));
if (gotStart != offset || gotEnd != end) {
throw new SDK.RangeNotSupportedException("requested bytes " + offset + "-" + end
+ ", got " + gotStart + "-" + gotEnd);
}
var source = response.body().source();
while (dst.hasRemaining()) {
if (source.read(dst) < 0) {
throw new IOException("range response for bytes " + offset + "-" + end + " ended early");
}
}
return Long.parseLong(m.group(3));
}
}

@Override
public long position() throws IOException {
ensureOpen();
return position;
}

@Override
public SeekableByteChannel position(long newPosition) throws IOException {
ensureOpen();
if (newPosition < 0) {
throw new IllegalArgumentException("negative position " + newPosition);
}
position = newPosition;
return this;
}

@Override
public long size() throws IOException {
ensureOpen();
return size;
}

@Override
public int write(ByteBuffer src) {
throw new NonWritableChannelException();
}

@Override
public SeekableByteChannel truncate(long size) {
throw new NonWritableChannelException();
}

@Override
public boolean isOpen() {
return open;
}

@Override
public void close() {
open = false;
}

private void ensureOpen() throws ClosedChannelException {
if (!open) {
throw new ClosedChannelException();
}
}
}
Loading
Loading