Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
49b9527
feat(java-sdk): add comprehensive DPoP (RFC 9449) support (DSPX-3397)
dmihalcik-virtru Jun 8, 2026
a67b37c
fix(java-sdk): fix DPoP compile errors, nonce caching, and add 401-retry
dmihalcik-virtru Jun 9, 2026
e6a527e
feat(java-sdk): add --dpop and --dpop-key CLI flags to encrypt/decrypt
dmihalcik-virtru Jun 9, 2026
ea1f55d
fix(java-sdk): propagate --dpop flags to subcommand help via ScopeTyp…
dmihalcik-virtru Jun 9, 2026
4890578
fix(cmdline): replace System.exit in Supports with Callable<Integer>
dmihalcik-virtru Jun 10, 2026
244f4ba
test(java-sdk): add TokenSource unit tests and remove unused altindag…
dmihalcik-virtru Jun 11, 2026
6bc35bc
docs: DPoP nonce challenge design spec
dmihalcik-virtru Jun 16, 2026
cde8e27
docs: DPoP nonce challenge implementation plan
dmihalcik-virtru Jun 16, 2026
04f7f14
feat(sdk): retry token request with nonce on use_dpop_nonce (RFC 9449…
dmihalcik-virtru Jun 16, 2026
ca8bacd
fix(sdk): address DPoP nonce retry quality issues
dmihalcik-virtru Jun 16, 2026
6b87f96
feat(cmdline): declare dpop_nonce_challenge support
dmihalcik-virtru Jun 16, 2026
a12fef3
fix(sdk): harden DPoP retry interceptor
dmihalcik-virtru Jun 16, 2026
08454ca
fix(sdk): tighten TokenSource error handling and JWK validation
dmihalcik-virtru Jun 16, 2026
f483625
fix(cmdline): restore fast-fail validation for credential options
dmihalcik-virtru Jun 16, 2026
6105652
fix(cmdline): print stack trace for failures that escape picocli
dmihalcik-virtru Jun 16, 2026
33ca541
Add verbose logging and DPoP retry exception logging
dmihalcik-virtru Jun 16, 2026
38ac01f
Fall back to Bearer scheme when AS returns non-DPoP-bound token
dmihalcik-virtru Jun 16, 2026
16f16c9
fix(sdk): strip query and fragment from DPoP htu claim
dmihalcik-virtru Jun 16, 2026
dac58d1
test(sdk): advertise DPoP token_type in SDKBuilder mock IdP
dmihalcik-virtru Jun 16, 2026
09ea2e6
docs(sdk): correct RFC 9449 section citations and remove stale plan/spec
dmihalcik-virtru Jun 16, 2026
8b9c0b3
fix(sdk): fail loudly when DPoP is requested but well-known omits pla…
dmihalcik-virtru Jun 16, 2026
fde7c5a
fix(cmdline): validate DPoP key options at parse time + add coverage
dmihalcik-virtru Jun 16, 2026
5ca6229
debug(sdk): add DPoP path/method/claims logging to AuthInterceptor
dmihalcik-virtru Jun 17, 2026
95ce0d0
fix(sdk): disable Connect-GET on authenticated client (DPoP htm drift)
dmihalcik-virtru Jun 17, 2026
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
19 changes: 19 additions & 0 deletions cmdline/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -77,5 +77,24 @@
<artifactId>sdk</artifactId>
<version>${project.version}</version>
</dependency>
<!-- BouncyCastle is required at runtime by com.nimbusds.jose.jwk.JWK.parseFromPEMEncodedObjects,
which the CLI calls when the dpop key option is supplied. The sdk module only pulls BC in
at test scope, so the CLI must request it explicitly. -->
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcpkix-jdk18on</artifactId>
</dependency>
<!-- Test Dependencies -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.25.3</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>
127 changes: 127 additions & 0 deletions cmdline/src/main/java/io/opentdf/platform/CliDpopOptions.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package io.opentdf.platform;

import com.nimbusds.jose.JOSEException;
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.jwk.Curve;
import com.nimbusds.jose.jwk.ECKey;
import com.nimbusds.jose.jwk.JWK;
import com.nimbusds.jose.jwk.KeyUse;
import com.nimbusds.jose.jwk.gen.ECKeyGenerator;
import com.nimbusds.jose.jwk.gen.RSAKeyGenerator;
import io.opentdf.platform.sdk.DpopKeyValidation;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Optional;
import java.util.UUID;

final class CliDpopOptions {
private CliDpopOptions() {
}

static final class DpopMaterial {
final JWK jwk;
final JWSAlgorithm alg;

DpopMaterial(JWK jwk, JWSAlgorithm alg) {
this.jwk = jwk;
this.alg = alg;
}
}

static Optional<DpopMaterial> parse(String dpopAlg, Path dpopKeyPath) {
if (dpopKeyPath != null) {
JWK jwk = loadPrivateKey(dpopKeyPath);
JWSAlgorithm alg;
if (dpopAlg != null && !dpopAlg.isEmpty()) {
alg = parseAlgorithm(dpopAlg);
} else if (jwk instanceof ECKey) {
Curve curve = ((ECKey) jwk).getCurve();
try {
alg = DpopKeyValidation.inferEcAlgorithm(curve);
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException(
"DPoP key file " + dpopKeyPath + " uses unsupported EC curve " + curve, e);
}
} else {
alg = JWSAlgorithm.RS256;
}
try {
DpopKeyValidation.validate(jwk, alg);
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException(
"DPoP key file " + dpopKeyPath + " is incompatible with --dpop=" + alg + ": " + e.getMessage(),
e);
}
return Optional.of(new DpopMaterial(jwk, alg));
}
if (dpopAlg != null) {
JWSAlgorithm alg = dpopAlg.isEmpty() ? JWSAlgorithm.RS256 : parseAlgorithm(dpopAlg);
return Optional.of(new DpopMaterial(generateKeyForAlgorithm(alg), alg));
}
return Optional.empty();
}

static JWSAlgorithm parseAlgorithm(String alg) {
switch (alg.toUpperCase()) {
case "RS256": return JWSAlgorithm.RS256;
case "RS384": return JWSAlgorithm.RS384;
case "RS512": return JWSAlgorithm.RS512;
case "ES256": return JWSAlgorithm.ES256;
case "ES384": return JWSAlgorithm.ES384;
case "ES512": return JWSAlgorithm.ES512;
default:
throw new IllegalArgumentException("Unsupported DPoP algorithm: " + alg
+ ". Supported: RS256, RS384, RS512, ES256, ES384, ES512");
}
}

private static JWK loadPrivateKey(Path path) {
String pem;
try {
pem = Files.readString(path);
} catch (IOException e) {
throw new IllegalArgumentException("Cannot read DPoP key file " + path + ": " + e.getMessage(), e);
}
JWK jwk;
try {
jwk = JWK.parseFromPEMEncodedObjects(pem);
} catch (JOSEException e) {
throw new IllegalArgumentException(
"DPoP key file " + path + " is not a valid PEM-encoded key: " + e.getMessage(), e);
}
if (!jwk.isPrivate()) {
throw new IllegalArgumentException(
"DPoP key file " + path + " contains a public key only; a private key is required");
}
return jwk;
}

private static JWK generateKeyForAlgorithm(JWSAlgorithm alg) {
try {
if (JWSAlgorithm.RS256.equals(alg) || JWSAlgorithm.RS384.equals(alg) || JWSAlgorithm.RS512.equals(alg)) {
return new RSAKeyGenerator(2048)
.keyUse(KeyUse.SIGNATURE)
.keyID(UUID.randomUUID().toString())
.generate();
}
Curve curve;
if (JWSAlgorithm.ES256.equals(alg)) {
curve = Curve.P_256;
} else if (JWSAlgorithm.ES384.equals(alg)) {
curve = Curve.P_384;
} else if (JWSAlgorithm.ES512.equals(alg)) {
curve = Curve.P_521;
} else {
throw new IllegalArgumentException("Cannot generate key for algorithm: " + alg);
}
return new ECKeyGenerator(curve)
.keyUse(KeyUse.SIGNATURE)
.keyID(UUID.randomUUID().toString())
.generate();
} catch (JOSEException e) {
throw new IllegalArgumentException("Failed to generate DPoP key for algorithm " + alg + ": " + e.getMessage(), e);
}
}
}
126 changes: 99 additions & 27 deletions cmdline/src/main/java/io/opentdf/platform/Command.java
Original file line number Diff line number Diff line change
@@ -1,29 +1,14 @@
package io.opentdf.platform;

import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonDeserializationContext;
import com.google.gson.JsonDeserializer;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import com.nimbusds.jose.jwk.JWK;
import com.google.gson.GsonBuilder;
import com.google.gson.reflect.TypeToken;

import java.security.cert.X509Certificate;
import java.text.ParseException;
import com.google.gson.JsonSyntaxException;
import io.opentdf.platform.sdk.AssertionConfig;
import io.opentdf.platform.sdk.AutoConfigureException;
import io.opentdf.platform.sdk.Config;
import io.opentdf.platform.sdk.KeyType;
import io.opentdf.platform.sdk.SDK;
import io.opentdf.platform.sdk.SDKBuilder;
import picocli.CommandLine;
import picocli.CommandLine.HelpCommand;
import picocli.CommandLine.Option;

import javax.net.ssl.X509TrustManager;
import com.google.gson.reflect.TypeToken;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.File;
Expand All @@ -41,12 +26,25 @@
import java.security.spec.InvalidKeySpecException;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
import java.text.ParseException;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.Callable;
import java.util.function.Consumer;
import io.opentdf.platform.sdk.AssertionConfig;
import io.opentdf.platform.sdk.AutoConfigureException;
import io.opentdf.platform.sdk.Config;
import io.opentdf.platform.sdk.KeyType;
import io.opentdf.platform.sdk.SDK;
import io.opentdf.platform.sdk.SDKBuilder;
import org.apache.logging.log4j.Level;
import org.apache.logging.log4j.core.config.Configurator;
import picocli.CommandLine;
import picocli.CommandLine.HelpCommand;
import picocli.CommandLine.Option;

/**
* Constants for the TDF command line tool.
Expand All @@ -60,18 +58,38 @@ class Versions {
public static final String TDF_SPEC = "4.3.0";
}

@CommandLine.Command(name = "tdf", subcommands = { HelpCommand.class }, version = "{\"version\":\"" + Versions.SDK
+ "\",\"tdfSpecVersion\":\"" + Versions.TDF_SPEC + "\"}")
@CommandLine.Command(name = "tdf", subcommands = { HelpCommand.class,
Command.Supports.class }, version = "{\"version\":\"" + Versions.SDK
+ "\",\"tdfSpecVersion\":\"" + Versions.TDF_SPEC + "\"}")
class Command {

@Option(names = { "-V", "--version" }, versionHelp = true, description = "display version info")
boolean versionInfoRequested;

// Picocli injects the parsed command spec here so buildSDK() can raise
// ParameterException with the right help context when required options
// are missing for encrypt/decrypt/metadata (which all call buildSDK()).
@CommandLine.Spec
CommandLine.Model.CommandSpec spec;

@CommandLine.Command(name = "supports", description = "Check if a feature is supported")
static class Supports implements Callable<Integer> {
@CommandLine.Parameters(index = "0", description = "Feature to check (e.g., dpop)")
private String feature;

@Override
public Integer call() {
return ("dpop".equalsIgnoreCase(feature) || "dpop_nonce_challenge".equalsIgnoreCase(feature)) ? 0 : 1;
}
}
Comment thread
dmihalcik-virtru marked this conversation as resolved.

private static class AssertionKeyDeserializer implements JsonDeserializer<AssertionConfig.AssertionKey> {
@Override
public AssertionConfig.AssertionKey deserialize(JsonElement json, java.lang.reflect.Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
public AssertionConfig.AssertionKey deserialize(JsonElement json, java.lang.reflect.Type typeOfT,
JsonDeserializationContext context) throws JsonParseException {
JsonObject jsonObject = json.getAsJsonObject();
AssertionConfig.AssertionKey assertionKey = new AssertionConfig.AssertionKey(AssertionConfig.AssertionKeyAlg.NotDefined, null);
AssertionConfig.AssertionKey assertionKey = new AssertionConfig.AssertionKey(
AssertionConfig.AssertionKeyAlg.NotDefined, null);

if (jsonObject.has("alg")) {
assertionKey.alg = context.deserialize(jsonObject.get("alg"), AssertionConfig.AssertionKeyAlg.class);
Expand All @@ -81,13 +99,15 @@ public AssertionConfig.AssertionKey deserialize(JsonElement json, java.lang.refl
}
if (jsonObject.has("jwk")) {
try {
assertionKey.jwk = JWK.parse(jsonObject.get("jwk").toString());
assertionKey.jwk = com.nimbusds.jose.jwk.JWK.parse(jsonObject.get("jwk").toString());
} catch (ParseException e) {
throw new JsonParseException("Failed to parse jwk", e);
}
}
if (jsonObject.has("x5c")) {
assertionKey.x5c = context.deserialize(jsonObject.get("x5c"), new TypeToken<List<com.nimbusds.jose.util.Base64>>() {}.getType());
assertionKey.x5c = context.deserialize(jsonObject.get("x5c"),
new TypeToken<List<com.nimbusds.jose.util.Base64>>() {
}.getType());
}

return assertionKey;
Expand All @@ -105,7 +125,19 @@ private Gson buildGson() {
private static final String PEM_HEADER = "-----BEGIN (.*)-----";
private static final String PEM_FOOTER = "-----END (.*)-----";

@Option(names = { "--client-secret" }, required = true)
@Option(names = { "-v", "--verbose" }, scope = CommandLine.ScopeType.INHERIT, defaultValue = "false", description = "Enable verbose output including stack traces on error")
void setVerbose(boolean verbose) {
this.verbose = verbose;
if (verbose) {
var root = org.apache.logging.log4j.LogManager.getRootLogger();
if (!root.getLevel().isLessSpecificThan(Level.DEBUG)) {
Configurator.setRootLevel(Level.DEBUG);
}
}
}
boolean verbose;

@Option(names = { "--client-secret" })
private String clientSecret;

@Option(names = { "-h", "--plaintext" }, defaultValue = "false")
Expand All @@ -114,12 +146,20 @@ private Gson buildGson() {
@Option(names = { "-i", "--insecure" }, defaultValue = "false")
private boolean insecure;

@Option(names = { "--client-id" }, required = true)
@Option(names = { "--client-id" })
private String clientId;

@Option(names = { "-p", "--platform-endpoint" }, required = true)
@Option(names = { "-p", "--platform-endpoint" })
private String platformEndpoint;

@Option(names = {
"--dpop" }, arity = "0..1", fallbackValue = "", scope = CommandLine.ScopeType.INHERIT, description = "Enable DPoP (RFC 9449). Optional: specify algorithm (RS256, RS384, RS512, ES256, ES384, ES512). Default: RS256.")
private String dpopAlg;

@Option(names = {
"--dpop-key" }, scope = CommandLine.ScopeType.INHERIT, description = "Enable DPoP using a PEM-encoded private key at <path>. Algorithm inferred from key type. Combinable with --dpop=<alg>.")
private Path dpopKeyPath;

private Object correctKeyType(AssertionConfig.AssertionKeyAlg alg, Object key, boolean publicKey)
throws RuntimeException {
if (alg == AssertionConfig.AssertionKeyAlg.HS256) {
Expand Down Expand Up @@ -261,16 +301,47 @@ void encrypt(
}

private SDK buildSDK() {
// The picocli @Option annotations on platformEndpoint/clientId/clientSecret are
// intentionally NOT marked required = true so that `tdf supports <feature>` can
// run without credentials. Subcommands that actually build an SDK enforce them
// here so the failure surfaces as a normal picocli ParameterException (exit 2)
// rather than a deep SDK error.
if (platformEndpoint == null || platformEndpoint.isEmpty()) {
throw new CommandLine.ParameterException(spec.commandLine(),
"Missing required option: '--platform-endpoint=<platformEndpoint>'");
}
if (clientId == null || clientId.isEmpty()) {
throw new CommandLine.ParameterException(spec.commandLine(),
"Missing required option: '--client-id=<clientId>'");
}
if (clientSecret == null || clientSecret.isEmpty()) {
throw new CommandLine.ParameterException(spec.commandLine(),
"Missing required option: '--client-secret=<clientSecret>'");
}

SDKBuilder builder = new SDKBuilder();
if (insecure) {
builder.insecureSslFactory();
}

applyDPoPOptions(builder);

return builder.platformEndpoint(platformEndpoint)
.clientSecret(clientId, clientSecret).useInsecurePlaintextConnection(plaintext)
.build();
}

private void applyDPoPOptions(SDKBuilder builder) {
try {
CliDpopOptions.parse(dpopAlg, dpopKeyPath).ifPresent(m -> {
builder.dpopKey(m.jwk);
builder.dpopAlgorithm(m.alg);
});
} catch (IllegalArgumentException e) {
throw new CommandLine.ParameterException(spec.commandLine(), e.getMessage());
}
}

@CommandLine.Command(name = "decrypt")
void decrypt(
@Option(names = { "-f", "--file" }, required = true) Path tdfPath,
Expand Down Expand Up @@ -300,7 +371,8 @@ void decrypt(
// try it as a file path
try {
String fileJson = new String(Files.readAllBytes(Paths.get(assertionVerificationInput)));
assertionVerificationKeys = gson.fromJson(fileJson, Config.AssertionVerificationKeys.class);
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) {
Expand Down
13 changes: 11 additions & 2 deletions cmdline/src/main/java/io/opentdf/platform/TDF.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,16 @@

public class TDF {
public static void main(String[] args) {
var result = new CommandLine(new Command()).execute(args);
System.exit(result);
var command = new Command();
var cmd = new CommandLine(command);
cmd.setExecutionExceptionHandler((ex, commandLine, parseResult) -> {
if (command.verbose) {
ex.printStackTrace(System.err);
} else {
System.err.println(ex.getMessage() != null ? ex.getMessage() : ex.toString());
}
return 1;
});
System.exit(cmd.execute(args));
}
}
Loading
Loading