Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
ba9592e
feat(java-sdk): add comprehensive DPoP (RFC 9449) support (DSPX-3397)
dmihalcik-virtru Jun 8, 2026
b899a15
fix(java-sdk): fix DPoP compile errors, nonce caching, and add 401-retry
dmihalcik-virtru Jun 9, 2026
7b30aef
feat(java-sdk): add --dpop and --dpop-key CLI flags to encrypt/decrypt
dmihalcik-virtru Jun 9, 2026
5755ea0
fix(java-sdk): propagate --dpop flags to subcommand help via ScopeTyp…
dmihalcik-virtru Jun 9, 2026
6e55f55
fix(cmdline): replace System.exit in Supports with Callable<Integer>
dmihalcik-virtru Jun 10, 2026
f227a1e
test(java-sdk): add TokenSource unit tests and remove unused altindag…
dmihalcik-virtru Jun 11, 2026
3e05969
docs: DPoP nonce challenge design spec
dmihalcik-virtru Jun 16, 2026
b4062ee
docs: DPoP nonce challenge implementation plan
dmihalcik-virtru Jun 16, 2026
a5b8228
feat(sdk): retry token request with nonce on use_dpop_nonce (RFC 9449…
dmihalcik-virtru Jun 16, 2026
796ad9b
fix(sdk): address DPoP nonce retry quality issues
dmihalcik-virtru Jun 16, 2026
baca983
feat(cmdline): declare dpop_nonce_challenge support
dmihalcik-virtru Jun 16, 2026
3674856
fix(sdk): harden DPoP retry interceptor
dmihalcik-virtru Jun 16, 2026
c63890e
fix(sdk): tighten TokenSource error handling and JWK validation
dmihalcik-virtru Jun 16, 2026
76d3b77
fix(cmdline): restore fast-fail validation for credential options
dmihalcik-virtru Jun 16, 2026
c9268d1
fix(cmdline): print stack trace for failures that escape picocli
dmihalcik-virtru Jun 16, 2026
ad4894a
Add verbose logging and DPoP retry exception logging
dmihalcik-virtru Jun 16, 2026
f3c8180
Fall back to Bearer scheme when AS returns non-DPoP-bound token
dmihalcik-virtru Jun 16, 2026
3568013
fix(sdk): strip query and fragment from DPoP htu claim
dmihalcik-virtru Jun 16, 2026
d5eca4a
test(sdk): advertise DPoP token_type in SDKBuilder mock IdP
dmihalcik-virtru Jun 16, 2026
631fa05
docs(sdk): correct RFC 9449 section citations and remove stale plan/spec
dmihalcik-virtru Jun 16, 2026
e830c4e
fix(sdk): fail loudly when DPoP is requested but well-known omits pla…
dmihalcik-virtru Jun 16, 2026
3a55f31
fix(cmdline): validate DPoP key options at parse time + add coverage
dmihalcik-virtru Jun 16, 2026
68ff681
debug(sdk): add DPoP path/method/claims logging to AuthInterceptor
dmihalcik-virtru Jun 17, 2026
d7e6967
fix(sdk): disable Connect-GET on authenticated client (DPoP htm drift)
dmihalcik-virtru Jun 17, 2026
0958854
fix(sdk): log malformed DPoP nonce challenge instead of swallowing 401
dmihalcik-virtru Jul 1, 2026
5a457ff
fix(sdk): drop stale DPoP proof header on Bearer-downgrade retry
dmihalcik-virtru Jul 1, 2026
7334294
fix(sdk): read token and scheme atomically to close a data race
dmihalcik-virtru Jul 1, 2026
714eec5
fix(sdk): reject public-only DPoP JWK in central validation
dmihalcik-virtru Jul 1, 2026
eb3533e
refactor(cmdline): validate DpopMaterial in its constructor
dmihalcik-virtru Jul 1, 2026
c6120d0
docs(sdk): correct getAuthHeaders Javadoc for Bearer fallback
dmihalcik-virtru Jul 1, 2026
a66014c
test(sdk): assert DPoP proof htm claim reflects request method
dmihalcik-virtru Jul 1, 2026
8e091ef
test(sdk): cover AuthInterceptor connect-kotlin request/response path
dmihalcik-virtru Jul 1, 2026
ab46e33
refactor(sdk): model token scheme as an enum, make AuthHeaders static
dmihalcik-virtru Jul 1, 2026
4ed3952
docs(sdk): clarify retry-once semantics and trim redundant helper Jav…
dmihalcik-virtru Jul 1, 2026
c36755a
fix(sdk): surface persistent DPoP nonce challenge after retry
dmihalcik-virtru Jul 2, 2026
28510cb
docs: note JAVA_HOME setup via brew for local builds
dmihalcik-virtru Jul 2, 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
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@

## Build, Test, and Dev Commands

- `java`/`mvn` are not on PATH and `JAVA_HOME` is unset here. Set it from the Homebrew prefix (compiles the release-11 target fine, no version lookup needed):
`export JAVA_HOME="$(brew --prefix openjdk@21)/libexec/openjdk.jdk/Contents/Home"`
Other brew JDKs available: `openjdk` (latest), `openjdk@21`, `openjdk@25`, `openjdk@26`.
- Build everything (runs codegen): `mvn clean install`
- Fast local build (skip tests): `mvn clean install -DskipTests`
- SDK unit tests: `mvn -pl sdk test`
Expand Down
19 changes: 19 additions & 0 deletions cmdline/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,25 @@
<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>

<profiles>
Expand Down
129 changes: 129 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,129 @@
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) {
// Validate in the constructor so a DpopMaterial is never valid only by
// convention of its caller — every instance has a compatible key/alg pair.
DpopKeyValidation.validate(jwk, 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 {
return Optional.of(new DpopMaterial(jwk, alg));
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException(
"DPoP key file " + dpopKeyPath + " is incompatible with --dpop=" + alg + ": " + e.getMessage(),
e);
}
}
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);
}
}
}
123 changes: 99 additions & 24 deletions cmdline/src/main/java/io/opentdf/platform/Command.java
Original file line number Diff line number Diff line change
@@ -1,26 +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.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 com.google.gson.reflect.TypeToken;

import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
Expand All @@ -39,13 +27,26 @@
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.
* These must be compile-time constants to appear in annotations.
Expand All @@ -58,17 +59,37 @@ 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 @@ -78,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 @@ -102,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 @@ -111,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 @@ -258,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 @@ -297,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
Loading
Loading