From 49b95273403b58c99ee20b122a44f3b92f88b0c7 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Mon, 8 Jun 2026 11:14:03 -0400 Subject: [PATCH 01/24] feat(java-sdk): add comprehensive DPoP (RFC 9449) support (DSPX-3397) - Add server-issued nonce caching infrastructure in TokenSource with per-origin storage - Add dpopKey() method to SDKBuilder for caller-supplied RSA keys (defaults to auto-generated ephemeral key) - Update AuthInterceptor to cache DPoP-Nonce from successful responses - Add 'supports dpop' CLI command for xtest feature detection - Extend TokenSource.getAuthHeaders() to accept optional nonce parameter for proof generation Implementation uses Nimbus OAuth2 SDK's DefaultDPoPProofFactory for RFC 9449 compliant DPoP proof generation with htm/htu/iat/jti claims (plus ath for resource endpoints). Current implementation uses RSA-2048/RS256 for DPoP keys. The SDK already had DPoP proof generation via Nimbus OAuth2 SDK; this PR adds nonce support infrastructure and makes the DPoP key configurable. Note: Full 401 retry logic with nonce challenges requires Connect RPC interceptor changes and is deferred to future work. Nonce caching infrastructure is in place for when retry logic is added. Co-Authored-By: Claude Sonnet 4.5 Signed-off-by: Dave Mihalcik --- .../java/io/opentdf/platform/Command.java | 20 ++++++- .../io/opentdf/platform/sdk/SDKBuilder.java | 40 +++++++++---- .../io/opentdf/platform/sdk/TokenSource.java | 58 ++++++++++++++++++- .../opentdf/platform/sdk/AuthInterceptor.kt | 6 ++ 4 files changed, 111 insertions(+), 13 deletions(-) diff --git a/cmdline/src/main/java/io/opentdf/platform/Command.java b/cmdline/src/main/java/io/opentdf/platform/Command.java index 685f8782..e077cb84 100644 --- a/cmdline/src/main/java/io/opentdf/platform/Command.java +++ b/cmdline/src/main/java/io/opentdf/platform/Command.java @@ -60,13 +60,31 @@ class Versions { public static final String TDF_SPEC = "4.3.0"; } -@CommandLine.Command(name = "tdf", subcommands = { HelpCommand.class }, version = "{\"version\":\"" + Versions.SDK +@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; + @CommandLine.Command(name = "supports", description = "Check if a feature is supported") + static class Supports implements Runnable { + @CommandLine.Parameters(index = "0", description = "Feature to check (e.g., dpop)") + private String feature; + + @Override + public void run() { + // Check if the requested feature is supported + if ("dpop".equalsIgnoreCase(feature)) { + // DPoP (RFC 9449) is supported + System.exit(0); + } else { + // Unknown or unsupported feature + System.exit(1); + } + } + } + private static class AssertionKeyDeserializer implements JsonDeserializer { @Override public AssertionConfig.AssertionKey deserialize(JsonElement json, java.lang.reflect.Type typeOfT, JsonDeserializationContext context) throws JsonParseException { diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/SDKBuilder.java b/sdk/src/main/java/io/opentdf/platform/sdk/SDKBuilder.java index 5b7bbcc7..39b75751 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/SDKBuilder.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/SDKBuilder.java @@ -64,6 +64,7 @@ public class SDKBuilder { private AuthorizationGrant authzGrant; private ProtocolType protocolType = ProtocolType.CONNECT; private SrtSigner srtSigner; + private RSAKey dpopKey; private static final Logger logger = LoggerFactory.getLogger(SDKBuilder.class); @@ -213,6 +214,18 @@ public SDKBuilder srtSigner(SrtSigner signer) { return this; } + /** + * Configure a custom RSA key for DPoP (RFC 9449) proof generation. + * If not provided, the SDK will auto-generate an ephemeral RSA-2048 key. + * + * @param dpopKey RSA key to use for DPoP proofs + * @return this builder instance for method chaining + */ + public SDKBuilder dpopKey(RSAKey dpopKey) { + this.dpopKey = dpopKey; + return this; + } + private Interceptor getAuthInterceptor(RSAKey rsaKey) { if (platformEndpoint == null) { throw new SDKException("cannot build an SDK without specifying the platform endpoint"); @@ -305,20 +318,25 @@ ServicesAndInternals buildServices() { "gRPC-Web is designed for web browsers and typically operates over HTTP/1.1, " + "while plaintext connections force HTTP/2 prior knowledge."); } - - RSAKey dpopKey; - try { - dpopKey = new RSAKeyGenerator(2048) - .keyUse(KeyUse.SIGNATURE) - .keyID(UUID.randomUUID().toString()) - .generate(); - } catch (JOSEException e) { - throw new SDKException("Error generating DPoP key", e); + + // Use provided DPoP key or generate an ephemeral one + RSAKey effectiveDpopKey; + if (this.dpopKey != null) { + effectiveDpopKey = this.dpopKey; + } else { + try { + effectiveDpopKey = new RSAKeyGenerator(2048) + .keyUse(KeyUse.SIGNATURE) + .keyID(UUID.randomUUID().toString()) + .generate(); + } catch (JOSEException e) { + throw new SDKException("Error generating DPoP key", e); + } } this.platformEndpoint = AddressNormalizer.normalizeAddress(this.platformEndpoint, this.usePlainText); - var authInterceptor = getAuthInterceptor(dpopKey); - var srtSignerToUse = this.srtSigner == null ? new DefaultSrtSigner(dpopKey) : this.srtSigner; + var authInterceptor = getAuthInterceptor(effectiveDpopKey); + var srtSignerToUse = this.srtSigner == null ? new DefaultSrtSigner(effectiveDpopKey) : this.srtSigner; var kasClient = getKASClient(srtSignerToUse, authInterceptor); var httpClient = getHttpClient(); var client = getProtocolClient(platformEndpoint, httpClient, authInterceptor); diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/TokenSource.java b/sdk/src/main/java/io/opentdf/platform/sdk/TokenSource.java index 01089452..a633f7b3 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/TokenSource.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/TokenSource.java @@ -22,6 +22,8 @@ import java.net.URISyntaxException; import java.net.URL; import java.time.Instant; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; /** * The TokenSource class is responsible for providing authorization tokens. It handles @@ -35,6 +37,8 @@ class TokenSource { private final URI tokenEndpointURI; private final AuthorizationGrant authzGrant; private final SSLSocketFactory sslSocketFactory; + // Cache for server-issued nonces, keyed by origin (scheme://host:port) + private final Map nonceCache = new ConcurrentHashMap<>(); private static final Logger logger = LoggerFactory.getLogger(TokenSource.class); @@ -73,6 +77,18 @@ public String getDpopHeader() { } public AuthHeaders getAuthHeaders(URL url, String method) { + return getAuthHeaders(url, method, null); + } + + /** + * Get authorization headers for a request, including DPoP proof. + * + * @param url The URL being accessed + * @param method The HTTP method + * @param nonce Optional server-issued nonce to include in the proof + * @return AuthHeaders containing Authorization and DPoP headers + */ + public AuthHeaders getAuthHeaders(URL url, String method, String nonce) { // Get the access token AccessToken t = getToken(); @@ -80,7 +96,19 @@ public AuthHeaders getAuthHeaders(URL url, String method) { String dpopProof; try { DPoPProofFactory dpopFactory = new DefaultDPoPProofFactory(rsaKey, JWSAlgorithm.RS256); - SignedJWT proof = dpopFactory.createDPoPJWT(method, url.toURI(), t); + + // Get cached nonce if not explicitly provided + if (nonce == null) { + String origin = getOrigin(url); + nonce = nonceCache.get(origin); + } + + SignedJWT proof; + if (nonce != null) { + proof = dpopFactory.createDPoPJWT(method, url.toURI(), t, nonce); + } else { + proof = dpopFactory.createDPoPJWT(method, url.toURI(), t); + } dpopProof = proof.serialize(); } catch (URISyntaxException e) { throw new SDKException("Invalid URI syntax for DPoP proof creation", e); @@ -93,6 +121,34 @@ public AuthHeaders getAuthHeaders(URL url, String method) { dpopProof); } + /** + * Cache a server-issued nonce for the given URL's origin. + * + * @param url The URL from which the nonce was received + * @param nonce The nonce value to cache + */ + public void cacheNonce(URL url, String nonce) { + if (nonce != null && !nonce.isEmpty()) { + String origin = getOrigin(url); + nonceCache.put(origin, nonce); + logger.trace("Cached DPoP nonce for origin: {}", origin); + } + } + + /** + * Get the origin (scheme://host:port) from a URL for nonce caching. + * + * @param url The URL to extract origin from + * @return The origin string + */ + private String getOrigin(URL url) { + int port = url.getPort(); + if (port == -1) { + port = url.getDefaultPort(); + } + return url.getProtocol() + "://" + url.getHost() + ":" + port; + } + /** * Either fetches a new access token or returns the cached access token if it is still valid. * diff --git a/sdk/src/main/kotlin/io/opentdf/platform/sdk/AuthInterceptor.kt b/sdk/src/main/kotlin/io/opentdf/platform/sdk/AuthInterceptor.kt index a3babe2b..3b1ea6e1 100644 --- a/sdk/src/main/kotlin/io/opentdf/platform/sdk/AuthInterceptor.kt +++ b/sdk/src/main/kotlin/io/opentdf/platform/sdk/AuthInterceptor.kt @@ -47,6 +47,12 @@ private class AuthInterceptor(private val ts: TokenSource) : Interceptor{ ) }, responseFunction = { resp -> + // Check for DPoP-Nonce in successful responses and cache it + val dpopNonce = resp.headers["dpop-nonce"]?.firstOrNull() + ?: resp.headers["DPoP-Nonce"]?.firstOrNull() + if (dpopNonce != null && resp.code == 200) { + ts.cacheNonce(resp.message.request().url().toUrl(), dpopNonce) + } resp }, ) From a67b37c99ab9b5f59f7b1979b0749d42e2664e8c Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Tue, 9 Jun 2026 13:53:49 -0400 Subject: [PATCH 02/24] fix(java-sdk): fix DPoP compile errors, nonce caching, and add 401-retry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AuthInterceptor.kt: fix resp.code→resp.status and resp.message.request() compilation errors; use ThreadLocal to thread request URL into responseFunction for nonce caching; change private→internal so SDKBuilder.java can access dpopRetryInterceptor(); add dpopRetryInterceptor() OkHttp interceptor that caches DPoP-Nonce and retries 401 once - TokenSource.java: wrap nonce String as new Nonce(nonce) to match DefaultDPoPProofFactory.createDPoPJWT signature; generalize RSAKey to JWK+JWSAlgorithm to support EC keys for ES256/ES384/ES512 - SDKBuilder.java: update to JWK+JWSAlgorithm, separate SRT key from DPoP key (EC DPoP key auto-generates RSA for SRT), wire dpopRetryInterceptor into OkHttpClient for KAS and all platform services; add dpopAlgorithm() builder method - Add TokenSourceTest and DPoPRetryInterceptorTest (6 new tests) Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Dave Mihalcik --- .../io/opentdf/platform/sdk/SDKBuilder.java | 108 +++++++++--- .../io/opentdf/platform/sdk/TokenSource.java | 22 ++- .../opentdf/platform/sdk/AuthInterceptor.kt | 43 ++++- .../sdk/DPoPRetryInterceptorTest.java | 156 ++++++++++++++++++ .../opentdf/platform/sdk/TokenSourceTest.java | 119 +++++++++++++ 5 files changed, 412 insertions(+), 36 deletions(-) create mode 100644 sdk/src/test/java/io/opentdf/platform/sdk/DPoPRetryInterceptorTest.java create mode 100644 sdk/src/test/java/io/opentdf/platform/sdk/TokenSourceTest.java diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/SDKBuilder.java b/sdk/src/main/java/io/opentdf/platform/sdk/SDKBuilder.java index 39b75751..cc0e9608 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/SDKBuilder.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/SDKBuilder.java @@ -1,15 +1,19 @@ package io.opentdf.platform.sdk; import com.connectrpc.ConnectException; -import com.connectrpc.Interceptor; import com.connectrpc.ProtocolClientConfig; import com.connectrpc.extensions.GoogleJavaProtobufStrategy; import com.connectrpc.impl.ProtocolClient; import com.connectrpc.okhttp.ConnectOkHttpClient; import com.connectrpc.protocols.GETConfiguration; 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.RSAKey; +import com.nimbusds.jose.jwk.gen.ECKeyGenerator; import com.nimbusds.jose.jwk.gen.RSAKeyGenerator; import com.nimbusds.oauth2.sdk.AuthorizationGrant; import com.nimbusds.oauth2.sdk.ClientCredentialsGrant; @@ -33,6 +37,9 @@ import io.opentdf.platform.wellknownconfiguration.GetWellKnownConfigurationResponse; import io.opentdf.platform.wellknownconfiguration.WellKnownServiceClient; import io.opentdf.platform.wellknownconfiguration.WellKnownServiceClientInterface; +import nl.altindag.ssl.SSLFactory; +import nl.altindag.ssl.pem.util.PemUtils; +import nl.altindag.ssl.util.KeyStoreUtils; import okhttp3.OkHttpClient; import okhttp3.Protocol; import org.slf4j.Logger; @@ -64,7 +71,8 @@ public class SDKBuilder { private AuthorizationGrant authzGrant; private ProtocolType protocolType = ProtocolType.CONNECT; private SrtSigner srtSigner; - private RSAKey dpopKey; + private JWK dpopKey; + private JWSAlgorithm dpopAlg; private static final Logger logger = LoggerFactory.getLogger(SDKBuilder.class); @@ -195,7 +203,7 @@ public SDKBuilder useInsecurePlaintextConnection(Boolean usePlainText) { /** * Set the network protocol to use for communication with platform services. - * + * * @param protocolType the protocol type to use (CONNECT, GRPC, or GRPC_WEB) * @return this builder instance for method chaining * @throws IllegalArgumentException if protocolType is null @@ -215,18 +223,31 @@ public SDKBuilder srtSigner(SrtSigner signer) { } /** - * Configure a custom RSA key for DPoP (RFC 9449) proof generation. + * Configure a custom JWK (RSA or EC) for DPoP (RFC 9449) proof generation. * If not provided, the SDK will auto-generate an ephemeral RSA-2048 key. + * RSA keys also serve as the SRT signing key; EC keys use a separate auto-generated RSA key for SRT. * - * @param dpopKey RSA key to use for DPoP proofs + * @param dpopKey JWK (RSA or EC) to use for DPoP proofs * @return this builder instance for method chaining */ - public SDKBuilder dpopKey(RSAKey dpopKey) { + public SDKBuilder dpopKey(JWK dpopKey) { this.dpopKey = dpopKey; return this; } - private Interceptor getAuthInterceptor(RSAKey rsaKey) { + /** + * Set the JWS algorithm to use for DPoP proofs. If omitted, defaults to RS256 for RSA keys + * or the curve-appropriate algorithm for EC keys. + * + * @param dpopAlg JWS algorithm (e.g. RS256, ES256) + * @return this builder instance for method chaining + */ + public SDKBuilder dpopAlgorithm(JWSAlgorithm dpopAlg) { + this.dpopAlg = dpopAlg; + return this; + } + + private AuthInterceptor getAuthInterceptor(JWK dpopJwk, JWSAlgorithm dpopAlgorithm) { if (platformEndpoint == null) { throw new SDKException("cannot build an SDK without specifying the platform endpoint"); } @@ -277,7 +298,7 @@ private Interceptor getAuthInterceptor(RSAKey rsaKey) { if (this.authzGrant == null) { this.authzGrant = new ClientCredentialsGrant(); } - var ts = new TokenSource(clientAuth, rsaKey, providerMetadata.getTokenEndpointURI(), this.authzGrant, sslSocketFactory); + var ts = new TokenSource(clientAuth, dpopJwk, dpopAlgorithm, providerMetadata.getTokenEndpointURI(), this.authzGrant, sslSocketFactory); return new AuthInterceptor(ts); } @@ -295,14 +316,14 @@ public SDKBuilder insecureSslFactory() { } static class ServicesAndInternals { - final Interceptor interceptor; + final AuthInterceptor interceptor; final TrustManager trustManager; final ProtocolClient protocolClient; final SrtSigner srtSigner; final SDK.Services services; - ServicesAndInternals(Interceptor interceptor, TrustManager trustManager, SDK.Services services, ProtocolClient protocolClient, SrtSigner srtSigner) { + ServicesAndInternals(AuthInterceptor interceptor, TrustManager trustManager, SDK.Services services, ProtocolClient protocolClient, SrtSigner srtSigner) { this.interceptor = interceptor; this.trustManager = trustManager; this.services = services; @@ -319,26 +340,54 @@ ServicesAndInternals buildServices() { "while plaintext connections force HTTP/2 prior knowledge."); } - // Use provided DPoP key or generate an ephemeral one - RSAKey effectiveDpopKey; + // Resolve the DPoP JWK and algorithm + JWK effectiveDpopJwk; + JWSAlgorithm effectiveDpopAlg; + RSAKey srtKey; // SRT signing always uses RSA + if (this.dpopKey != null) { - effectiveDpopKey = this.dpopKey; + effectiveDpopJwk = this.dpopKey; + if (this.dpopAlg != null) { + effectiveDpopAlg = this.dpopAlg; + } else if (effectiveDpopJwk instanceof ECKey) { + effectiveDpopAlg = inferEcAlgorithm((ECKey) effectiveDpopJwk); + } else { + effectiveDpopAlg = JWSAlgorithm.RS256; + } + if (effectiveDpopJwk instanceof RSAKey) { + srtKey = (RSAKey) effectiveDpopJwk; + } else { + // EC DPoP key: generate a separate RSA key for SRT signing + try { + srtKey = new RSAKeyGenerator(2048) + .keyUse(KeyUse.SIGNATURE) + .keyID(UUID.randomUUID().toString()) + .generate(); + } catch (JOSEException e) { + throw new SDKException("Error generating SRT RSA key", e); + } + } } else { + // Auto-generate RSA-2048 for both DPoP and SRT try { - effectiveDpopKey = new RSAKeyGenerator(2048) + srtKey = new RSAKeyGenerator(2048) .keyUse(KeyUse.SIGNATURE) .keyID(UUID.randomUUID().toString()) .generate(); } catch (JOSEException e) { throw new SDKException("Error generating DPoP key", e); } + effectiveDpopJwk = srtKey; + effectiveDpopAlg = this.dpopAlg != null ? this.dpopAlg : JWSAlgorithm.RS256; } this.platformEndpoint = AddressNormalizer.normalizeAddress(this.platformEndpoint, this.usePlainText); - var authInterceptor = getAuthInterceptor(effectiveDpopKey); - var srtSignerToUse = this.srtSigner == null ? new DefaultSrtSigner(effectiveDpopKey) : this.srtSigner; - var kasClient = getKASClient(srtSignerToUse, authInterceptor); - var httpClient = getHttpClient(); + var authInterceptor = getAuthInterceptor(effectiveDpopJwk, effectiveDpopAlg); + var srtSignerToUse = this.srtSigner == null ? new DefaultSrtSigner(srtKey) : this.srtSigner; + + okhttp3.Interceptor dpopRetry = authInterceptor != null ? authInterceptor.dpopRetryInterceptor() : null; + var kasClient = getKASClient(srtSignerToUse, authInterceptor, dpopRetry); + var httpClient = getHttpClient(dpopRetry); var client = getProtocolClient(platformEndpoint, httpClient, authInterceptor); var attributeService = new AttributesServiceClient(client); var namespaceService = new NamespaceServiceClient(client); @@ -412,9 +461,9 @@ public SDK.KAS kas() { } @Nonnull - private KASClient getKASClient(SrtSigner srtSigner, Interceptor interceptor) { + private KASClient getKASClient(SrtSigner srtSigner, AuthInterceptor interceptor, okhttp3.Interceptor dpopRetry) { BiFunction protocolClientFactory = (OkHttpClient client, String address) -> getProtocolClient(address, client, interceptor); - return new KASClient(getHttpClient(), protocolClientFactory, srtSigner, usePlainText); + return new KASClient(getHttpClient(dpopRetry), protocolClientFactory, srtSigner, usePlainText); } public SDK build() { @@ -426,7 +475,7 @@ private ProtocolClient getUnauthenticatedProtocolClient(String endpoint, OkHttpC return getProtocolClient(endpoint, httpClient, null); } - private ProtocolClient getProtocolClient(String endpoint, OkHttpClient httpClient, Interceptor authInterceptor) { + private ProtocolClient getProtocolClient(String endpoint, OkHttpClient httpClient, AuthInterceptor authInterceptor) { var protocolClientConfig = new ProtocolClientConfig( endpoint, new GoogleJavaProtobufStrategy(), @@ -441,9 +490,14 @@ private ProtocolClient getProtocolClient(String endpoint, OkHttpClient httpClien @SuppressWarnings("deprecation") private OkHttpClient getHttpClient() { - // using a single http client is apparently the best practice, subject to everyone wanting to - // have the same protocols + return getHttpClient((okhttp3.Interceptor) null); + } + + private OkHttpClient getHttpClient(okhttp3.Interceptor additionalInterceptor) { var httpClient = new OkHttpClient.Builder(); + if (additionalInterceptor != null) { + httpClient.addInterceptor(additionalInterceptor); + } if (usePlainText) { // For plaintext connections, we need HTTP/2 prior knowledge because gRPC servers // expect HTTP/2, and Connect protocol can communicate with gRPC servers over HTTP/2 @@ -469,4 +523,12 @@ SSLSocketFactory getSslFactory() { X509TrustManager getTrustManager() { return this.trustManager; } + + private static JWSAlgorithm inferEcAlgorithm(ECKey ecKey) { + Curve curve = ecKey.getCurve(); + if (Curve.P_256.equals(curve)) return JWSAlgorithm.ES256; + if (Curve.P_384.equals(curve)) return JWSAlgorithm.ES384; + if (Curve.P_521.equals(curve)) return JWSAlgorithm.ES512; + throw new SDKException("Unsupported EC curve for DPoP: " + curve); + } } diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/TokenSource.java b/sdk/src/main/java/io/opentdf/platform/sdk/TokenSource.java index a633f7b3..3242288d 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/TokenSource.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/TokenSource.java @@ -2,6 +2,7 @@ import com.nimbusds.jose.JOSEException; import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.jwk.JWK; import com.nimbusds.jose.jwk.RSAKey; import com.nimbusds.jwt.SignedJWT; import com.nimbusds.oauth2.sdk.AuthorizationGrant; @@ -14,6 +15,8 @@ import com.nimbusds.oauth2.sdk.http.HTTPRequest; import com.nimbusds.oauth2.sdk.http.HTTPResponse; import com.nimbusds.oauth2.sdk.token.AccessToken; +import com.nimbusds.openid.connect.sdk.Nonce; +import nl.altindag.ssl.SSLFactory; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -33,7 +36,8 @@ class TokenSource { private Instant tokenExpiryTime; private AccessToken token; private final ClientAuthentication clientAuth; - private final RSAKey rsaKey; + private final JWK dpopJwk; + private final JWSAlgorithm dpopAlg; private final URI tokenEndpointURI; private final AuthorizationGrant authzGrant; private final SSLSocketFactory sslSocketFactory; @@ -43,15 +47,17 @@ class TokenSource { /** - * Constructs a new TokenSource with the specified client authentication and RSA key. + * Constructs a new TokenSource with the specified client authentication and DPoP key. * * @param clientAuth the client authentication to be used by the interceptor - * @param rsaKey the RSA key to be used by the interceptor + * @param dpopJwk the JWK (RSA or EC) to use for DPoP proof generation + * @param dpopAlg the JWS algorithm matching the key type * @param sslSocketFactory Optional SSLSocketFactory for token endpoint requests */ - public TokenSource(ClientAuthentication clientAuth, RSAKey rsaKey, URI tokenEndpointURI, AuthorizationGrant authzGrant, SSLSocketFactory sslSocketFactory) { + public TokenSource(ClientAuthentication clientAuth, JWK dpopJwk, JWSAlgorithm dpopAlg, URI tokenEndpointURI, AuthorizationGrant authzGrant, SSLSocketFactory sslSocketFactory) { this.clientAuth = clientAuth; - this.rsaKey = rsaKey; + this.dpopJwk = dpopJwk; + this.dpopAlg = dpopAlg; this.tokenEndpointURI = tokenEndpointURI; this.sslSocketFactory = sslSocketFactory; this.authzGrant = authzGrant; @@ -95,7 +101,7 @@ public AuthHeaders getAuthHeaders(URL url, String method, String nonce) { // Build the DPoP proof for each request String dpopProof; try { - DPoPProofFactory dpopFactory = new DefaultDPoPProofFactory(rsaKey, JWSAlgorithm.RS256); + DPoPProofFactory dpopFactory = new DefaultDPoPProofFactory(dpopJwk, dpopAlg); // Get cached nonce if not explicitly provided if (nonce == null) { @@ -105,7 +111,7 @@ public AuthHeaders getAuthHeaders(URL url, String method, String nonce) { SignedJWT proof; if (nonce != null) { - proof = dpopFactory.createDPoPJWT(method, url.toURI(), t, nonce); + proof = dpopFactory.createDPoPJWT(method, url.toURI(), t, new Nonce(nonce)); } else { proof = dpopFactory.createDPoPJWT(method, url.toURI(), t); } @@ -169,7 +175,7 @@ private synchronized AccessToken getToken() { httpRequest.setSSLSocketFactory(sslSocketFactory); } - DPoPProofFactory dpopFactory = new DefaultDPoPProofFactory(rsaKey, JWSAlgorithm.RS256); + DPoPProofFactory dpopFactory = new DefaultDPoPProofFactory(dpopJwk, dpopAlg); SignedJWT proof = dpopFactory.createDPoPJWT(httpRequest.getMethod().name(), httpRequest.getURI()); diff --git a/sdk/src/main/kotlin/io/opentdf/platform/sdk/AuthInterceptor.kt b/sdk/src/main/kotlin/io/opentdf/platform/sdk/AuthInterceptor.kt index 3b1ea6e1..99a4a2b6 100644 --- a/sdk/src/main/kotlin/io/opentdf/platform/sdk/AuthInterceptor.kt +++ b/sdk/src/main/kotlin/io/opentdf/platform/sdk/AuthInterceptor.kt @@ -5,8 +5,12 @@ import com.connectrpc.StreamFunction import com.connectrpc.UnaryFunction import com.connectrpc.http.UnaryHTTPRequest import com.connectrpc.http.clone +import java.net.URL + +internal class AuthInterceptor(private val ts: TokenSource) : Interceptor { + // Thread request URL from requestFunction into responseFunction for nonce caching + private val requestUrl = ThreadLocal() -private class AuthInterceptor(private val ts: TokenSource) : Interceptor{ override fun streamFunction(): StreamFunction { return StreamFunction( requestFunction = { request -> @@ -31,6 +35,8 @@ private class AuthInterceptor(private val ts: TokenSource) : Interceptor{ override fun unaryFunction(): UnaryFunction { return UnaryFunction( requestFunction = { request -> + requestUrl.set(request.url) + val requestHeaders = mutableMapOf>() val authHeaders = ts.getAuthHeaders(request.url, request.httpMethod.name) requestHeaders["Authorization"] = listOf(authHeaders.authHeader) @@ -47,14 +53,41 @@ private class AuthInterceptor(private val ts: TokenSource) : Interceptor{ ) }, responseFunction = { resp -> - // Check for DPoP-Nonce in successful responses and cache it + val url = requestUrl.get() + requestUrl.remove() + + // Cache any server-issued DPoP nonce for future requests to the same origin val dpopNonce = resp.headers["dpop-nonce"]?.firstOrNull() ?: resp.headers["DPoP-Nonce"]?.firstOrNull() - if (dpopNonce != null && resp.code == 200) { - ts.cacheNonce(resp.message.request().url().toUrl(), dpopNonce) + if (dpopNonce != null && url != null) { + ts.cacheNonce(url, dpopNonce) } resp }, ) } -} \ No newline at end of file + + /** + * Returns an OkHttp interceptor that retries on 401 responses carrying a DPoP-Nonce header. + * The nonce is cached in the TokenSource and used in a fresh proof for the single retry. + */ + fun dpopRetryInterceptor(): okhttp3.Interceptor = okhttp3.Interceptor { chain -> + val response = chain.proceed(chain.request()) + if (response.code == 401) { + val dpopNonce = response.header("dpop-nonce") ?: response.header("DPoP-Nonce") + if (dpopNonce != null) { + response.close() + val url = chain.request().url.toUrl() + val method = chain.request().method + ts.cacheNonce(url, dpopNonce) + val authHeaders = ts.getAuthHeaders(url, method) + val newRequest = chain.request().newBuilder() + .header("Authorization", authHeaders.authHeader) + .header("DPoP", authHeaders.dpopHeader) + .build() + return@Interceptor chain.proceed(newRequest) + } + } + response + } +} diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/DPoPRetryInterceptorTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/DPoPRetryInterceptorTest.java new file mode 100644 index 00000000..c4bc486f --- /dev/null +++ b/sdk/src/test/java/io/opentdf/platform/sdk/DPoPRetryInterceptorTest.java @@ -0,0 +1,156 @@ +package io.opentdf.platform.sdk; + +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.jwk.KeyUse; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.jwk.gen.RSAKeyGenerator; +import com.nimbusds.jwt.SignedJWT; +import com.nimbusds.oauth2.sdk.ClientCredentialsGrant; +import com.nimbusds.oauth2.sdk.auth.ClientSecretBasic; +import com.nimbusds.oauth2.sdk.auth.Secret; +import com.nimbusds.oauth2.sdk.id.ClientID; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.jupiter.api.Test; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +class DPoPRetryInterceptorTest { + + private static final String FAKE_TOKEN_RESPONSE = + "{\"access_token\":\"test-token\",\"token_type\":\"Bearer\",\"expires_in\":3600}"; + + private AuthInterceptor buildAuthInterceptor(MockWebServer tokenServer, RSAKey rsaKey) throws Exception { + TokenSource ts = new TokenSource( + new ClientSecretBasic(new ClientID("test-client"), new Secret("test-secret")), + rsaKey, + JWSAlgorithm.RS256, + tokenServer.url("/token").uri(), + new ClientCredentialsGrant(), + null + ); + return new AuthInterceptor(ts); + } + + @Test + void retryOn401WithDPoPNonce() throws Exception { + RSAKey rsaKey = new RSAKeyGenerator(2048) + .keyUse(KeyUse.SIGNATURE) + .keyID(UUID.randomUUID().toString()) + .generate(); + try (MockWebServer tokenServer = new MockWebServer(); + MockWebServer kasServer = new MockWebServer()) { + // Queue multiple token responses (one for each getAuthHeaders call during retry) + for (int i = 0; i < 5; i++) { + tokenServer.enqueue(new MockResponse() + .setBody(FAKE_TOKEN_RESPONSE) + .setHeader("Content-Type", "application/json")); + } + tokenServer.start(); + + // First request returns 401 + DPoP-Nonce; second returns 200 + kasServer.enqueue(new MockResponse() + .setResponseCode(401) + .addHeader("DPoP-Nonce", "server-issued-nonce")); + kasServer.enqueue(new MockResponse().setResponseCode(200)); + kasServer.start(); + + AuthInterceptor authInterceptor = buildAuthInterceptor(tokenServer, rsaKey); + OkHttpClient client = new OkHttpClient.Builder() + .addInterceptor(authInterceptor.dpopRetryInterceptor()) + .build(); + + Request request = new Request.Builder() + .url(kasServer.url("/kas/rewrap")) + .post(okhttp3.RequestBody.create(new byte[0])) + .build(); + Response response = client.newCall(request).execute(); + response.close(); + + assertThat(kasServer.getRequestCount()).isEqualTo(2); + assertThat(response.code()).isEqualTo(200); + + // Verify second request carries a DPoP proof with the nonce + kasServer.takeRequest(); // consume first request + RecordedRequest retryRequest = kasServer.takeRequest(); + String dpopHeader = retryRequest.getHeader("DPoP"); + assertThat(dpopHeader).isNotNull(); + + SignedJWT dpopJwt = SignedJWT.parse(dpopHeader); + String nonceClaim = dpopJwt.getJWTClaimsSet().getStringClaim("nonce"); + assertThat(nonceClaim).isEqualTo("server-issued-nonce"); + } + } + + @Test + void noRetryOn401WithoutDPoPNonce() throws Exception { + RSAKey rsaKey = new RSAKeyGenerator(2048) + .keyUse(KeyUse.SIGNATURE) + .keyID(UUID.randomUUID().toString()) + .generate(); + try (MockWebServer tokenServer = new MockWebServer(); + MockWebServer kasServer = new MockWebServer()) { + tokenServer.enqueue(new MockResponse() + .setBody(FAKE_TOKEN_RESPONSE) + .setHeader("Content-Type", "application/json")); + tokenServer.start(); + + kasServer.enqueue(new MockResponse().setResponseCode(401)); + kasServer.start(); + + AuthInterceptor authInterceptor = buildAuthInterceptor(tokenServer, rsaKey); + OkHttpClient client = new OkHttpClient.Builder() + .addInterceptor(authInterceptor.dpopRetryInterceptor()) + .build(); + + Request request = new Request.Builder() + .url(kasServer.url("/kas/rewrap")) + .post(okhttp3.RequestBody.create(new byte[0])) + .build(); + Response response = client.newCall(request).execute(); + response.close(); + + assertThat(kasServer.getRequestCount()).isEqualTo(1); + assertThat(response.code()).isEqualTo(401); + } + } + + @Test + void noRetryOnSuccessResponse() throws Exception { + RSAKey rsaKey = new RSAKeyGenerator(2048) + .keyUse(KeyUse.SIGNATURE) + .keyID(UUID.randomUUID().toString()) + .generate(); + try (MockWebServer tokenServer = new MockWebServer(); + MockWebServer kasServer = new MockWebServer()) { + tokenServer.enqueue(new MockResponse() + .setBody(FAKE_TOKEN_RESPONSE) + .setHeader("Content-Type", "application/json")); + tokenServer.start(); + + kasServer.enqueue(new MockResponse().setResponseCode(200)); + kasServer.start(); + + AuthInterceptor authInterceptor = buildAuthInterceptor(tokenServer, rsaKey); + OkHttpClient client = new OkHttpClient.Builder() + .addInterceptor(authInterceptor.dpopRetryInterceptor()) + .build(); + + Request request = new Request.Builder() + .url(kasServer.url("/kas/rewrap")) + .post(okhttp3.RequestBody.create(new byte[0])) + .build(); + Response response = client.newCall(request).execute(); + response.close(); + + assertThat(kasServer.getRequestCount()).isEqualTo(1); + assertThat(response.code()).isEqualTo(200); + } + } +} diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/TokenSourceTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/TokenSourceTest.java new file mode 100644 index 00000000..544df01f --- /dev/null +++ b/sdk/src/test/java/io/opentdf/platform/sdk/TokenSourceTest.java @@ -0,0 +1,119 @@ +package io.opentdf.platform.sdk; + +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.jwk.KeyUse; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.jwk.gen.RSAKeyGenerator; +import com.nimbusds.jwt.SignedJWT; +import com.nimbusds.oauth2.sdk.ClientCredentialsGrant; +import com.nimbusds.oauth2.sdk.auth.ClientSecretBasic; +import com.nimbusds.oauth2.sdk.auth.Secret; +import com.nimbusds.oauth2.sdk.id.ClientID; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import org.junit.jupiter.api.Test; + +import java.net.URL; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +class TokenSourceTest { + + private static final String FAKE_TOKEN_RESPONSE = + "{\"access_token\":\"test-access-token\",\"token_type\":\"Bearer\",\"expires_in\":3600}"; + + private TokenSource buildTokenSource(MockWebServer tokenServer, RSAKey rsaKey) throws Exception { + return new TokenSource( + new ClientSecretBasic(new ClientID("test-client"), new Secret("test-secret")), + rsaKey, + JWSAlgorithm.RS256, + tokenServer.url("/token").uri(), + new ClientCredentialsGrant(), + null + ); + } + + @Test + void cachedNonceIsIncludedInNextDPoPProof() throws Exception { + RSAKey rsaKey = new RSAKeyGenerator(2048) + .keyUse(KeyUse.SIGNATURE) + .keyID(UUID.randomUUID().toString()) + .generate(); + try (MockWebServer tokenServer = new MockWebServer()) { + // Token endpoint queues two responses: one for initial fetch, one in case of re-fetch + tokenServer.enqueue(new MockResponse() + .setBody(FAKE_TOKEN_RESPONSE) + .setHeader("Content-Type", "application/json")); + tokenServer.enqueue(new MockResponse() + .setBody(FAKE_TOKEN_RESPONSE) + .setHeader("Content-Type", "application/json")); + tokenServer.start(); + + TokenSource ts = buildTokenSource(tokenServer, rsaKey); + URL testUrl = new URL("https://kas.example.com/kas"); + + ts.cacheNonce(testUrl, "server-nonce-abc"); + + TokenSource.AuthHeaders headers = ts.getAuthHeaders(testUrl, "POST"); + SignedJWT dpopJwt = SignedJWT.parse(headers.getDpopHeader()); + String nonceClaim = dpopJwt.getJWTClaimsSet().getStringClaim("nonce"); + + assertThat(nonceClaim).isEqualTo("server-nonce-abc"); + } + } + + @Test + void explicitNonceOverridesCachedNonce() throws Exception { + RSAKey rsaKey = new RSAKeyGenerator(2048) + .keyUse(KeyUse.SIGNATURE) + .keyID(UUID.randomUUID().toString()) + .generate(); + try (MockWebServer tokenServer = new MockWebServer()) { + tokenServer.enqueue(new MockResponse() + .setBody(FAKE_TOKEN_RESPONSE) + .setHeader("Content-Type", "application/json")); + tokenServer.enqueue(new MockResponse() + .setBody(FAKE_TOKEN_RESPONSE) + .setHeader("Content-Type", "application/json")); + tokenServer.start(); + + TokenSource ts = buildTokenSource(tokenServer, rsaKey); + URL testUrl = new URL("https://kas.example.com/kas"); + + ts.cacheNonce(testUrl, "cached-nonce"); + + TokenSource.AuthHeaders headers = ts.getAuthHeaders(testUrl, "POST", "explicit-nonce"); + SignedJWT dpopJwt = SignedJWT.parse(headers.getDpopHeader()); + String nonceClaim = dpopJwt.getJWTClaimsSet().getStringClaim("nonce"); + + assertThat(nonceClaim).isEqualTo("explicit-nonce"); + } + } + + @Test + void noNonceClaimWhenNoCachedNonce() throws Exception { + RSAKey rsaKey = new RSAKeyGenerator(2048) + .keyUse(KeyUse.SIGNATURE) + .keyID(UUID.randomUUID().toString()) + .generate(); + try (MockWebServer tokenServer = new MockWebServer()) { + tokenServer.enqueue(new MockResponse() + .setBody(FAKE_TOKEN_RESPONSE) + .setHeader("Content-Type", "application/json")); + tokenServer.enqueue(new MockResponse() + .setBody(FAKE_TOKEN_RESPONSE) + .setHeader("Content-Type", "application/json")); + tokenServer.start(); + + TokenSource ts = buildTokenSource(tokenServer, rsaKey); + URL testUrl = new URL("https://kas.example.com/kas"); + + TokenSource.AuthHeaders headers = ts.getAuthHeaders(testUrl, "POST"); + SignedJWT dpopJwt = SignedJWT.parse(headers.getDpopHeader()); + String nonceClaim = dpopJwt.getJWTClaimsSet().getStringClaim("nonce"); + + assertThat(nonceClaim).isNull(); + } + } +} From e6a527e6539e3663be20b915b8e34db081c36787 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Tue, 9 Jun 2026 13:53:57 -0400 Subject: [PATCH 03/24] feat(java-sdk): add --dpop and --dpop-key CLI flags to encrypt/decrypt Add DPoP configuration flags to the tdf cmdline tool (shared by encrypt and decrypt via buildSDK()): - --dpop[=]: enable DPoP; optional algorithm (RS256, RS384, RS512, ES256, ES384, ES512); defaults to RS256; generates ephemeral key - --dpop-key : use PEM-encoded private key from file; algorithm inferred from key type (EC or RSA); combinable with --dpop= Both flags work for encrypt and decrypt subcommands. Help text contains "dpop" so the grep probe matches: encrypt --help | grep -i dpop. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Dave Mihalcik --- .../java/io/opentdf/platform/Command.java | 81 ++++++++++++++++++- 1 file changed, 80 insertions(+), 1 deletion(-) diff --git a/cmdline/src/main/java/io/opentdf/platform/Command.java b/cmdline/src/main/java/io/opentdf/platform/Command.java index e077cb84..1172f25f 100644 --- a/cmdline/src/main/java/io/opentdf/platform/Command.java +++ b/cmdline/src/main/java/io/opentdf/platform/Command.java @@ -6,7 +6,13 @@ import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonParseException; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.jwk.Curve; import com.nimbusds.jose.jwk.JWK; +import com.nimbusds.jose.jwk.KeyUse; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.jwk.gen.ECKeyGenerator; +import com.nimbusds.jose.jwk.gen.RSAKeyGenerator; import com.google.gson.GsonBuilder; import com.google.gson.reflect.TypeToken; @@ -46,6 +52,7 @@ import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.UUID; import java.util.function.Consumer; /** @@ -99,7 +106,7 @@ 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); } @@ -138,6 +145,14 @@ private Gson buildGson() { @Option(names = { "-p", "--platform-endpoint" }, required = true) private String platformEndpoint; + @Option(names = { "--dpop" }, arity = "0..1", fallbackValue = "", + description = "Enable DPoP (RFC 9449). Optional: specify algorithm (RS256, RS384, RS512, ES256, ES384, ES512). Default: RS256.") + private String dpopAlg; + + @Option(names = { "--dpop-key" }, + description = "Enable DPoP using a PEM-encoded private key at . Algorithm inferred from key type. Combinable with --dpop=.") + private Path dpopKeyPath; + private Object correctKeyType(AssertionConfig.AssertionKeyAlg alg, Object key, boolean publicKey) throws RuntimeException { if (alg == AssertionConfig.AssertionKeyAlg.HS256) { @@ -284,11 +299,75 @@ private SDK buildSDK() { builder.insecureSslFactory(); } + applyDPoPOptions(builder); + return builder.platformEndpoint(platformEndpoint) .clientSecret(clientId, clientSecret).useInsecurePlaintextConnection(plaintext) .build(); } + /** + * Apply --dpop and --dpop-key options to the SDK builder. + * --dpop-key loads a PEM private key; --dpop specifies the algorithm (default RS256). + * If neither flag is set, the SDK auto-generates an ephemeral RSA-2048 DPoP key. + */ + private void applyDPoPOptions(SDKBuilder builder) { + try { + if (dpopKeyPath != null) { + String pem = Files.readString(dpopKeyPath); + JWK jwk = JWK.parseFromPEMEncodedObjects(pem); + builder.dpopKey(jwk); + if (dpopAlg != null && !dpopAlg.isEmpty()) { + builder.dpopAlgorithm(parseAlgorithm(dpopAlg)); + } + } else if (dpopAlg != null) { + JWSAlgorithm alg = dpopAlg.isEmpty() ? JWSAlgorithm.RS256 : parseAlgorithm(dpopAlg); + JWK jwk = generateKeyForAlgorithm(alg); + builder.dpopKey(jwk).dpopAlgorithm(alg); + } + } catch (Exception e) { + throw new RuntimeException("Failed to configure DPoP: " + e.getMessage(), e); + } + } + + private 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 RuntimeException("Unsupported DPoP algorithm: " + alg + + ". Supported: RS256, RS384, RS512, ES256, ES384, ES512"); + } + } + + private static JWK generateKeyForAlgorithm(JWSAlgorithm alg) throws Exception { + 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(); + } else if (JWSAlgorithm.ES256.equals(alg)) { + return new ECKeyGenerator(Curve.P_256) + .keyUse(KeyUse.SIGNATURE) + .keyID(UUID.randomUUID().toString()) + .generate(); + } else if (JWSAlgorithm.ES384.equals(alg)) { + return new ECKeyGenerator(Curve.P_384) + .keyUse(KeyUse.SIGNATURE) + .keyID(UUID.randomUUID().toString()) + .generate(); + } else if (JWSAlgorithm.ES512.equals(alg)) { + return new ECKeyGenerator(Curve.P_521) + .keyUse(KeyUse.SIGNATURE) + .keyID(UUID.randomUUID().toString()) + .generate(); + } + throw new RuntimeException("Cannot generate key for algorithm: " + alg); + } + @CommandLine.Command(name = "decrypt") void decrypt( @Option(names = { "-f", "--file" }, required = true) Path tdfPath, From ea1f55daf4a46676f938ce4aeba2953a9147a643 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Tue, 9 Jun 2026 16:54:43 -0400 Subject: [PATCH 04/24] fix(java-sdk): propagate --dpop flags to subcommand help via ScopeType.INHERIT Add scope = CommandLine.ScopeType.INHERIT to --dpop and --dpop-key so they appear in `help encrypt` and `help decrypt` (not just the parent `tdf` help), allowing the tests-repo cli.sh probe to pass. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Dave Mihalcik --- cmdline/src/main/java/io/opentdf/platform/Command.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cmdline/src/main/java/io/opentdf/platform/Command.java b/cmdline/src/main/java/io/opentdf/platform/Command.java index 1172f25f..ab35e93c 100644 --- a/cmdline/src/main/java/io/opentdf/platform/Command.java +++ b/cmdline/src/main/java/io/opentdf/platform/Command.java @@ -146,10 +146,12 @@ private Gson buildGson() { 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 . Algorithm inferred from key type. Combinable with --dpop=.") private Path dpopKeyPath; From 48905789c35505c4712e5185b9187f5c11df092c Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Wed, 10 Jun 2026 08:39:49 -0400 Subject: [PATCH 05/24] fix(cmdline): replace System.exit in Supports with Callable Gemini review: System.exit() in Supports.run() abruptly terminates the JVM and prevents unit testing. Switch to Callable so picocli handles the exit code via CommandLine.execute(). Also remove `required = true` from --client-id, --client-secret, and --platform-endpoint so `supports dpop` can run without auth credentials (it performs a local capability check, not a platform call). Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Dave Mihalcik --- .../java/io/opentdf/platform/Command.java | 108 +++++++++--------- 1 file changed, 55 insertions(+), 53 deletions(-) diff --git a/cmdline/src/main/java/io/opentdf/platform/Command.java b/cmdline/src/main/java/io/opentdf/platform/Command.java index ab35e93c..5b5ec869 100644 --- a/cmdline/src/main/java/io/opentdf/platform/Command.java +++ b/cmdline/src/main/java/io/opentdf/platform/Command.java @@ -1,35 +1,20 @@ 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.google.gson.JsonSyntaxException; +import com.google.gson.reflect.TypeToken; import com.nimbusds.jose.JWSAlgorithm; import com.nimbusds.jose.jwk.Curve; import com.nimbusds.jose.jwk.JWK; import com.nimbusds.jose.jwk.KeyUse; -import com.nimbusds.jose.jwk.RSAKey; import com.nimbusds.jose.jwk.gen.ECKeyGenerator; import com.nimbusds.jose.jwk.gen.RSAKeyGenerator; -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 java.io.BufferedInputStream; import java.io.BufferedOutputStream; import java.io.File; @@ -47,13 +32,24 @@ 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.UUID; +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 picocli.CommandLine; +import picocli.CommandLine.HelpCommand; +import picocli.CommandLine.Option; /** * Constants for the TDF command line tool. @@ -67,36 +63,32 @@ class Versions { public static final String TDF_SPEC = "4.3.0"; } -@CommandLine.Command(name = "tdf", subcommands = { HelpCommand.class, Command.Supports.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; @CommandLine.Command(name = "supports", description = "Check if a feature is supported") - static class Supports implements Runnable { + static class Supports implements Callable { @CommandLine.Parameters(index = "0", description = "Feature to check (e.g., dpop)") private String feature; @Override - public void run() { - // Check if the requested feature is supported - if ("dpop".equalsIgnoreCase(feature)) { - // DPoP (RFC 9449) is supported - System.exit(0); - } else { - // Unknown or unsupported feature - System.exit(1); - } + public Integer call() { + return "dpop".equalsIgnoreCase(feature) ? 0 : 1; } } private static class AssertionKeyDeserializer implements JsonDeserializer { @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); @@ -112,7 +104,9 @@ public AssertionConfig.AssertionKey deserialize(JsonElement json, java.lang.refl } } if (jsonObject.has("x5c")) { - assertionKey.x5c = context.deserialize(jsonObject.get("x5c"), new TypeToken>() {}.getType()); + assertionKey.x5c = context.deserialize(jsonObject.get("x5c"), + new TypeToken>() { + }.getType()); } return assertionKey; @@ -130,7 +124,7 @@ 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 = { "--client-secret" }) private String clientSecret; @Option(names = { "-h", "--plaintext" }, defaultValue = "false") @@ -139,20 +133,18 @@ 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.") + @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 . Algorithm inferred from key type. Combinable with --dpop=.") + @Option(names = { + "--dpop-key" }, scope = CommandLine.ScopeType.INHERIT, description = "Enable DPoP using a PEM-encoded private key at . Algorithm inferred from key type. Combinable with --dpop=.") private Path dpopKeyPath; private Object correctKeyType(AssertionConfig.AssertionKeyAlg alg, Object key, boolean publicKey) @@ -310,8 +302,10 @@ private SDK buildSDK() { /** * Apply --dpop and --dpop-key options to the SDK builder. - * --dpop-key loads a PEM private key; --dpop specifies the algorithm (default RS256). - * If neither flag is set, the SDK auto-generates an ephemeral RSA-2048 DPoP key. + * --dpop-key loads a PEM private key; --dpop specifies the algorithm (default + * RS256). + * If neither flag is set, the SDK auto-generates an ephemeral RSA-2048 DPoP + * key. */ private void applyDPoPOptions(SDKBuilder builder) { try { @@ -334,14 +328,21 @@ private void applyDPoPOptions(SDKBuilder builder) { private 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 RuntimeException("Unsupported DPoP algorithm: " + alg - + ". Supported: RS256, RS384, RS512, ES256, ES384, ES512"); + 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 RuntimeException("Unsupported DPoP algorithm: " + alg + + ". Supported: RS256, RS384, RS512, ES256, ES384, ES512"); } } @@ -399,7 +400,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) { From 244f4baa5dbd013af1b429d3ec0422171db61262 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Thu, 11 Jun 2026 15:10:52 -0400 Subject: [PATCH 06/24] test(java-sdk): add TokenSource unit tests and remove unused altindag imports Adds EC key, origin-isolated nonce, and empty-nonce guard tests to TokenSourceTest; removes three nl.altindag.ssl imports from SDKBuilder that were added without a corresponding pom.xml dependency. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Dave Mihalcik --- .../io/opentdf/platform/sdk/SDKBuilder.java | 3 - .../io/opentdf/platform/sdk/TokenSource.java | 1 - .../opentdf/platform/sdk/TokenSourceTest.java | 94 +++++++++++++++++++ 3 files changed, 94 insertions(+), 4 deletions(-) diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/SDKBuilder.java b/sdk/src/main/java/io/opentdf/platform/sdk/SDKBuilder.java index cc0e9608..fccc82f8 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/SDKBuilder.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/SDKBuilder.java @@ -37,9 +37,6 @@ import io.opentdf.platform.wellknownconfiguration.GetWellKnownConfigurationResponse; import io.opentdf.platform.wellknownconfiguration.WellKnownServiceClient; import io.opentdf.platform.wellknownconfiguration.WellKnownServiceClientInterface; -import nl.altindag.ssl.SSLFactory; -import nl.altindag.ssl.pem.util.PemUtils; -import nl.altindag.ssl.util.KeyStoreUtils; import okhttp3.OkHttpClient; import okhttp3.Protocol; import org.slf4j.Logger; diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/TokenSource.java b/sdk/src/main/java/io/opentdf/platform/sdk/TokenSource.java index 3242288d..65550486 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/TokenSource.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/TokenSource.java @@ -16,7 +16,6 @@ import com.nimbusds.oauth2.sdk.http.HTTPResponse; import com.nimbusds.oauth2.sdk.token.AccessToken; import com.nimbusds.openid.connect.sdk.Nonce; -import nl.altindag.ssl.SSLFactory; import org.slf4j.Logger; import org.slf4j.LoggerFactory; diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/TokenSourceTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/TokenSourceTest.java index 544df01f..1c685a62 100644 --- a/sdk/src/test/java/io/opentdf/platform/sdk/TokenSourceTest.java +++ b/sdk/src/test/java/io/opentdf/platform/sdk/TokenSourceTest.java @@ -1,8 +1,11 @@ package io.opentdf.platform.sdk; import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.jwk.Curve; +import com.nimbusds.jose.jwk.ECKey; import com.nimbusds.jose.jwk.KeyUse; import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.jwk.gen.ECKeyGenerator; import com.nimbusds.jose.jwk.gen.RSAKeyGenerator; import com.nimbusds.jwt.SignedJWT; import com.nimbusds.oauth2.sdk.ClientCredentialsGrant; @@ -116,4 +119,95 @@ void noNonceClaimWhenNoCachedNonce() throws Exception { assertThat(nonceClaim).isNull(); } } + + @Test + void ecKeyGeneratesDPoPProof() throws Exception { + ECKey ecKey = new ECKeyGenerator(Curve.P_256) + .keyUse(KeyUse.SIGNATURE) + .keyID(UUID.randomUUID().toString()) + .generate(); + try (MockWebServer tokenServer = new MockWebServer()) { + tokenServer.enqueue(new MockResponse() + .setBody(FAKE_TOKEN_RESPONSE) + .setHeader("Content-Type", "application/json")); + tokenServer.start(); + + TokenSource ts = new TokenSource( + new ClientSecretBasic(new ClientID("test-client"), new Secret("test-secret")), + ecKey, + JWSAlgorithm.ES256, + tokenServer.url("/token").uri(), + new ClientCredentialsGrant(), + null + ); + URL testUrl = new URL("https://kas.example.com/kas"); + ts.cacheNonce(testUrl, "ec-nonce"); + + TokenSource.AuthHeaders headers = ts.getAuthHeaders(testUrl, "POST"); + assertThat(headers.getAuthHeader()).startsWith("DPoP "); + + SignedJWT dpopJwt = SignedJWT.parse(headers.getDpopHeader()); + assertThat(dpopJwt.getHeader().getAlgorithm()).isEqualTo(JWSAlgorithm.ES256); + assertThat(dpopJwt.getJWTClaimsSet().getStringClaim("nonce")).isEqualTo("ec-nonce"); + } + } + + @Test + void noncesAreIsolatedByOrigin() throws Exception { + RSAKey rsaKey = new RSAKeyGenerator(2048) + .keyUse(KeyUse.SIGNATURE) + .keyID(UUID.randomUUID().toString()) + .generate(); + try (MockWebServer tokenServer = new MockWebServer()) { + for (int i = 0; i < 4; i++) { + tokenServer.enqueue(new MockResponse() + .setBody(FAKE_TOKEN_RESPONSE) + .setHeader("Content-Type", "application/json")); + } + tokenServer.start(); + + TokenSource ts = buildTokenSource(tokenServer, rsaKey); + URL kasUrl = new URL("https://kas.example.com/kas"); + URL otherUrl = new URL("https://other.example.com/kas"); + + ts.cacheNonce(kasUrl, "kas-nonce"); + + TokenSource.AuthHeaders headersForKas = ts.getAuthHeaders(kasUrl, "POST"); + TokenSource.AuthHeaders headersForOther = ts.getAuthHeaders(otherUrl, "POST"); + + String kasNonce = SignedJWT.parse(headersForKas.getDpopHeader()) + .getJWTClaimsSet().getStringClaim("nonce"); + String otherNonce = SignedJWT.parse(headersForOther.getDpopHeader()) + .getJWTClaimsSet().getStringClaim("nonce"); + + assertThat(kasNonce).isEqualTo("kas-nonce"); + assertThat(otherNonce).isNull(); + } + } + + @Test + void emptyNonceIsNotCached() throws Exception { + RSAKey rsaKey = new RSAKeyGenerator(2048) + .keyUse(KeyUse.SIGNATURE) + .keyID(UUID.randomUUID().toString()) + .generate(); + try (MockWebServer tokenServer = new MockWebServer()) { + tokenServer.enqueue(new MockResponse() + .setBody(FAKE_TOKEN_RESPONSE) + .setHeader("Content-Type", "application/json")); + tokenServer.start(); + + TokenSource ts = buildTokenSource(tokenServer, rsaKey); + URL testUrl = new URL("https://kas.example.com/kas"); + + ts.cacheNonce(testUrl, ""); + ts.cacheNonce(testUrl, null); + + TokenSource.AuthHeaders headers = ts.getAuthHeaders(testUrl, "POST"); + String nonceClaim = SignedJWT.parse(headers.getDpopHeader()) + .getJWTClaimsSet().getStringClaim("nonce"); + + assertThat(nonceClaim).isNull(); + } + } } From 6bc35bc13006510cfa25c254131aedb6beb9bca3 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Tue, 16 Jun 2026 09:12:26 -0400 Subject: [PATCH 07/24] docs: DPoP nonce challenge design spec --- .../2026-06-16-dpop-nonce-challenge-design.md | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-16-dpop-nonce-challenge-design.md diff --git a/docs/superpowers/specs/2026-06-16-dpop-nonce-challenge-design.md b/docs/superpowers/specs/2026-06-16-dpop-nonce-challenge-design.md new file mode 100644 index 00000000..6341bcb4 --- /dev/null +++ b/docs/superpowers/specs/2026-06-16-dpop-nonce-challenge-design.md @@ -0,0 +1,109 @@ +# DPoP Nonce Challenge Support — Java SDK + +**Date:** 2026-06-16 +**Branch:** DSPX-3397-java-sdk + +## Problem + +When the platform runs with `server.auth.dpop.require_nonce: true`, every DPoP token +request without a nonce claim receives an HTTP 401 with `error=use_dpop_nonce` and a +`DPoP-Nonce` response header. `TokenSource.getToken()` currently throws an +`SDKException` on any non-success token response, so all authenticated operations +fail — including legacy TDF decryption — and the test matrix skips +`dpop_nonce_challenge` tests because `Command.java` and `cli.sh` declare the feature +unsupported. + +## Scope + +Three files, ~25 lines of change total. No new dependencies. No architectural changes. + +## Design + +### 1. `TokenSource.getToken()` — proactive lookup + one-shot retry + +**Proactive lookup (first attempt):** +Before generating the initial DPoP proof for the token endpoint, look up the nonce +cache by the token endpoint's origin: + +```java +String origin = getOrigin(tokenEndpointURI.toURL()); +String cachedNonce = nonceCache.get(origin); +SignedJWT proof = (cachedNonce != null) + ? dpopFactory.createDPoPJWT(method, uri, new Nonce(cachedNonce)) + : dpopFactory.createDPoPJWT(method, uri); +``` + +This mirrors what `getAuthHeaders()` already does for resource requests, so after the +first nonce handshake all future token refreshes succeed on the first try. + +**Retry on `use_dpop_nonce`:** +After `TokenResponse.parse(httpResponse)`, if the response is not a success and the +error code is `use_dpop_nonce`: + +1. Extract `DPoP-Nonce` from `httpResponse.getHeaderValue("DPoP-Nonce")`. +2. Call `cacheNonce(tokenEndpointURI.toURL(), nonce)`. +3. Rebuild a fresh `TokenRequest → HTTPRequest`, set the SSL factory, generate a new + proof using `createDPoPJWT(method, uri, new Nonce(nonce))` (the Nimbus 11.10.1 + `DPoPProofFactory` interface provides this signature), call `send()`. +4. Parse the response. Apply the normal success/failure check — no second retry. + +`getOrigin()` and `cacheNonce()` are existing package-private helpers on `TokenSource`; +no new methods needed. + +### 2. `Command.java` — declare `dpop_nonce_challenge` supported + +File: `cmdline/src/main/java/io/opentdf/platform/Command.java`, line 81. + +```java +// before +return "dpop".equalsIgnoreCase(feature) ? 0 : 1; + +// after +return ("dpop".equalsIgnoreCase(feature) + || "dpop_nonce_challenge".equalsIgnoreCase(feature)) ? 0 : 1; +``` + +### 3. `xtest/sdk/java/cli.sh` — delegate detection to the binary + +File: `xtest/sdk/java/cli.sh` (in the **tests repo**), lines 115-116. + +```bash +# before +dpop_nonce_challenge) + echo "dpop_nonce_challenge not supported" + exit 1 + ;; + +# after +dpop_nonce_challenge) + java -jar "$SCRIPT_DIR"/cmdline.jar supports dpop_nonce_challenge + exit $? + ;; +``` + +## Error handling + +- If the retry also fails (wrong nonce, network error, etc.), the existing + `SDKException` path applies — same message format as today. +- If `DPoP-Nonce` is absent from the 401 body (malformed server response), the + `use_dpop_nonce` branch falls through to the normal failure path without retrying. + +## Testing + +**Automated (CI):** Re-run the `xtest.yml` workflow against `DSPX-3397-platform-service` +with `dpop-challenge-enabled: true`. Previously-failing legacy tests (`test_decrypt_*`) +and previously-skipped nonce tests (`test_dpop_server_issued_nonce_retry`) should both +pass. + +**Unit test:** Add a test in `TokenSourceTest` (or the existing DPoP test class) that +mocks an HTTP token endpoint returning 401 + `use_dpop_nonce` + `DPoP-Nonce: ` +on the first call and 200 on the second, verifying the nonce is included in the retry +proof's JWT claims. + +## Files changed + +| File | Repo | Change | +|------|------|--------| +| `sdk/src/main/java/io/opentdf/platform/sdk/TokenSource.java` | java-sdk | Nonce lookup + retry in `getToken()` | +| `cmdline/src/main/java/io/opentdf/platform/Command.java` | java-sdk | Declare `dpop_nonce_challenge` supported | +| `xtest/sdk/java/cli.sh` | tests | Delegate detection to binary | From cde8e27eb784bd286513219361545fd9d60d70e2 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Tue, 16 Jun 2026 09:37:00 -0400 Subject: [PATCH 08/24] docs: DPoP nonce challenge implementation plan --- .../plans/2026-06-16-dpop-nonce-challenge.md | 377 ++++++++++++++++++ 1 file changed, 377 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-16-dpop-nonce-challenge.md diff --git a/docs/superpowers/plans/2026-06-16-dpop-nonce-challenge.md b/docs/superpowers/plans/2026-06-16-dpop-nonce-challenge.md new file mode 100644 index 00000000..03f3f895 --- /dev/null +++ b/docs/superpowers/plans/2026-06-16-dpop-nonce-challenge.md @@ -0,0 +1,377 @@ +# DPoP Nonce Challenge Support Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the Java SDK correctly handle DPoP nonce challenges from the authorization server (token endpoint) and declare `dpop_nonce_challenge` support, so that legacy TDF decryption and explicit DPoP nonce tests pass when the platform runs with `require_nonce: true`. + +**Architecture:** Add proactive nonce cache lookup and a one-shot retry to `TokenSource.getToken()` — when the token endpoint returns `error=use_dpop_nonce`, extract the `DPoP-Nonce` response header, cache it per-origin, rebuild the token request with the nonce in the DPoP proof, and retry once. Then declare support in `Command.java` and the xtest CLI shim. + +**Tech Stack:** Java 11, Nimbus oauth2-oidc-sdk 11.10.1, OkHttp MockWebServer (tests), JUnit 5, AssertJ, Picocli + +--- + +## File Map + +| File | Repo | Change | +|------|------|--------| +| `sdk/src/main/java/io/opentdf/platform/sdk/TokenSource.java` | java-sdk | Nonce lookup + retry in `getToken()` | +| `sdk/src/test/java/io/opentdf/platform/sdk/TokenSourceTest.java` | java-sdk | Two new tests: retry and proactive | +| `cmdline/src/main/java/io/opentdf/platform/Command.java` | java-sdk | Declare `dpop_nonce_challenge` supported | +| `cmdline/src/test/java/io/opentdf/platform/CommandTest.java` | java-sdk | New: test supports command exit codes | +| `xtest/sdk/java/cli.sh` | tests | Delegate `dpop_nonce_challenge` detection to binary | + +--- + +## Task 1: Token endpoint nonce retry in TokenSource + +**Files:** +- Modify: `sdk/src/main/java/io/opentdf/platform/sdk/TokenSource.java` (`getToken()` method, lines 162–216) +- Modify: `sdk/src/test/java/io/opentdf/platform/sdk/TokenSourceTest.java` + +### - [ ] Step 1: Write two failing tests + +Add these two tests to `TokenSourceTest.java`, after the `emptyNonceIsNotCached` test. The existing `buildTokenSource` helper and imports cover everything needed; add `RecordedRequest` to the imports. + +```java +// Add to imports: +import okhttp3.mockwebserver.RecordedRequest; +``` + +```java +@Test +void getToken_retriesWithNonceOnUseDpopNonce() throws Exception { + RSAKey rsaKey = new RSAKeyGenerator(2048) + .keyUse(KeyUse.SIGNATURE) + .keyID(UUID.randomUUID().toString()) + .generate(); + try (MockWebServer tokenServer = new MockWebServer()) { + // First: 401 use_dpop_nonce + tokenServer.enqueue(new MockResponse() + .setResponseCode(401) + .setHeader("Content-Type", "application/json") + .addHeader("DPoP-Nonce", "retry-nonce-abc") + .setBody("{\"error\":\"use_dpop_nonce\",\"error_description\":\"nonce required\"}")); + // Second: success + tokenServer.enqueue(new MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody("{\"access_token\":\"real-token\",\"token_type\":\"DPoP\",\"expires_in\":3600}")); + tokenServer.start(); + + TokenSource ts = buildTokenSource(tokenServer, rsaKey); + URL resourceUrl = new URL("https://kas.example.com/kas"); + + TokenSource.AuthHeaders headers = ts.getAuthHeaders(resourceUrl, "POST"); + + assertThat(headers.getAuthHeader()).isEqualTo("DPoP real-token"); + assertThat(tokenServer.getRequestCount()).isEqualTo(2); + + RecordedRequest first = tokenServer.takeRequest(); + String firstNonce = SignedJWT.parse(first.getHeader("DPoP")) + .getJWTClaimsSet().getStringClaim("nonce"); + assertThat(firstNonce).isNull(); + + RecordedRequest second = tokenServer.takeRequest(); + String secondNonce = SignedJWT.parse(second.getHeader("DPoP")) + .getJWTClaimsSet().getStringClaim("nonce"); + assertThat(secondNonce).isEqualTo("retry-nonce-abc"); + } +} + +@Test +void getToken_usesProactivelyCachedNonce() throws Exception { + RSAKey rsaKey = new RSAKeyGenerator(2048) + .keyUse(KeyUse.SIGNATURE) + .keyID(UUID.randomUUID().toString()) + .generate(); + try (MockWebServer tokenServer = new MockWebServer()) { + tokenServer.enqueue(new MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody("{\"access_token\":\"proactive-token\",\"token_type\":\"DPoP\",\"expires_in\":3600}")); + tokenServer.start(); + + TokenSource ts = buildTokenSource(tokenServer, rsaKey); + // Pre-seed the cache for the token endpoint origin + ts.cacheNonce(tokenServer.url("/token").url(), "proactive-nonce"); + + ts.getAuthHeaders(new URL("https://kas.example.com/kas"), "POST"); + + assertThat(tokenServer.getRequestCount()).isEqualTo(1); + + RecordedRequest request = tokenServer.takeRequest(); + String nonceClaim = SignedJWT.parse(request.getHeader("DPoP")) + .getJWTClaimsSet().getStringClaim("nonce"); + assertThat(nonceClaim).isEqualTo("proactive-nonce"); + } +} +``` + +### - [ ] Step 2: Run tests to confirm they fail + +```bash +cd /Users/dmihalcik/Documents/GitHub/worktrees/DSPX-3397-java-sdk/sdk +mvn test -Dtest=TokenSourceTest#getToken_retriesWithNonceOnUseDpopNonce+getToken_usesProactivelyCachedNonce -q +``` + +Expected: both tests FAIL. `getToken_retriesWithNonceOnUseDpopNonce` fails because `getToken()` throws `SDKException("failure to get token ... error code = [use_dpop_nonce]")` on the first 401. `getToken_usesProactivelyCachedNonce` fails because the token endpoint 401 is not expected (only one response is queued). + +### - [ ] Step 3: Implement the fix in TokenSource.getToken() + +Replace the body of the `if (token == null || isTokenExpired())` block in `TokenSource.java` (approximately lines 168–206). Keep the surrounding `try/catch` and `synchronized` untouched. + +The `Nonce` import is already present at line 18. No new imports needed. + +```java +if (token == null || isTokenExpired()) { + logger.trace("The current access token is expired or empty, getting a new one"); + + DPoPProofFactory dpopFactory = new DefaultDPoPProofFactory(dpopJwk, dpopAlg); + + // Proactively use any cached nonce for the token endpoint (RFC 9449 §8) + String cachedNonce = nonceCache.get(getOrigin(tokenEndpointURI.toURL())); + + TokenRequest tokenRequest = new TokenRequest(this.tokenEndpointURI, clientAuth, authzGrant, null); + HTTPRequest httpRequest = tokenRequest.toHTTPRequest(); + if (sslSocketFactory != null) { + httpRequest.setSSLSocketFactory(sslSocketFactory); + } + SignedJWT proof = (cachedNonce != null) + ? dpopFactory.createDPoPJWT(httpRequest.getMethod().name(), httpRequest.getURI(), new Nonce(cachedNonce)) + : dpopFactory.createDPoPJWT(httpRequest.getMethod().name(), httpRequest.getURI()); + httpRequest.setDPoP(proof); + + HTTPResponse httpResponse = httpRequest.send(); + TokenResponse tokenResponse = TokenResponse.parse(httpResponse); + + // RFC 9449 §8: if AS requires a nonce, cache it and retry once + if (!tokenResponse.indicatesSuccess()) { + ErrorObject error = tokenResponse.toErrorResponse().getErrorObject(); + if ("use_dpop_nonce".equals(error.getCode().getValue())) { + String dpopNonce = httpResponse.getHeaderValue("DPoP-Nonce"); + if (dpopNonce != null) { + cacheNonce(tokenEndpointURI.toURL(), dpopNonce); + TokenRequest retryRequest = new TokenRequest(tokenEndpointURI, clientAuth, authzGrant, null); + HTTPRequest retryHttpRequest = retryRequest.toHTTPRequest(); + if (sslSocketFactory != null) { + retryHttpRequest.setSSLSocketFactory(sslSocketFactory); + } + SignedJWT retryProof = dpopFactory.createDPoPJWT( + retryHttpRequest.getMethod().name(), + retryHttpRequest.getURI(), + new Nonce(dpopNonce)); + retryHttpRequest.setDPoP(retryProof); + httpResponse = retryHttpRequest.send(); + tokenResponse = TokenResponse.parse(httpResponse); + } + } + if (!tokenResponse.indicatesSuccess()) { + ErrorObject finalError = tokenResponse.toErrorResponse().getErrorObject(); + throw new SDKException("failure to get token. description = [" + finalError.getDescription() + + "] error code = [" + finalError.getCode() + + "] error uri = [" + finalError.getURI() + "]"); + } + } + + var tokens = tokenResponse.toSuccessResponse().getTokens(); + if (tokens.getDPoPAccessToken() != null) { + logger.trace("retrieved a new DPoP access token"); + } else if (tokens.getAccessToken() != null) { + logger.trace("retrieved a new access token"); + } else { + logger.trace("got an access token of unknown type"); + } + + this.token = tokens.getAccessToken(); + + if (token.getLifetime() != 0) { + this.tokenExpiryTime = Instant.now().plusSeconds(token.getLifetime() / 3); + } +} +``` + +### - [ ] Step 4: Run the full TokenSourceTest suite + +```bash +cd /Users/dmihalcik/Documents/GitHub/worktrees/DSPX-3397-java-sdk/sdk +mvn test -Dtest=TokenSourceTest -q +``` + +Expected: all tests PASS (the 6 existing + 2 new). + +### - [ ] Step 5: Commit + +```bash +cd /Users/dmihalcik/Documents/GitHub/worktrees/DSPX-3397-java-sdk +git add sdk/src/main/java/io/opentdf/platform/sdk/TokenSource.java \ + sdk/src/test/java/io/opentdf/platform/sdk/TokenSourceTest.java +git commit -m "feat(sdk): retry token request with nonce on use_dpop_nonce (RFC 9449 §8)" +``` + +--- + +## Task 2: Declare dpop_nonce_challenge in Command.java + +**Files:** +- Modify: `cmdline/src/main/java/io/opentdf/platform/Command.java` (line 81) +- Create: `cmdline/src/test/java/io/opentdf/platform/CommandTest.java` + +### - [ ] Step 6: Create the test file + +Create `cmdline/src/test/java/io/opentdf/platform/CommandTest.java`: + +```java +package io.opentdf.platform; + +import org.junit.jupiter.api.Test; +import picocli.CommandLine; + +import static org.assertj.core.api.Assertions.assertThat; + +class CommandTest { + + @Test + void supports_dpop_exits_0() { + int code = new CommandLine(new Command()).execute("supports", "dpop"); + assertThat(code).isEqualTo(0); + } + + @Test + void supports_dpop_nonce_challenge_exits_0() { + int code = new CommandLine(new Command()).execute("supports", "dpop_nonce_challenge"); + assertThat(code).isEqualTo(0); + } + + @Test + void supports_unknown_feature_exits_1() { + int code = new CommandLine(new Command()).execute("supports", "unknown_feature"); + assertThat(code).isEqualTo(1); + } +} +``` + +### - [ ] Step 7: Run test to confirm it fails + +```bash +cd /Users/dmihalcik/Documents/GitHub/worktrees/DSPX-3397-java-sdk/cmdline +mvn test -Dtest=CommandTest#supports_dpop_nonce_challenge_exits_0 -q +``` + +Expected: FAIL — exit code is 1, not 0. + +### - [ ] Step 8: Update Command.java line 81 + +Change the single line inside `Supports.call()`: + +```java +// Before: +return "dpop".equalsIgnoreCase(feature) ? 0 : 1; + +// After: +return ("dpop".equalsIgnoreCase(feature) || "dpop_nonce_challenge".equalsIgnoreCase(feature)) ? 0 : 1; +``` + +### - [ ] Step 9: Run CommandTest suite + +```bash +cd /Users/dmihalcik/Documents/GitHub/worktrees/DSPX-3397-java-sdk/cmdline +mvn test -Dtest=CommandTest -q +``` + +Expected: all 3 tests PASS. + +### - [ ] Step 10: Commit + +```bash +cd /Users/dmihalcik/Documents/GitHub/worktrees/DSPX-3397-java-sdk +git add cmdline/src/main/java/io/opentdf/platform/Command.java \ + cmdline/src/test/java/io/opentdf/platform/CommandTest.java +git commit -m "feat(cmdline): declare dpop_nonce_challenge support" +``` + +--- + +## Task 3: Update xtest cli.sh feature detection + +**Files:** +- Modify: `xtest/sdk/java/cli.sh` in the **tests repo** (`/Users/dmihalcik/Documents/GitHub/opentdf/tests`) + +### - [ ] Step 11: Update cli.sh + +In `xtest/sdk/java/cli.sh`, find the `dpop_nonce_challenge)` case (around line 115) and replace the hardcoded failure with a delegation to the binary: + +```bash +# Before: +dpop_nonce_challenge) + echo "dpop_nonce_challenge not supported" + exit 1 + ;; + +# After: +dpop_nonce_challenge) + java -jar "$SCRIPT_DIR"/cmdline.jar supports dpop_nonce_challenge + exit $? + ;; +``` + +### - [ ] Step 12: Smoke-test the detection locally + +After building the cmdline jar (`mvn package -pl cmdline -DskipTests -q` in the java-sdk worktree) and placing it where the test harness expects it: + +```bash +cd /Users/dmihalcik/Documents/GitHub/opentdf/tests/xtest/sdk/java +bash cli.sh dpop_nonce_challenge +echo "Exit code: $?" +``` + +Expected output: exit code `0`. + +### - [ ] Step 13: Commit in the tests repo + +```bash +cd /Users/dmihalcik/Documents/GitHub/opentdf/tests +git add xtest/sdk/java/cli.sh +git commit -m "feat(java-sdk): delegate dpop_nonce_challenge detection to binary" +``` + +--- + +## Task 4: Full test run + +### - [ ] Step 14: Run the full sdk module test suite + +```bash +cd /Users/dmihalcik/Documents/GitHub/worktrees/DSPX-3397-java-sdk/sdk +mvn test -q +``` + +Expected: all tests PASS, no regressions. + +### - [ ] Step 15: Build the cmdline jar + +```bash +cd /Users/dmihalcik/Documents/GitHub/worktrees/DSPX-3397-java-sdk +mvn package -DskipTests -q +``` + +Expected: `cmdline/target/cmdline-*.jar` produced with no errors. + +### - [ ] Step 16: Push java-sdk branch and trigger CI + +```bash +cd /Users/dmihalcik/Documents/GitHub/worktrees/DSPX-3397-java-sdk +git push origin DSPX-3397-java-sdk +``` + +Then re-trigger `xtest.yml` in the tests repo against the updated branches: + +```bash +gh workflow run xtest.yml \ + --repo opentdf/tests \ + --ref fix-dpop-nonce-challenge \ + --field platform-ref=DSPX-3397-platform-service \ + --field js-ref=DSPX-3397-web-sdk \ + --field java-ref=DSPX-3397-java-sdk +``` + +Expected: Java legacy tests (`test_decrypt_*`) pass; `test_dpop_server_issued_nonce_retry` and related nonce tests run and pass (no longer skipped). From 04f7f143eda527616ece97a2c4cd620cf9c4206e Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Tue, 16 Jun 2026 10:43:01 -0400 Subject: [PATCH 09/24] =?UTF-8?q?feat(sdk):=20retry=20token=20request=20wi?= =?UTF-8?q?th=20nonce=20on=20use=5Fdpop=5Fnonce=20(RFC=209449=20=C2=A78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../io/opentdf/platform/sdk/TokenSource.java | 48 +++++++++---- .../opentdf/platform/sdk/TokenSourceTest.java | 69 +++++++++++++++++++ 2 files changed, 105 insertions(+), 12 deletions(-) diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/TokenSource.java b/sdk/src/main/java/io/opentdf/platform/sdk/TokenSource.java index 65550486..35096d4c 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/TokenSource.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/TokenSource.java @@ -166,27 +166,52 @@ private synchronized AccessToken getToken() { logger.trace("The current access token is expired or empty, getting a new one"); - // Make the token request - TokenRequest tokenRequest = new TokenRequest(this.tokenEndpointURI, - clientAuth, authzGrant, null); + DPoPProofFactory dpopFactory = new DefaultDPoPProofFactory(dpopJwk, dpopAlg); + + // Proactively use any cached nonce for the token endpoint origin (RFC 9449 §8) + String cachedNonce = nonceCache.get(getOrigin(tokenEndpointURI.toURL())); + + TokenRequest tokenRequest = new TokenRequest(this.tokenEndpointURI, clientAuth, authzGrant, null); HTTPRequest httpRequest = tokenRequest.toHTTPRequest(); if (sslSocketFactory != null) { httpRequest.setSSLSocketFactory(sslSocketFactory); } - - DPoPProofFactory dpopFactory = new DefaultDPoPProofFactory(dpopJwk, dpopAlg); - - SignedJWT proof = dpopFactory.createDPoPJWT(httpRequest.getMethod().name(), httpRequest.getURI()); - + SignedJWT proof = (cachedNonce != null) + ? dpopFactory.createDPoPJWT(httpRequest.getMethod().name(), httpRequest.getURI(), new Nonce(cachedNonce)) + : dpopFactory.createDPoPJWT(httpRequest.getMethod().name(), httpRequest.getURI()); httpRequest.setDPoP(proof); - TokenResponse tokenResponse; HTTPResponse httpResponse = httpRequest.send(); - tokenResponse = TokenResponse.parse(httpResponse); + TokenResponse tokenResponse = TokenResponse.parse(httpResponse); + + // RFC 9449 §8: if AS requires a nonce, cache it and retry once if (!tokenResponse.indicatesSuccess()) { ErrorObject error = tokenResponse.toErrorResponse().getErrorObject(); - throw new SDKException("failure to get token. description = [" + error.getDescription() + "] error code = [" + error.getCode() + "] error uri = [" + error.getURI() + "]"); + if ("use_dpop_nonce".equals(error.getCode())) { + String dpopNonce = httpResponse.getHeaderValue("DPoP-Nonce"); + if (dpopNonce != null) { + cacheNonce(tokenEndpointURI.toURL(), dpopNonce); + TokenRequest retryRequest = new TokenRequest(tokenEndpointURI, clientAuth, authzGrant, null); + HTTPRequest retryHttpRequest = retryRequest.toHTTPRequest(); + if (sslSocketFactory != null) { + retryHttpRequest.setSSLSocketFactory(sslSocketFactory); + } + SignedJWT retryProof = dpopFactory.createDPoPJWT( + retryHttpRequest.getMethod().name(), + retryHttpRequest.getURI(), + new Nonce(dpopNonce)); + retryHttpRequest.setDPoP(retryProof); + httpResponse = retryHttpRequest.send(); + tokenResponse = TokenResponse.parse(httpResponse); + } + } + if (!tokenResponse.indicatesSuccess()) { + ErrorObject finalError = tokenResponse.toErrorResponse().getErrorObject(); + throw new SDKException("failure to get token. description = [" + finalError.getDescription() + + "] error code = [" + finalError.getCode() + + "] error uri = [" + finalError.getURI() + "]"); + } } var tokens = tokenResponse.toSuccessResponse().getTokens(); @@ -201,7 +226,6 @@ private synchronized AccessToken getToken() { this.token = tokens.getAccessToken(); if (token.getLifetime() != 0) { - // Need some type of leeway but not sure whats best this.tokenExpiryTime = Instant.now().plusSeconds(token.getLifetime() / 3); } diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/TokenSourceTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/TokenSourceTest.java index 1c685a62..b428d3bd 100644 --- a/sdk/src/test/java/io/opentdf/platform/sdk/TokenSourceTest.java +++ b/sdk/src/test/java/io/opentdf/platform/sdk/TokenSourceTest.java @@ -14,6 +14,7 @@ import com.nimbusds.oauth2.sdk.id.ClientID; import okhttp3.mockwebserver.MockResponse; import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; import org.junit.jupiter.api.Test; import java.net.URL; @@ -210,4 +211,72 @@ void emptyNonceIsNotCached() throws Exception { assertThat(nonceClaim).isNull(); } } + + @Test + void getToken_retriesWithNonceOnUseDpopNonce() throws Exception { + RSAKey rsaKey = new RSAKeyGenerator(2048) + .keyUse(KeyUse.SIGNATURE) + .keyID(UUID.randomUUID().toString()) + .generate(); + try (MockWebServer tokenServer = new MockWebServer()) { + // First: 401 use_dpop_nonce + tokenServer.enqueue(new MockResponse() + .setResponseCode(401) + .setHeader("Content-Type", "application/json") + .addHeader("DPoP-Nonce", "retry-nonce-abc") + .setBody("{\"error\":\"use_dpop_nonce\",\"error_description\":\"nonce required\"}")); + // Second: success + tokenServer.enqueue(new MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody("{\"access_token\":\"real-token\",\"token_type\":\"DPoP\",\"expires_in\":3600}")); + tokenServer.start(); + + TokenSource ts = buildTokenSource(tokenServer, rsaKey); + URL resourceUrl = new URL("https://kas.example.com/kas"); + + TokenSource.AuthHeaders headers = ts.getAuthHeaders(resourceUrl, "POST"); + + assertThat(headers.getAuthHeader()).isEqualTo("DPoP real-token"); + assertThat(tokenServer.getRequestCount()).isEqualTo(2); + + RecordedRequest first = tokenServer.takeRequest(); + String firstNonce = SignedJWT.parse(first.getHeader("DPoP")) + .getJWTClaimsSet().getStringClaim("nonce"); + assertThat(firstNonce).isNull(); + + RecordedRequest second = tokenServer.takeRequest(); + String secondNonce = SignedJWT.parse(second.getHeader("DPoP")) + .getJWTClaimsSet().getStringClaim("nonce"); + assertThat(secondNonce).isEqualTo("retry-nonce-abc"); + } + } + + @Test + void getToken_usesProactivelyCachedNonce() throws Exception { + RSAKey rsaKey = new RSAKeyGenerator(2048) + .keyUse(KeyUse.SIGNATURE) + .keyID(UUID.randomUUID().toString()) + .generate(); + try (MockWebServer tokenServer = new MockWebServer()) { + tokenServer.enqueue(new MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody("{\"access_token\":\"proactive-token\",\"token_type\":\"DPoP\",\"expires_in\":3600}")); + tokenServer.start(); + + TokenSource ts = buildTokenSource(tokenServer, rsaKey); + // Pre-seed the cache for the token endpoint origin + ts.cacheNonce(tokenServer.url("/token").url(), "proactive-nonce"); + + ts.getAuthHeaders(new URL("https://kas.example.com/kas"), "POST"); + + assertThat(tokenServer.getRequestCount()).isEqualTo(1); + + RecordedRequest request = tokenServer.takeRequest(); + String nonceClaim = SignedJWT.parse(request.getHeader("DPoP")) + .getJWTClaimsSet().getStringClaim("nonce"); + assertThat(nonceClaim).isEqualTo("proactive-nonce"); + } + } } From ca8bacdb9c1af701c5ab07448d7cc78b8ad601da Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Tue, 16 Jun 2026 11:31:03 -0400 Subject: [PATCH 10/24] fix(sdk): address DPoP nonce retry quality issues --- .../io/opentdf/platform/sdk/TokenSource.java | 12 ++++++++-- .../opentdf/platform/sdk/TokenSourceTest.java | 24 +++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/TokenSource.java b/sdk/src/main/java/io/opentdf/platform/sdk/TokenSource.java index 35096d4c..c79b682c 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/TokenSource.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/TokenSource.java @@ -169,7 +169,8 @@ private synchronized AccessToken getToken() { DPoPProofFactory dpopFactory = new DefaultDPoPProofFactory(dpopJwk, dpopAlg); // Proactively use any cached nonce for the token endpoint origin (RFC 9449 §8) - String cachedNonce = nonceCache.get(getOrigin(tokenEndpointURI.toURL())); + URL tokenEndpointUrl = tokenEndpointURI.toURL(); + String cachedNonce = nonceCache.get(getOrigin(tokenEndpointUrl)); TokenRequest tokenRequest = new TokenRequest(this.tokenEndpointURI, clientAuth, authzGrant, null); HTTPRequest httpRequest = tokenRequest.toHTTPRequest(); @@ -191,7 +192,7 @@ private synchronized AccessToken getToken() { if ("use_dpop_nonce".equals(error.getCode())) { String dpopNonce = httpResponse.getHeaderValue("DPoP-Nonce"); if (dpopNonce != null) { - cacheNonce(tokenEndpointURI.toURL(), dpopNonce); + cacheNonce(tokenEndpointUrl, dpopNonce); TokenRequest retryRequest = new TokenRequest(tokenEndpointURI, clientAuth, authzGrant, null); HTTPRequest retryHttpRequest = retryRequest.toHTTPRequest(); if (sslSocketFactory != null) { @@ -204,6 +205,13 @@ private synchronized AccessToken getToken() { retryHttpRequest.setDPoP(retryProof); httpResponse = retryHttpRequest.send(); tokenResponse = TokenResponse.parse(httpResponse); + // Cache any nonce rotation from the AS (RFC 9449 §8.1) + String rotatedNonce = httpResponse.getHeaderValue("DPoP-Nonce"); + if (rotatedNonce != null) { + cacheNonce(tokenEndpointUrl, rotatedNonce); + } + } else { + logger.warn("server returned use_dpop_nonce but did not supply a DPoP-Nonce response header"); } } if (!tokenResponse.indicatesSuccess()) { diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/TokenSourceTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/TokenSourceTest.java index b428d3bd..15dc70b3 100644 --- a/sdk/src/test/java/io/opentdf/platform/sdk/TokenSourceTest.java +++ b/sdk/src/test/java/io/opentdf/platform/sdk/TokenSourceTest.java @@ -21,6 +21,7 @@ import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; class TokenSourceTest { @@ -252,6 +253,29 @@ void getToken_retriesWithNonceOnUseDpopNonce() throws Exception { } } + @Test + void getToken_throwsDescriptiveErrorWhenUseDpopNonceLacksHeader() throws Exception { + RSAKey rsaKey = new RSAKeyGenerator(2048) + .keyUse(KeyUse.SIGNATURE) + .keyID(UUID.randomUUID().toString()) + .generate(); + try (MockWebServer tokenServer = new MockWebServer()) { + // 401 use_dpop_nonce but NO DPoP-Nonce header + tokenServer.enqueue(new MockResponse() + .setResponseCode(401) + .setHeader("Content-Type", "application/json") + .setBody("{\"error\":\"use_dpop_nonce\",\"error_description\":\"nonce required\"}")); + tokenServer.start(); + + TokenSource ts = buildTokenSource(tokenServer, rsaKey); + + assertThatThrownBy(() -> ts.getAuthHeaders(new URL("https://kas.example.com/kas"), "POST")) + .isInstanceOf(SDKException.class) + .satisfies(e -> assertThat(e.getMessage() + (e.getCause() != null ? e.getCause().getMessage() : "")) + .contains("use_dpop_nonce")); + } + } + @Test void getToken_usesProactivelyCachedNonce() throws Exception { RSAKey rsaKey = new RSAKeyGenerator(2048) From 6b87f96a944beb8ac4c6fa9674cd5bef5c08d314 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Tue, 16 Jun 2026 11:54:34 -0400 Subject: [PATCH 11/24] feat(cmdline): declare dpop_nonce_challenge support - Add test cases for Command.Supports subcommand - Implement support for dpop_nonce_challenge feature - Add JUnit 5 and AssertJ test dependencies to cmdline module --- cmdline/pom.xml | 12 +++++++++ .../java/io/opentdf/platform/Command.java | 2 +- .../java/io/opentdf/platform/CommandTest.java | 27 +++++++++++++++++++ 3 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 cmdline/src/test/java/io/opentdf/platform/CommandTest.java diff --git a/cmdline/pom.xml b/cmdline/pom.xml index 3f82579a..ac577d48 100644 --- a/cmdline/pom.xml +++ b/cmdline/pom.xml @@ -77,5 +77,17 @@ sdk ${project.version} + + + org.junit.jupiter + junit-jupiter + test + + + org.assertj + assertj-core + 3.25.3 + test + diff --git a/cmdline/src/main/java/io/opentdf/platform/Command.java b/cmdline/src/main/java/io/opentdf/platform/Command.java index 5b5ec869..cc569110 100644 --- a/cmdline/src/main/java/io/opentdf/platform/Command.java +++ b/cmdline/src/main/java/io/opentdf/platform/Command.java @@ -78,7 +78,7 @@ static class Supports implements Callable { @Override public Integer call() { - return "dpop".equalsIgnoreCase(feature) ? 0 : 1; + return ("dpop".equalsIgnoreCase(feature) || "dpop_nonce_challenge".equalsIgnoreCase(feature)) ? 0 : 1; } } diff --git a/cmdline/src/test/java/io/opentdf/platform/CommandTest.java b/cmdline/src/test/java/io/opentdf/platform/CommandTest.java new file mode 100644 index 00000000..b6cf2bb1 --- /dev/null +++ b/cmdline/src/test/java/io/opentdf/platform/CommandTest.java @@ -0,0 +1,27 @@ +package io.opentdf.platform; + +import org.junit.jupiter.api.Test; +import picocli.CommandLine; + +import static org.assertj.core.api.Assertions.assertThat; + +class CommandTest { + + @Test + void supports_dpop_exits_0() { + int code = new CommandLine(new Command()).execute("supports", "dpop"); + assertThat(code).isEqualTo(0); + } + + @Test + void supports_dpop_nonce_challenge_exits_0() { + int code = new CommandLine(new Command()).execute("supports", "dpop_nonce_challenge"); + assertThat(code).isEqualTo(0); + } + + @Test + void supports_unknown_feature_exits_1() { + int code = new CommandLine(new Command()).execute("supports", "unknown_feature"); + assertThat(code).isEqualTo(1); + } +} From a12fef3f0fc353e3f47653efa94047882dd96b30 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Tue, 16 Jun 2026 14:39:28 -0400 Subject: [PATCH 12/24] fix(sdk): harden DPoP retry interceptor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Validate WWW-Authenticate before retry/cache: only honor 401s carrying scheme=DPoP and error=use_dpop_nonce (RFC 9449 §8). A bare DPoP-Nonce header from any 401 no longer triggers a retry or poisons the cache. - Cache rotated nonces after every chain.proceed(), not just on 401s (RFC 9449 §8.1) — the next request now picks up rotations from 200s without an extra round-trip. - Clear the ThreadLocal defensively on entry and on exception so a failure in getAuthHeaders() cannot leak the URL into the next call on the same worker thread. Tests cover: single-retry guarantee, three negative WWW-Authenticate shapes (no challenge, Basic, DPoP error=invalid_token), 200-response nonce rotation cached for the next request, and 10-thread concurrent retry smoke test using a stateful Dispatcher (FIFO queues can't deliver alternating 401/200 reliably under concurrent load). --- .../opentdf/platform/sdk/AuthInterceptor.kt | 81 +++-- .../sdk/DPoPRetryInterceptorTest.java | 316 +++++++++++++++++- 2 files changed, 370 insertions(+), 27 deletions(-) diff --git a/sdk/src/main/kotlin/io/opentdf/platform/sdk/AuthInterceptor.kt b/sdk/src/main/kotlin/io/opentdf/platform/sdk/AuthInterceptor.kt index 99a4a2b6..9c611f1e 100644 --- a/sdk/src/main/kotlin/io/opentdf/platform/sdk/AuthInterceptor.kt +++ b/sdk/src/main/kotlin/io/opentdf/platform/sdk/AuthInterceptor.kt @@ -8,7 +8,13 @@ import com.connectrpc.http.clone import java.net.URL internal class AuthInterceptor(private val ts: TokenSource) : Interceptor { - // Thread request URL from requestFunction into responseFunction for nonce caching + // The connect-kotlin Interceptor API exposes no per-call context to thread the + // request URL into responseFunction. ThreadLocal is the workaround, relying on + // connect-kotlin's contract that requestFunction and responseFunction for a single + // unary call run synchronously on the same thread. If that assumption ever breaks, + // nonces could be cached against the wrong origin. The okhttp-level + // dpopRetryInterceptor below avoids the issue by reading the URL straight from + // chain.request(). private val requestUrl = ThreadLocal() override fun streamFunction(): StreamFunction { @@ -35,22 +41,31 @@ internal class AuthInterceptor(private val ts: TokenSource) : Interceptor { override fun unaryFunction(): UnaryFunction { return UnaryFunction( requestFunction = { request -> + // Clear any value left behind by an earlier requestFunction that + // threw before its paired responseFunction could run. + requestUrl.remove() requestUrl.set(request.url) + try { + val requestHeaders = mutableMapOf>() + val authHeaders = ts.getAuthHeaders(request.url, request.httpMethod.name) + requestHeaders["Authorization"] = listOf(authHeaders.authHeader) + requestHeaders["DPoP"] = listOf(authHeaders.dpopHeader) - val requestHeaders = mutableMapOf>() - val authHeaders = ts.getAuthHeaders(request.url, request.httpMethod.name) - requestHeaders["Authorization"] = listOf(authHeaders.authHeader) - requestHeaders["DPoP"] = listOf(authHeaders.dpopHeader) - - return@UnaryFunction UnaryHTTPRequest( - url = request.url, - contentType = request.contentType, - headers = requestHeaders, - message = request.message, - timeout = request.timeout, - methodSpec = request.methodSpec, - httpMethod = request.httpMethod - ) + UnaryHTTPRequest( + url = request.url, + contentType = request.contentType, + headers = requestHeaders, + message = request.message, + timeout = request.timeout, + methodSpec = request.methodSpec, + httpMethod = request.httpMethod + ) + } catch (t: Throwable) { + // responseFunction won't run, so clear the slot ourselves to + // avoid stale state leaking to the next call on this thread. + requestUrl.remove() + throw t + } }, responseFunction = { resp -> val url = requestUrl.get() @@ -68,26 +83,46 @@ internal class AuthInterceptor(private val ts: TokenSource) : Interceptor { } /** - * Returns an OkHttp interceptor that retries on 401 responses carrying a DPoP-Nonce header. - * The nonce is cached in the TokenSource and used in a fresh proof for the single retry. + * Returns an OkHttp interceptor that retries on RFC 9449 §8 DPoP nonce challenges. + * A 401 is retried only when WWW-Authenticate carries scheme=DPoP and error=use_dpop_nonce; + * any other 401 (or any 401 with only a stray DPoP-Nonce header) is passed through unchanged. + * Rotated nonces are cached after every successful proceed so the next request picks them up. */ fun dpopRetryInterceptor(): okhttp3.Interceptor = okhttp3.Interceptor { chain -> - val response = chain.proceed(chain.request()) - if (response.code == 401) { + val url = chain.request().url.toUrl() + var response = chain.proceed(chain.request()) + + // RFC 9449 §8.1: cache any rotated nonce from the response, regardless of status. + cacheNonceIfPresent(url, response) + + if (response.code == 401 && isDpopNonceChallenge(response)) { val dpopNonce = response.header("dpop-nonce") ?: response.header("DPoP-Nonce") if (dpopNonce != null) { response.close() - val url = chain.request().url.toUrl() - val method = chain.request().method ts.cacheNonce(url, dpopNonce) - val authHeaders = ts.getAuthHeaders(url, method) + val authHeaders = ts.getAuthHeaders(url, chain.request().method) val newRequest = chain.request().newBuilder() .header("Authorization", authHeaders.authHeader) .header("DPoP", authHeaders.dpopHeader) .build() - return@Interceptor chain.proceed(newRequest) + response = chain.proceed(newRequest) + cacheNonceIfPresent(url, response) } } response } + + private fun cacheNonceIfPresent(url: URL, response: okhttp3.Response) { + val nonce = response.header("dpop-nonce") ?: response.header("DPoP-Nonce") + if (nonce != null) { + ts.cacheNonce(url, nonce) + } + } + + private fun isDpopNonceChallenge(response: okhttp3.Response): Boolean { + return response.challenges().any { challenge -> + challenge.scheme.equals("DPoP", ignoreCase = true) && + challenge.authParams["error"].equals("use_dpop_nonce", ignoreCase = true) + } + } } diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/DPoPRetryInterceptorTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/DPoPRetryInterceptorTest.java index c4bc486f..c5df8d2c 100644 --- a/sdk/src/test/java/io/opentdf/platform/sdk/DPoPRetryInterceptorTest.java +++ b/sdk/src/test/java/io/opentdf/platform/sdk/DPoPRetryInterceptorTest.java @@ -12,12 +12,20 @@ import okhttp3.OkHttpClient; import okhttp3.Request; import okhttp3.Response; +import okhttp3.mockwebserver.Dispatcher; import okhttp3.mockwebserver.MockResponse; import okhttp3.mockwebserver.MockWebServer; import okhttp3.mockwebserver.RecordedRequest; import org.junit.jupiter.api.Test; +import java.util.ArrayList; +import java.util.List; import java.util.UUID; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; import static org.assertj.core.api.Assertions.assertThat; @@ -27,7 +35,11 @@ class DPoPRetryInterceptorTest { "{\"access_token\":\"test-token\",\"token_type\":\"Bearer\",\"expires_in\":3600}"; private AuthInterceptor buildAuthInterceptor(MockWebServer tokenServer, RSAKey rsaKey) throws Exception { - TokenSource ts = new TokenSource( + return new AuthInterceptor(buildTokenSource(tokenServer, rsaKey)); + } + + private TokenSource buildTokenSource(MockWebServer tokenServer, RSAKey rsaKey) { + return new TokenSource( new ClientSecretBasic(new ClientID("test-client"), new Secret("test-secret")), rsaKey, JWSAlgorithm.RS256, @@ -35,7 +47,6 @@ private AuthInterceptor buildAuthInterceptor(MockWebServer tokenServer, RSAKey r new ClientCredentialsGrant(), null ); - return new AuthInterceptor(ts); } @Test @@ -54,10 +65,11 @@ void retryOn401WithDPoPNonce() throws Exception { } tokenServer.start(); - // First request returns 401 + DPoP-Nonce; second returns 200 + // First request returns 401 + DPoP-Nonce + DPoP nonce challenge; second returns 200 kasServer.enqueue(new MockResponse() .setResponseCode(401) - .addHeader("DPoP-Nonce", "server-issued-nonce")); + .addHeader("DPoP-Nonce", "server-issued-nonce") + .addHeader("WWW-Authenticate", "DPoP error=\"use_dpop_nonce\"")); kasServer.enqueue(new MockResponse().setResponseCode(200)); kasServer.start(); @@ -121,6 +133,302 @@ void noRetryOn401WithoutDPoPNonce() throws Exception { } } + @Test + void onlyRetriesOnceWhenSecondResponseAlsoChallengesWithNonce() throws Exception { + // Pins the single-retry guarantee: even if the retry response is also a + // 401 + DPoP-Nonce + use_dpop_nonce challenge, no further retry is attempted. + // This protects against an infinite-retry loop if an AS misbehaves or rotates + // nonces faster than the client can spend them. + RSAKey rsaKey = new RSAKeyGenerator(2048) + .keyUse(KeyUse.SIGNATURE) + .keyID(UUID.randomUUID().toString()) + .generate(); + try (MockWebServer tokenServer = new MockWebServer(); + MockWebServer kasServer = new MockWebServer()) { + tokenServer.enqueue(new MockResponse() + .setBody(FAKE_TOKEN_RESPONSE) + .setHeader("Content-Type", "application/json")); + tokenServer.start(); + + kasServer.enqueue(new MockResponse() + .setResponseCode(401) + .addHeader("DPoP-Nonce", "first-nonce") + .addHeader("WWW-Authenticate", "DPoP error=\"use_dpop_nonce\"")); + kasServer.enqueue(new MockResponse() + .setResponseCode(401) + .addHeader("DPoP-Nonce", "second-nonce") + .addHeader("WWW-Authenticate", "DPoP error=\"use_dpop_nonce\"")); + kasServer.start(); + + AuthInterceptor authInterceptor = buildAuthInterceptor(tokenServer, rsaKey); + OkHttpClient client = new OkHttpClient.Builder() + .addInterceptor(authInterceptor.dpopRetryInterceptor()) + .build(); + + Request request = new Request.Builder() + .url(kasServer.url("/kas/rewrap")) + .post(okhttp3.RequestBody.create(new byte[0])) + .build(); + Response response = client.newCall(request).execute(); + response.close(); + + assertThat(kasServer.getRequestCount()).isEqualTo(2); + assertThat(response.code()).isEqualTo(401); + } + } + + @Test + void concurrentRequestsAllRetrySuccessfully() throws Exception { + // Smoke test: drive 10 parallel requests through the retry interceptor, each of + // which sees a 401+nonce followed by a 200. All 10 must eventually return 200 + // and each retry must carry a DPoP-Nonce claim. Regressions in the cross-thread + // safety of the nonce cache or interceptor state should surface here. + final int parallelism = 10; + RSAKey rsaKey = new RSAKeyGenerator(2048) + .keyUse(KeyUse.SIGNATURE) + .keyID(UUID.randomUUID().toString()) + .generate(); + try (MockWebServer tokenServer = new MockWebServer(); + MockWebServer kasServer = new MockWebServer()) { + // One token response per request — the cache keeps us to one in practice, + // but enqueue enough that any per-thread re-fetch doesn't deadlock the test. + for (int i = 0; i < parallelism * 2; i++) { + tokenServer.enqueue(new MockResponse() + .setBody(FAKE_TOKEN_RESPONSE) + .setHeader("Content-Type", "application/json")); + } + tokenServer.start(); + + // A FIFO queue can't deliver alternating 401/200 reliably under concurrent + // load — by the time request N's retry arrives, request N+1's first attempt + // may have already consumed N's 200. Use a stateful dispatcher that decides + // based on whether the request already carries a nonce. + kasServer.setDispatcher(new Dispatcher() { + @Override + public MockResponse dispatch(RecordedRequest request) { + String dpop = request.getHeader("DPoP"); + boolean hasNonce = false; + if (dpop != null) { + try { + hasNonce = SignedJWT.parse(dpop) + .getJWTClaimsSet().getStringClaim("nonce") != null; + } catch (Exception ignored) { + } + } + if (hasNonce) { + return new MockResponse().setResponseCode(200); + } + return new MockResponse() + .setResponseCode(401) + .addHeader("DPoP-Nonce", "concurrent-nonce") + .addHeader("WWW-Authenticate", "DPoP error=\"use_dpop_nonce\""); + } + }); + kasServer.start(); + + AuthInterceptor authInterceptor = buildAuthInterceptor(tokenServer, rsaKey); + OkHttpClient client = new OkHttpClient.Builder() + .addInterceptor(authInterceptor.dpopRetryInterceptor()) + .build(); + + ExecutorService pool = Executors.newFixedThreadPool(parallelism); + try { + List> tasks = new ArrayList<>(); + for (int i = 0; i < parallelism; i++) { + tasks.add(() -> { + Request request = new Request.Builder() + .url(kasServer.url("/kas/rewrap")) + .post(okhttp3.RequestBody.create(new byte[0])) + .build(); + try (Response response = client.newCall(request).execute()) { + return response.code(); + } + }); + } + List> results = pool.invokeAll(tasks, 30, TimeUnit.SECONDS); + for (Future f : results) { + assertThat(f.get()).isEqualTo(200); + } + } finally { + pool.shutdownNow(); + } + + // Each request produced a 401 + a retry: 2 * parallelism total. + assertThat(kasServer.getRequestCount()).isEqualTo(parallelism * 2); + + // Every retry must carry a nonce — pin that the cross-thread URL/nonce + // bookkeeping never produced a retry without one. + int retriesWithNonce = 0; + int totalRetries = 0; + for (int i = 0; i < parallelism * 2; i++) { + RecordedRequest recorded = kasServer.takeRequest(); + String dpop = recorded.getHeader("DPoP"); + if (dpop == null) { + continue; + } + String nonce = SignedJWT.parse(dpop).getJWTClaimsSet().getStringClaim("nonce"); + if (nonce != null) { + retriesWithNonce++; + } + if (nonce != null) { + totalRetries++; + } + } + assertThat(totalRetries).isEqualTo(parallelism); + assertThat(retriesWithNonce).isEqualTo(parallelism); + } + } + + @Test + void noRetryOn401WithDPoPNonceButNoChallenge() throws Exception { + // A bare DPoP-Nonce header on a 401 (no WWW-Authenticate) must not trigger a retry — + // otherwise any rogue origin can poison the nonce cache and burn a token round-trip. + RSAKey rsaKey = new RSAKeyGenerator(2048) + .keyUse(KeyUse.SIGNATURE) + .keyID(UUID.randomUUID().toString()) + .generate(); + try (MockWebServer tokenServer = new MockWebServer(); + MockWebServer kasServer = new MockWebServer()) { + tokenServer.enqueue(new MockResponse() + .setBody(FAKE_TOKEN_RESPONSE) + .setHeader("Content-Type", "application/json")); + tokenServer.start(); + + kasServer.enqueue(new MockResponse() + .setResponseCode(401) + .addHeader("DPoP-Nonce", "spurious-nonce")); + kasServer.start(); + + AuthInterceptor authInterceptor = buildAuthInterceptor(tokenServer, rsaKey); + OkHttpClient client = new OkHttpClient.Builder() + .addInterceptor(authInterceptor.dpopRetryInterceptor()) + .build(); + + Response response = client.newCall(new Request.Builder() + .url(kasServer.url("/kas/rewrap")) + .post(okhttp3.RequestBody.create(new byte[0])) + .build()).execute(); + response.close(); + + assertThat(kasServer.getRequestCount()).isEqualTo(1); + assertThat(response.code()).isEqualTo(401); + } + } + + @Test + void noRetryOn401WithNonDpopChallenge() throws Exception { + // WWW-Authenticate: Basic must not trigger a DPoP retry even if DPoP-Nonce is present. + RSAKey rsaKey = new RSAKeyGenerator(2048) + .keyUse(KeyUse.SIGNATURE) + .keyID(UUID.randomUUID().toString()) + .generate(); + try (MockWebServer tokenServer = new MockWebServer(); + MockWebServer kasServer = new MockWebServer()) { + tokenServer.enqueue(new MockResponse() + .setBody(FAKE_TOKEN_RESPONSE) + .setHeader("Content-Type", "application/json")); + tokenServer.start(); + + kasServer.enqueue(new MockResponse() + .setResponseCode(401) + .addHeader("DPoP-Nonce", "spurious-nonce") + .addHeader("WWW-Authenticate", "Basic realm=\"x\"")); + kasServer.start(); + + AuthInterceptor authInterceptor = buildAuthInterceptor(tokenServer, rsaKey); + OkHttpClient client = new OkHttpClient.Builder() + .addInterceptor(authInterceptor.dpopRetryInterceptor()) + .build(); + + Response response = client.newCall(new Request.Builder() + .url(kasServer.url("/kas/rewrap")) + .post(okhttp3.RequestBody.create(new byte[0])) + .build()).execute(); + response.close(); + + assertThat(kasServer.getRequestCount()).isEqualTo(1); + assertThat(response.code()).isEqualTo(401); + } + } + + @Test + void noRetryOn401WithDpopErrorOtherThanUseDpopNonce() throws Exception { + // RFC 9449 §8 only signals retry on error=use_dpop_nonce. Other DPoP errors + // (invalid_token, insufficient_scope, etc.) must surface to the caller. + RSAKey rsaKey = new RSAKeyGenerator(2048) + .keyUse(KeyUse.SIGNATURE) + .keyID(UUID.randomUUID().toString()) + .generate(); + try (MockWebServer tokenServer = new MockWebServer(); + MockWebServer kasServer = new MockWebServer()) { + tokenServer.enqueue(new MockResponse() + .setBody(FAKE_TOKEN_RESPONSE) + .setHeader("Content-Type", "application/json")); + tokenServer.start(); + + kasServer.enqueue(new MockResponse() + .setResponseCode(401) + .addHeader("DPoP-Nonce", "fresh-nonce") + .addHeader("WWW-Authenticate", "DPoP error=\"invalid_token\"")); + kasServer.start(); + + AuthInterceptor authInterceptor = buildAuthInterceptor(tokenServer, rsaKey); + OkHttpClient client = new OkHttpClient.Builder() + .addInterceptor(authInterceptor.dpopRetryInterceptor()) + .build(); + + Response response = client.newCall(new Request.Builder() + .url(kasServer.url("/kas/rewrap")) + .post(okhttp3.RequestBody.create(new byte[0])) + .build()).execute(); + response.close(); + + assertThat(kasServer.getRequestCount()).isEqualTo(1); + assertThat(response.code()).isEqualTo(401); + } + } + + @Test + void rotatedNonceFromSuccessfulResponseIsCachedForNextRequest() throws Exception { + // RFC 9449 §8.1: any response (including 200) may rotate the nonce. The retry + // interceptor must pick that up so the *next* request picks it from the cache. + // Note: the retry interceptor itself does not stamp DPoP headers on the initial + // request — those come from the auth path that builds the request — so we + // verify cache population by querying the TokenSource directly afterward. + RSAKey rsaKey = new RSAKeyGenerator(2048) + .keyUse(KeyUse.SIGNATURE) + .keyID(UUID.randomUUID().toString()) + .generate(); + try (MockWebServer tokenServer = new MockWebServer(); + MockWebServer kasServer = new MockWebServer()) { + tokenServer.enqueue(new MockResponse() + .setBody(FAKE_TOKEN_RESPONSE) + .setHeader("Content-Type", "application/json")); + tokenServer.start(); + + kasServer.enqueue(new MockResponse() + .setResponseCode(200) + .addHeader("DPoP-Nonce", "rotated-nonce")); + kasServer.start(); + + TokenSource ts = buildTokenSource(tokenServer, rsaKey); + OkHttpClient client = new OkHttpClient.Builder() + .addInterceptor(new AuthInterceptor(ts).dpopRetryInterceptor()) + .build(); + + client.newCall(new Request.Builder() + .url(kasServer.url("/kas/rewrap")) + .post(okhttp3.RequestBody.create(new byte[0])) + .build()).execute().close(); + + TokenSource.AuthHeaders headers = ts.getAuthHeaders( + kasServer.url("/kas/rewrap").url(), "POST"); + String nonceClaim = SignedJWT.parse(headers.getDpopHeader()) + .getJWTClaimsSet().getStringClaim("nonce"); + assertThat(nonceClaim).isEqualTo("rotated-nonce"); + } + } + @Test void noRetryOnSuccessResponse() throws Exception { RSAKey rsaKey = new RSAKeyGenerator(2048) From 08454ca93ad10946d3d8884bdce9cc64640a7b54 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Tue, 16 Jun 2026 14:39:44 -0400 Subject: [PATCH 13/24] fix(sdk): tighten TokenSource error handling and JWK validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Validate DPoP JWK/JWS-algorithm compatibility at TokenSource construction (new DpopKeyValidation helper, also used by SDKBuilder for EC curve→algorithm inference): RSA keys require RS*/PS*, EC keys must match curve to ES* (P-256↔ES256, P-384↔ES384, P-521↔ES512), other JWK types are rejected. Mismatches now fail fast at build time instead of at first proof. - Replace the broad catch(Exception) in getToken() with specific catches: SDKException passes through unwrapped (no double-wrapping of use_dpop_nonce errors), IOException/JOSEException/ParseException/ MalformedURLException each get a distinct, endpoint-named message. - Reject a 200 response with no access_token as a defensive guard against a null token being cached and producing 'DPoP null' headers. - Include the token endpoint URI in the use_dpop_nonce-missing-header warn log and promote the unknown-token-type log from trace to warn, since both indicate AS protocol violations. - Cover EC→RSA SRT key separation and the inverse RSA-reuse case with SDKBuilderTest cases. The EC path was previously untested even though it's the load-bearing motivation for the JWK generalization. Tests cover: nonce origin keying by port/scheme/default-port, parse errors attributed to the token endpoint, three JWK/algorithm mismatch cases, and EC↔RSA SRT signer behavior. --- .../platform/sdk/DpopKeyValidation.java | 55 +++++++ .../io/opentdf/platform/sdk/SDKBuilder.java | 10 +- .../io/opentdf/platform/sdk/TokenSource.java | 28 +++- .../opentdf/platform/sdk/SDKBuilderTest.java | 95 +++++++++++ .../opentdf/platform/sdk/TokenSourceTest.java | 154 ++++++++++++++++++ 5 files changed, 333 insertions(+), 9 deletions(-) create mode 100644 sdk/src/main/java/io/opentdf/platform/sdk/DpopKeyValidation.java diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/DpopKeyValidation.java b/sdk/src/main/java/io/opentdf/platform/sdk/DpopKeyValidation.java new file mode 100644 index 00000000..5022c589 --- /dev/null +++ b/sdk/src/main/java/io/opentdf/platform/sdk/DpopKeyValidation.java @@ -0,0 +1,55 @@ +package io.opentdf.platform.sdk; + +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.RSAKey; + +final class DpopKeyValidation { + private DpopKeyValidation() { + } + + static void validate(JWK jwk, JWSAlgorithm alg) { + if (jwk == null) { + throw new IllegalArgumentException("DPoP JWK cannot be null"); + } + if (alg == null) { + throw new IllegalArgumentException("DPoP algorithm cannot be null"); + } + if (jwk instanceof RSAKey) { + if (!isRsaAlgorithm(alg)) { + throw new IllegalArgumentException("DPoP algorithm " + alg + + " is not compatible with an RSA key; expected one of RS256/RS384/RS512 or PS256/PS384/PS512"); + } + } else if (jwk instanceof ECKey) { + JWSAlgorithm expected = inferEcAlgorithm(((ECKey) jwk).getCurve()); + if (!alg.equals(expected)) { + throw new IllegalArgumentException("DPoP algorithm " + alg + + " is not compatible with EC key on curve " + ((ECKey) jwk).getCurve() + + "; expected " + expected); + } + } else { + throw new IllegalArgumentException("Unsupported JWK type for DPoP: " + jwk.getKeyType() + + "; expected RSA or EC"); + } + } + + static JWSAlgorithm inferEcAlgorithm(Curve curve) { + if (Curve.P_256.equals(curve)) { + return JWSAlgorithm.ES256; + } + if (Curve.P_384.equals(curve)) { + return JWSAlgorithm.ES384; + } + if (Curve.P_521.equals(curve)) { + return JWSAlgorithm.ES512; + } + throw new IllegalArgumentException("Unsupported EC curve for DPoP: " + curve); + } + + private static boolean isRsaAlgorithm(JWSAlgorithm alg) { + return JWSAlgorithm.RS256.equals(alg) || JWSAlgorithm.RS384.equals(alg) || JWSAlgorithm.RS512.equals(alg) + || JWSAlgorithm.PS256.equals(alg) || JWSAlgorithm.PS384.equals(alg) || JWSAlgorithm.PS512.equals(alg); + } +} diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/SDKBuilder.java b/sdk/src/main/java/io/opentdf/platform/sdk/SDKBuilder.java index fccc82f8..dee26790 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/SDKBuilder.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/SDKBuilder.java @@ -522,10 +522,10 @@ X509TrustManager getTrustManager() { } private static JWSAlgorithm inferEcAlgorithm(ECKey ecKey) { - Curve curve = ecKey.getCurve(); - if (Curve.P_256.equals(curve)) return JWSAlgorithm.ES256; - if (Curve.P_384.equals(curve)) return JWSAlgorithm.ES384; - if (Curve.P_521.equals(curve)) return JWSAlgorithm.ES512; - throw new SDKException("Unsupported EC curve for DPoP: " + curve); + try { + return DpopKeyValidation.inferEcAlgorithm(ecKey.getCurve()); + } catch (IllegalArgumentException e) { + throw new SDKException(e.getMessage(), e); + } } } diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/TokenSource.java b/sdk/src/main/java/io/opentdf/platform/sdk/TokenSource.java index c79b682c..625b8653 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/TokenSource.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/TokenSource.java @@ -20,6 +20,8 @@ import org.slf4j.LoggerFactory; import javax.net.ssl.SSLSocketFactory; +import java.io.IOException; +import java.net.MalformedURLException; import java.net.URI; import java.net.URISyntaxException; import java.net.URL; @@ -54,6 +56,7 @@ class TokenSource { * @param sslSocketFactory Optional SSLSocketFactory for token endpoint requests */ public TokenSource(ClientAuthentication clientAuth, JWK dpopJwk, JWSAlgorithm dpopAlg, URI tokenEndpointURI, AuthorizationGrant authzGrant, SSLSocketFactory sslSocketFactory) { + DpopKeyValidation.validate(dpopJwk, dpopAlg); this.clientAuth = clientAuth; this.dpopJwk = dpopJwk; this.dpopAlg = dpopAlg; @@ -211,7 +214,8 @@ private synchronized AccessToken getToken() { cacheNonce(tokenEndpointUrl, rotatedNonce); } } else { - logger.warn("server returned use_dpop_nonce but did not supply a DPoP-Nonce response header"); + logger.warn("token endpoint {} returned use_dpop_nonce but did not supply a DPoP-Nonce response header", + tokenEndpointURI); } } if (!tokenResponse.indicatesSuccess()) { @@ -228,10 +232,15 @@ private synchronized AccessToken getToken() { } else if (tokens.getAccessToken() != null) { logger.trace("retrieved a new access token"); } else { - logger.trace("got an access token of unknown type"); + logger.warn("token endpoint {} returned a success response with an unknown access token type", + tokenEndpointURI); } this.token = tokens.getAccessToken(); + if (this.token == null) { + throw new SDKException("token endpoint " + tokenEndpointURI + + " returned a success response with no access token"); + } if (token.getLifetime() != 0) { this.tokenExpiryTime = Instant.now().plusSeconds(token.getLifetime() / 3); @@ -242,8 +251,19 @@ private synchronized AccessToken getToken() { return this.token; } - } catch (Exception e) { - throw new SDKException("failed to get token", e); + } catch (SDKException e) { + // Already shaped for the caller — don't double-wrap. + throw e; + } catch (MalformedURLException e) { + throw new SDKException("invalid token endpoint URL: " + tokenEndpointURI, e); + } catch (IOException e) { + throw new SDKException("network error contacting token endpoint " + tokenEndpointURI, e); + } catch (JOSEException e) { + throw new SDKException("DPoP proof generation failed for token endpoint " + tokenEndpointURI, e); + } catch (com.nimbusds.oauth2.sdk.ParseException e) { + throw new SDKException("malformed token response from " + tokenEndpointURI, e); + } catch (RuntimeException e) { + throw new SDKException("unexpected error fetching token from " + tokenEndpointURI, e); } return this.token; } diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/SDKBuilderTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/SDKBuilderTest.java index b24dcbd8..0cf07476 100644 --- a/sdk/src/test/java/io/opentdf/platform/sdk/SDKBuilderTest.java +++ b/sdk/src/test/java/io/opentdf/platform/sdk/SDKBuilderTest.java @@ -198,6 +198,101 @@ public String alg() { } } + @Test + void ecDpopKeyAutoGeneratesRsaSrtSigner() throws Exception { + // When the caller supplies an EC DPoP key, the SDK must auto-generate a separate + // RSA-2048 key for SRT signing because DefaultSrtSigner uses RSASSASigner which + // rejects non-RSA keys. Without this separation, build() would throw inside + // DefaultSrtSigner's constructor. + WellKnownServiceGrpc.WellKnownServiceImplBase wellKnownService = new WellKnownServiceGrpc.WellKnownServiceImplBase() { + @Override + public void getWellKnownConfiguration(GetWellKnownConfigurationRequest request, + StreamObserver responseObserver) { + responseObserver.onNext(GetWellKnownConfigurationResponse.getDefaultInstance()); + responseObserver.onCompleted(); + } + }; + + Server platformServices = null; + try { + platformServices = ServerBuilder + .forPort(getRandomPort()) + .directExecutor() + .addService(wellKnownService) + .build() + .start(); + + com.nimbusds.jose.jwk.ECKey ecDpopKey = new com.nimbusds.jose.jwk.gen.ECKeyGenerator(com.nimbusds.jose.jwk.Curve.P_256) + .keyUse(com.nimbusds.jose.jwk.KeyUse.SIGNATURE) + .keyID(java.util.UUID.randomUUID().toString()) + .generate(); + + var sdk = SDKBuilder.newBuilder() + .clientSecret("user", "password") + .platformEndpoint("http://localhost:" + platformServices.getPort()) + .useInsecurePlaintextConnection(true) + .protocol(ProtocolType.GRPC) + .dpopKey(ecDpopKey) + .build(); + + assertThat(sdk.getSrtSigner()).isPresent(); + assertThat(sdk.getSrtSigner().get().alg()).isEqualTo("RS256"); + // Sanity-check: the SRT signer can actually sign, which would fail if it was + // mistakenly handed the EC key (RSASSASigner constructor would have thrown). + byte[] signed = sdk.getSrtSigner().get().sign(new byte[]{1, 2, 3}); + assertThat(signed).isNotEmpty(); + } finally { + if (platformServices != null) { + platformServices.shutdownNow(); + } + } + } + + @Test + void rsaDpopKeyReusesSameKeyForSrt() throws Exception { + // When the caller supplies an RSA DPoP key, SDKBuilder reuses it for SRT signing + // (no second RSA key is generated). This test pins that behavior so a regression + // that splits the keys (and burns a key-generation per build) is caught. + WellKnownServiceGrpc.WellKnownServiceImplBase wellKnownService = new WellKnownServiceGrpc.WellKnownServiceImplBase() { + @Override + public void getWellKnownConfiguration(GetWellKnownConfigurationRequest request, + StreamObserver responseObserver) { + responseObserver.onNext(GetWellKnownConfigurationResponse.getDefaultInstance()); + responseObserver.onCompleted(); + } + }; + + Server platformServices = null; + try { + platformServices = ServerBuilder + .forPort(getRandomPort()) + .directExecutor() + .addService(wellKnownService) + .build() + .start(); + + com.nimbusds.jose.jwk.RSAKey rsaDpopKey = new com.nimbusds.jose.jwk.gen.RSAKeyGenerator(2048) + .keyUse(com.nimbusds.jose.jwk.KeyUse.SIGNATURE) + .keyID(java.util.UUID.randomUUID().toString()) + .generate(); + + var sdk = SDKBuilder.newBuilder() + .clientSecret("user", "password") + .platformEndpoint("http://localhost:" + platformServices.getPort()) + .useInsecurePlaintextConnection(true) + .protocol(ProtocolType.GRPC) + .dpopKey(rsaDpopKey) + .build(); + + assertThat(sdk.getSrtSigner()).isPresent(); + assertThat(sdk.getSrtSigner().get().alg()).isEqualTo("RS256"); + } finally { + if (platformServices != null) { + platformServices.shutdownNow(); + } + } + } + void sdkServicesSetup(boolean useSSLPlatform, boolean useSSLIDP) throws Exception { HeldCertificate rootCertificate = new HeldCertificate.Builder() diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/TokenSourceTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/TokenSourceTest.java index 15dc70b3..91304eb7 100644 --- a/sdk/src/test/java/io/opentdf/platform/sdk/TokenSourceTest.java +++ b/sdk/src/test/java/io/opentdf/platform/sdk/TokenSourceTest.java @@ -3,6 +3,7 @@ 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.RSAKey; import com.nimbusds.jose.jwk.gen.ECKeyGenerator; @@ -187,6 +188,90 @@ void noncesAreIsolatedByOrigin() throws Exception { } } + @Test + void noncesAreIsolatedByPort() throws Exception { + RSAKey rsaKey = new RSAKeyGenerator(2048) + .keyUse(KeyUse.SIGNATURE) + .keyID(UUID.randomUUID().toString()) + .generate(); + try (MockWebServer tokenServer = new MockWebServer()) { + for (int i = 0; i < 2; i++) { + tokenServer.enqueue(new MockResponse() + .setBody(FAKE_TOKEN_RESPONSE) + .setHeader("Content-Type", "application/json")); + } + tokenServer.start(); + + TokenSource ts = buildTokenSource(tokenServer, rsaKey); + URL port8080 = new URL("https://kas.example.com:8080/kas"); + URL port9090 = new URL("https://kas.example.com:9090/kas"); + + ts.cacheNonce(port8080, "nonce-8080"); + TokenSource.AuthHeaders headers8080 = ts.getAuthHeaders(port8080, "POST"); + TokenSource.AuthHeaders headers9090 = ts.getAuthHeaders(port9090, "POST"); + + assertThat(SignedJWT.parse(headers8080.getDpopHeader()) + .getJWTClaimsSet().getStringClaim("nonce")).isEqualTo("nonce-8080"); + assertThat(SignedJWT.parse(headers9090.getDpopHeader()) + .getJWTClaimsSet().getStringClaim("nonce")).isNull(); + } + } + + @Test + void noncesAreIsolatedByScheme() throws Exception { + RSAKey rsaKey = new RSAKeyGenerator(2048) + .keyUse(KeyUse.SIGNATURE) + .keyID(UUID.randomUUID().toString()) + .generate(); + try (MockWebServer tokenServer = new MockWebServer()) { + for (int i = 0; i < 2; i++) { + tokenServer.enqueue(new MockResponse() + .setBody(FAKE_TOKEN_RESPONSE) + .setHeader("Content-Type", "application/json")); + } + tokenServer.start(); + + TokenSource ts = buildTokenSource(tokenServer, rsaKey); + URL httpsUrl = new URL("https://kas.example.com/kas"); + URL httpUrl = new URL("http://kas.example.com/kas"); + + ts.cacheNonce(httpsUrl, "https-nonce"); + TokenSource.AuthHeaders headersHttps = ts.getAuthHeaders(httpsUrl, "POST"); + TokenSource.AuthHeaders headersHttp = ts.getAuthHeaders(httpUrl, "POST"); + + assertThat(SignedJWT.parse(headersHttps.getDpopHeader()) + .getJWTClaimsSet().getStringClaim("nonce")).isEqualTo("https-nonce"); + assertThat(SignedJWT.parse(headersHttp.getDpopHeader()) + .getJWTClaimsSet().getStringClaim("nonce")).isNull(); + } + } + + @Test + void noncesShareCacheForImplicitAndExplicitDefaultPort() throws Exception { + // Pins the getDefaultPort() normalization in TokenSource.getOrigin — + // https://host and https://host:443 must share a cache entry. + RSAKey rsaKey = new RSAKeyGenerator(2048) + .keyUse(KeyUse.SIGNATURE) + .keyID(UUID.randomUUID().toString()) + .generate(); + try (MockWebServer tokenServer = new MockWebServer()) { + tokenServer.enqueue(new MockResponse() + .setBody(FAKE_TOKEN_RESPONSE) + .setHeader("Content-Type", "application/json")); + tokenServer.start(); + + TokenSource ts = buildTokenSource(tokenServer, rsaKey); + URL implicitPort = new URL("https://kas.example.com/kas"); + URL explicitPort = new URL("https://kas.example.com:443/kas"); + + ts.cacheNonce(explicitPort, "shared-nonce"); + TokenSource.AuthHeaders headers = ts.getAuthHeaders(implicitPort, "POST"); + + assertThat(SignedJWT.parse(headers.getDpopHeader()) + .getJWTClaimsSet().getStringClaim("nonce")).isEqualTo("shared-nonce"); + } + } + @Test void emptyNonceIsNotCached() throws Exception { RSAKey rsaKey = new RSAKeyGenerator(2048) @@ -276,6 +361,75 @@ void getToken_throwsDescriptiveErrorWhenUseDpopNonceLacksHeader() throws Excepti } } + @Test + void getToken_surfacesMalformedTokenResponseDistinctly() throws Exception { + // Pre-fix every token-fetch failure surfaced as "failed to get token" with the + // cause buried. After C3 the parse failure is attributed to the token endpoint. + RSAKey rsaKey = new RSAKeyGenerator(2048) + .keyUse(KeyUse.SIGNATURE) + .keyID(UUID.randomUUID().toString()) + .generate(); + try (MockWebServer tokenServer = new MockWebServer()) { + // 200 with non-JSON body trips ParseException inside nimbus. + tokenServer.enqueue(new MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody("this is not json")); + tokenServer.start(); + + TokenSource ts = buildTokenSource(tokenServer, rsaKey); + + assertThatThrownBy(() -> ts.getAuthHeaders(new URL("https://kas.example.com/kas"), "POST")) + .isInstanceOf(SDKException.class) + .hasMessageContaining("malformed token response") + .hasMessageContaining(tokenServer.url("/token").toString()); + } + } + + @Test + void constructor_rejectsRsaKeyWithEcAlgorithm() throws Exception { + RSAKey rsaKey = new RSAKeyGenerator(2048).keyUse(KeyUse.SIGNATURE) + .keyID(UUID.randomUUID().toString()).generate(); + assertThatThrownBy(() -> new TokenSource( + new ClientSecretBasic(new ClientID("c"), new Secret("s")), + rsaKey, JWSAlgorithm.ES256, + new URL("https://idp.example.com/token").toURI(), + new ClientCredentialsGrant(), null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("RSA") + .hasMessageContaining("ES256"); + } + + @Test + void constructor_rejectsEcKeyWithMismatchedCurveAlgorithm() throws Exception { + ECKey ecKey = new ECKeyGenerator(Curve.P_256).keyUse(KeyUse.SIGNATURE) + .keyID(UUID.randomUUID().toString()).generate(); + assertThatThrownBy(() -> new TokenSource( + new ClientSecretBasic(new ClientID("c"), new Secret("s")), + ecKey, JWSAlgorithm.ES384, + new URL("https://idp.example.com/token").toURI(), + new ClientCredentialsGrant(), null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("P-256") + .hasMessageContaining("ES384"); + } + + @Test + void constructor_rejectsUnsupportedJwkType() throws Exception { + // OKP type — parsed from a static JWK to avoid pulling in the Tink dependency + // that OctetKeyPairGenerator needs at runtime. + JWK okp = JWK.parse("{\"kty\":\"OKP\",\"crv\":\"Ed25519\"," + + "\"x\":\"11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo\"," + + "\"d\":\"nWGxne_9WmC6hEr0kuwsxERJxWl7MmkZcDusAxyuf2A\"}"); + assertThatThrownBy(() -> new TokenSource( + new ClientSecretBasic(new ClientID("c"), new Secret("s")), + okp, JWSAlgorithm.EdDSA, + new URL("https://idp.example.com/token").toURI(), + new ClientCredentialsGrant(), null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Unsupported JWK type"); + } + @Test void getToken_usesProactivelyCachedNonce() throws Exception { RSAKey rsaKey = new RSAKeyGenerator(2048) From f4836252ab9cc2cfc89baa8111477a523110ad9c Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Tue, 16 Jun 2026 14:39:54 -0400 Subject: [PATCH 14/24] fix(cmdline): restore fast-fail validation for credential options The --client-id/--client-secret/--platform-endpoint flags were demoted from required=true so that 'tdf supports ' (used by xtest harnesses to probe SDK capabilities) could run without credentials. The side effect was that 'tdf encrypt' and 'tdf decrypt' silently passed picocli validation and failed deep inside SDKBuilder.build() with an opaque error. Keep the picocli annotations optional and instead enforce in buildSDK() with picocli's ParameterException, which produces the standard 'Missing required option: ...' error + usage and exits with code 2. 'tdf supports' is unaffected since it does not call buildSDK(). --- .../java/io/opentdf/platform/Command.java | 24 +++++++++++++++++++ .../java/io/opentdf/platform/CommandTest.java | 23 ++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/cmdline/src/main/java/io/opentdf/platform/Command.java b/cmdline/src/main/java/io/opentdf/platform/Command.java index cc569110..4e1d2d90 100644 --- a/cmdline/src/main/java/io/opentdf/platform/Command.java +++ b/cmdline/src/main/java/io/opentdf/platform/Command.java @@ -71,6 +71,12 @@ 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 { @CommandLine.Parameters(index = "0", description = "Feature to check (e.g., dpop)") @@ -288,6 +294,24 @@ void encrypt( } private SDK buildSDK() { + // The picocli @Option annotations on platformEndpoint/clientId/clientSecret are + // intentionally NOT marked required = true so that `tdf supports ` 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='"); + } + if (clientId == null || clientId.isEmpty()) { + throw new CommandLine.ParameterException(spec.commandLine(), + "Missing required option: '--client-id='"); + } + if (clientSecret == null || clientSecret.isEmpty()) { + throw new CommandLine.ParameterException(spec.commandLine(), + "Missing required option: '--client-secret='"); + } + SDKBuilder builder = new SDKBuilder(); if (insecure) { builder.insecureSslFactory(); diff --git a/cmdline/src/test/java/io/opentdf/platform/CommandTest.java b/cmdline/src/test/java/io/opentdf/platform/CommandTest.java index b6cf2bb1..347cfac0 100644 --- a/cmdline/src/test/java/io/opentdf/platform/CommandTest.java +++ b/cmdline/src/test/java/io/opentdf/platform/CommandTest.java @@ -3,6 +3,9 @@ import org.junit.jupiter.api.Test; import picocli.CommandLine; +import java.io.PrintWriter; +import java.io.StringWriter; + import static org.assertj.core.api.Assertions.assertThat; class CommandTest { @@ -24,4 +27,24 @@ void supports_unknown_feature_exits_1() { int code = new CommandLine(new Command()).execute("supports", "unknown_feature"); assertThat(code).isEqualTo(1); } + + @Test + void encrypt_withoutCredentials_failsWithMissingPlatformEndpoint() { + 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"); + + // Picocli exit code for ParameterException is USAGE (2). + assertThat(code).isEqualTo(CommandLine.ExitCode.USAGE); + assertThat(err.toString()).contains("Missing required option: '--platform-endpoint='"); + } + + @Test + void supports_withoutCredentials_stillExits0() { + // Regression sentinel: tdf supports must not require --client-id/--client-secret/--platform-endpoint. + int code = new CommandLine(new Command()).execute("supports", "dpop"); + assertThat(code).isEqualTo(0); + } } From 61056526adb72fe00275fb3b752d6106f92ca08b Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Tue, 16 Jun 2026 14:46:27 -0400 Subject: [PATCH 15/24] fix(cmdline): print stack trace for failures that escape picocli MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Picocli's default execution handler prints only ex.getMessage(), which is null for many failure modes (NPE, etc.). Exceptions thrown during CommandLine construction or by picocli itself bypass that handler entirely and would otherwise terminate the JVM silently. Wrap main() in a top-level try/catch that calls printStackTrace() — the first line is the exception's toString (class + message) so the failure category is always identifiable, and the stack trace gives diagnostic depth for bug reports. Normal exit codes from picocli's execute() are unaffected. --- cmdline/src/main/java/io/opentdf/platform/TDF.java | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/cmdline/src/main/java/io/opentdf/platform/TDF.java b/cmdline/src/main/java/io/opentdf/platform/TDF.java index 5d9a27c1..df92bcff 100644 --- a/cmdline/src/main/java/io/opentdf/platform/TDF.java +++ b/cmdline/src/main/java/io/opentdf/platform/TDF.java @@ -4,7 +4,17 @@ public class TDF { public static void main(String[] args) { - var result = new CommandLine(new Command()).execute(args); - System.exit(result); + try { + var result = new CommandLine(new Command()).execute(args); + System.exit(result); + } catch (Throwable t) { + // Belt-and-suspenders: picocli's default execution handler prints only + // getMessage(), which is null for many failure modes (NPE, etc.), and + // exceptions thrown during CommandLine construction or by picocli itself + // bypass that handler entirely. Print the class+message (toString) plus + // the stack trace so a failure is never silent. + t.printStackTrace(System.err); + System.exit(1); + } } } \ No newline at end of file From 33ca5410df1f1214bf12ae98b21063538cd1eba4 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Tue, 16 Jun 2026 15:32:21 -0400 Subject: [PATCH 16/24] Add verbose logging and DPoP retry exception logging - AuthInterceptor: log exception at DEBUG level when DPoP retry chain.proceed() throws - Command: -v/--verbose now raises root log level to DEBUG (only if currently coarser) - log4j2.xml: default root level changed from trace to info --- .../java/io/opentdf/platform/Command.java | 14 +++++++++++ .../main/java/io/opentdf/platform/TDF.java | 23 +++++++++---------- cmdline/src/main/resources/log4j2.xml | 2 +- .../java/io/opentdf/platform/CommandTest.java | 19 +++++++++++++++ .../opentdf/platform/sdk/AuthInterceptor.kt | 9 +++++++- 5 files changed, 53 insertions(+), 14 deletions(-) diff --git a/cmdline/src/main/java/io/opentdf/platform/Command.java b/cmdline/src/main/java/io/opentdf/platform/Command.java index 4e1d2d90..f360b888 100644 --- a/cmdline/src/main/java/io/opentdf/platform/Command.java +++ b/cmdline/src/main/java/io/opentdf/platform/Command.java @@ -47,6 +47,8 @@ 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; @@ -130,6 +132,18 @@ private Gson buildGson() { private static final String PEM_HEADER = "-----BEGIN (.*)-----"; private static final String PEM_FOOTER = "-----END (.*)-----"; + @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; diff --git a/cmdline/src/main/java/io/opentdf/platform/TDF.java b/cmdline/src/main/java/io/opentdf/platform/TDF.java index df92bcff..8ff9e88b 100644 --- a/cmdline/src/main/java/io/opentdf/platform/TDF.java +++ b/cmdline/src/main/java/io/opentdf/platform/TDF.java @@ -4,17 +4,16 @@ public class TDF { public static void main(String[] args) { - try { - var result = new CommandLine(new Command()).execute(args); - System.exit(result); - } catch (Throwable t) { - // Belt-and-suspenders: picocli's default execution handler prints only - // getMessage(), which is null for many failure modes (NPE, etc.), and - // exceptions thrown during CommandLine construction or by picocli itself - // bypass that handler entirely. Print the class+message (toString) plus - // the stack trace so a failure is never silent. - t.printStackTrace(System.err); - System.exit(1); - } + 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)); } } \ No newline at end of file diff --git a/cmdline/src/main/resources/log4j2.xml b/cmdline/src/main/resources/log4j2.xml index da185a60..a025e82a 100644 --- a/cmdline/src/main/resources/log4j2.xml +++ b/cmdline/src/main/resources/log4j2.xml @@ -6,7 +6,7 @@ - + diff --git a/cmdline/src/test/java/io/opentdf/platform/CommandTest.java b/cmdline/src/test/java/io/opentdf/platform/CommandTest.java index 347cfac0..ef7ef15c 100644 --- a/cmdline/src/test/java/io/opentdf/platform/CommandTest.java +++ b/cmdline/src/test/java/io/opentdf/platform/CommandTest.java @@ -47,4 +47,23 @@ void supports_withoutCredentials_stillExits0() { int code = new CommandLine(new Command()).execute("supports", "dpop"); assertThat(code).isEqualTo(0); } + + @Test + void verbose_flag_accepted_by_supports() { + int code = new CommandLine(new Command()).execute("--verbose", "supports", "dpop"); + assertThat(code).isEqualTo(0); + } + + @Test + void verbose_short_flag_accepted_by_supports() { + int code = new CommandLine(new Command()).execute("-v", "supports", "dpop"); + assertThat(code).isEqualTo(0); + } + + @Test + void verbose_flag_sets_verbose_field() { + var command = new Command(); + new CommandLine(command).parseArgs("--verbose", "supports", "dpop"); + assertThat(command.verbose).isTrue(); + } } diff --git a/sdk/src/main/kotlin/io/opentdf/platform/sdk/AuthInterceptor.kt b/sdk/src/main/kotlin/io/opentdf/platform/sdk/AuthInterceptor.kt index 9c611f1e..b58af73f 100644 --- a/sdk/src/main/kotlin/io/opentdf/platform/sdk/AuthInterceptor.kt +++ b/sdk/src/main/kotlin/io/opentdf/platform/sdk/AuthInterceptor.kt @@ -5,9 +5,11 @@ import com.connectrpc.StreamFunction import com.connectrpc.UnaryFunction import com.connectrpc.http.UnaryHTTPRequest import com.connectrpc.http.clone +import org.slf4j.LoggerFactory import java.net.URL internal class AuthInterceptor(private val ts: TokenSource) : Interceptor { + private val logger = LoggerFactory.getLogger(AuthInterceptor::class.java) // The connect-kotlin Interceptor API exposes no per-call context to thread the // request URL into responseFunction. ThreadLocal is the workaround, relying on // connect-kotlin's contract that requestFunction and responseFunction for a single @@ -105,7 +107,12 @@ internal class AuthInterceptor(private val ts: TokenSource) : Interceptor { .header("Authorization", authHeaders.authHeader) .header("DPoP", authHeaders.dpopHeader) .build() - response = chain.proceed(newRequest) + response = try { + chain.proceed(newRequest) + } catch (e: Exception) { + logger.debug("DPoP retry request to {} failed", url, e) + throw e + } cacheNonceIfPresent(url, response) } } From 38ac01f1b4376d092bb410ff55335f72a5e7795f Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Tue, 16 Jun 2026 15:44:14 -0400 Subject: [PATCH 17/24] Fall back to Bearer scheme when AS returns non-DPoP-bound token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If Keycloak (or any AS) returns token_type=Bearer despite the SDK sending a DPoP proof, the prior behavior was to emit "Authorization: DPoP " which misuses the scheme (RFC 9449 §7.1) and is rejected by any DPoP-enforcing resource server. TokenSource now remembers the scheme the AS declared (DPoP vs Bearer) and getAuthHeaders() emits a plain Bearer credential without a DPoP proof on downgrade. AuthInterceptor only sets the DPoP request header when a proof is present. A single WARN is logged on downgrade to flag the IdP misconfiguration. --- .../io/opentdf/platform/sdk/TokenSource.java | 25 +++++++++++++-- .../opentdf/platform/sdk/AuthInterceptor.kt | 10 +++--- .../sdk/DPoPRetryInterceptorTest.java | 2 +- .../opentdf/platform/sdk/TokenSourceTest.java | 31 ++++++++++++++++++- 4 files changed, 59 insertions(+), 9 deletions(-) diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/TokenSource.java b/sdk/src/main/java/io/opentdf/platform/sdk/TokenSource.java index 625b8653..d141e1aa 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/TokenSource.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/TokenSource.java @@ -19,6 +19,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import javax.annotation.Nullable; import javax.net.ssl.SSLSocketFactory; import java.io.IOException; import java.net.MalformedURLException; @@ -34,8 +35,12 @@ * timeouts and creating OIDC calls. It is thread-safe. */ class TokenSource { + static final String SCHEME_DPOP = "DPoP"; + static final String SCHEME_BEARER = "Bearer"; + private Instant tokenExpiryTime; private AccessToken token; + private String tokenScheme; private final ClientAuthentication clientAuth; private final JWK dpopJwk; private final JWSAlgorithm dpopAlg; @@ -68,9 +73,10 @@ public TokenSource(ClientAuthentication clientAuth, JWK dpopJwk, JWSAlgorithm dp class AuthHeaders { private final String authHeader; + @Nullable private final String dpopHeader; - public AuthHeaders(String authHeader, String dpopHeader) { + public AuthHeaders(String authHeader, @Nullable String dpopHeader) { this.authHeader = authHeader; this.dpopHeader = dpopHeader; } @@ -79,6 +85,7 @@ public String getAuthHeader() { return authHeader; } + @Nullable public String getDpopHeader() { return dpopHeader; } @@ -100,6 +107,13 @@ public AuthHeaders getAuthHeaders(URL url, String method, String nonce) { // Get the access token AccessToken t = getToken(); + // If the AS returned a plain bearer token, send it as a bearer credential + // without a DPoP proof. Sending "Authorization: DPoP " is a misuse + // of the scheme and resource servers that enforce DPoP will reject it. + if (SCHEME_BEARER.equals(tokenScheme)) { + return new AuthHeaders("Bearer " + t.getValue(), null); + } + // Build the DPoP proof for each request String dpopProof; try { @@ -227,7 +241,8 @@ private synchronized AccessToken getToken() { } var tokens = tokenResponse.toSuccessResponse().getTokens(); - if (tokens.getDPoPAccessToken() != null) { + boolean asAssertsDpop = tokens.getDPoPAccessToken() != null; + if (asAssertsDpop) { logger.trace("retrieved a new DPoP access token"); } else if (tokens.getAccessToken() != null) { logger.trace("retrieved a new access token"); @@ -241,6 +256,12 @@ private synchronized AccessToken getToken() { throw new SDKException("token endpoint " + tokenEndpointURI + " returned a success response with no access token"); } + this.tokenScheme = asAssertsDpop ? SCHEME_DPOP : SCHEME_BEARER; + if (!asAssertsDpop) { + logger.warn("token endpoint {} returned a non-DPoP-bound access token (token_type=Bearer) despite" + + " DPoP proof — falling back to Bearer scheme. Check the IdP DPoP configuration.", + tokenEndpointURI); + } if (token.getLifetime() != 0) { this.tokenExpiryTime = Instant.now().plusSeconds(token.getLifetime() / 3); diff --git a/sdk/src/main/kotlin/io/opentdf/platform/sdk/AuthInterceptor.kt b/sdk/src/main/kotlin/io/opentdf/platform/sdk/AuthInterceptor.kt index b58af73f..fd972333 100644 --- a/sdk/src/main/kotlin/io/opentdf/platform/sdk/AuthInterceptor.kt +++ b/sdk/src/main/kotlin/io/opentdf/platform/sdk/AuthInterceptor.kt @@ -25,7 +25,7 @@ internal class AuthInterceptor(private val ts: TokenSource) : Interceptor { val requestHeaders = mutableMapOf>() val authHeaders = ts.getAuthHeaders(request.url, "POST") requestHeaders["Authorization"] = listOf(authHeaders.authHeader) - requestHeaders["DPoP"] = listOf(authHeaders.dpopHeader) + authHeaders.dpopHeader?.let { requestHeaders["DPoP"] = listOf(it) } return@StreamFunction request.clone( url = request.url, @@ -51,7 +51,7 @@ internal class AuthInterceptor(private val ts: TokenSource) : Interceptor { val requestHeaders = mutableMapOf>() val authHeaders = ts.getAuthHeaders(request.url, request.httpMethod.name) requestHeaders["Authorization"] = listOf(authHeaders.authHeader) - requestHeaders["DPoP"] = listOf(authHeaders.dpopHeader) + authHeaders.dpopHeader?.let { requestHeaders["DPoP"] = listOf(it) } UnaryHTTPRequest( url = request.url, @@ -103,10 +103,10 @@ internal class AuthInterceptor(private val ts: TokenSource) : Interceptor { response.close() ts.cacheNonce(url, dpopNonce) val authHeaders = ts.getAuthHeaders(url, chain.request().method) - val newRequest = chain.request().newBuilder() + val newRequestBuilder = chain.request().newBuilder() .header("Authorization", authHeaders.authHeader) - .header("DPoP", authHeaders.dpopHeader) - .build() + authHeaders.dpopHeader?.let { newRequestBuilder.header("DPoP", it) } + val newRequest = newRequestBuilder.build() response = try { chain.proceed(newRequest) } catch (e: Exception) { diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/DPoPRetryInterceptorTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/DPoPRetryInterceptorTest.java index c5df8d2c..4cdf80b6 100644 --- a/sdk/src/test/java/io/opentdf/platform/sdk/DPoPRetryInterceptorTest.java +++ b/sdk/src/test/java/io/opentdf/platform/sdk/DPoPRetryInterceptorTest.java @@ -32,7 +32,7 @@ class DPoPRetryInterceptorTest { private static final String FAKE_TOKEN_RESPONSE = - "{\"access_token\":\"test-token\",\"token_type\":\"Bearer\",\"expires_in\":3600}"; + "{\"access_token\":\"test-token\",\"token_type\":\"DPoP\",\"expires_in\":3600}"; private AuthInterceptor buildAuthInterceptor(MockWebServer tokenServer, RSAKey rsaKey) throws Exception { return new AuthInterceptor(buildTokenSource(tokenServer, rsaKey)); diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/TokenSourceTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/TokenSourceTest.java index 91304eb7..6bd43768 100644 --- a/sdk/src/test/java/io/opentdf/platform/sdk/TokenSourceTest.java +++ b/sdk/src/test/java/io/opentdf/platform/sdk/TokenSourceTest.java @@ -27,7 +27,10 @@ class TokenSourceTest { private static final String FAKE_TOKEN_RESPONSE = - "{\"access_token\":\"test-access-token\",\"token_type\":\"Bearer\",\"expires_in\":3600}"; + "{\"access_token\":\"test-access-token\",\"token_type\":\"DPoP\",\"expires_in\":3600}"; + + private static final String BEARER_TOKEN_RESPONSE = + "{\"access_token\":\"plain-bearer-token\",\"token_type\":\"Bearer\",\"expires_in\":3600}"; private TokenSource buildTokenSource(MockWebServer tokenServer, RSAKey rsaKey) throws Exception { return new TokenSource( @@ -457,4 +460,30 @@ void getToken_usesProactivelyCachedNonce() throws Exception { assertThat(nonceClaim).isEqualTo("proactive-nonce"); } } + + @Test + void getAuthHeaders_downgradesToBearerWhenTokenEndpointReturnsBearer() throws Exception { + // Keycloak realms with DPoP disabled return token_type=Bearer even when the client + // sent a DPoP proof. The SDK must not emit "Authorization: DPoP " — + // that scheme is reserved for DPoP-bound tokens (RFC 9449 §7.1) and a DPoP-enforcing + // resource server will reject it. Downgrade to Bearer and omit the proof. + RSAKey rsaKey = new RSAKeyGenerator(2048) + .keyUse(KeyUse.SIGNATURE) + .keyID(UUID.randomUUID().toString()) + .generate(); + try (MockWebServer tokenServer = new MockWebServer()) { + tokenServer.enqueue(new MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody(BEARER_TOKEN_RESPONSE)); + tokenServer.start(); + + TokenSource ts = buildTokenSource(tokenServer, rsaKey); + + TokenSource.AuthHeaders headers = ts.getAuthHeaders(new URL("https://kas.example.com/kas"), "POST"); + + assertThat(headers.getAuthHeader()).isEqualTo("Bearer plain-bearer-token"); + assertThat(headers.getDpopHeader()).isNull(); + } + } } From 16f16c912959232cce878ab81452550dc04c85fa Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Tue, 16 Jun 2026 16:09:40 -0400 Subject: [PATCH 18/24] fix(sdk): strip query and fragment from DPoP htu claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RFC 9449 §4.2 requires the htu claim to be the request URI without query and fragment, and Nimbus enforces it by throwing IllegalArgumentException("The HTTP URI (htu) must not have a query"). When the OkHttp dpopRetryInterceptor handed Nimbus a URL whose query string came from the caller (e.g. a KAS rewrap URL), proof creation blew up inside the OkHttp Dispatcher thread and surfaced as 'error getting kas servers'. Normalize every URI fed to DefaultDPoPProofFactory through a single htuOf() helper that strips both query and fragment. --- .../io/opentdf/platform/sdk/TokenSource.java | 25 +++++++++++++++---- .../opentdf/platform/sdk/TokenSourceTest.java | 25 +++++++++++++++++++ 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/TokenSource.java b/sdk/src/main/java/io/opentdf/platform/sdk/TokenSource.java index d141e1aa..f637950b 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/TokenSource.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/TokenSource.java @@ -126,10 +126,11 @@ public AuthHeaders getAuthHeaders(URL url, String method, String nonce) { } SignedJWT proof; + URI htu = htuOf(url.toURI()); if (nonce != null) { - proof = dpopFactory.createDPoPJWT(method, url.toURI(), t, new Nonce(nonce)); + proof = dpopFactory.createDPoPJWT(method, htu, t, new Nonce(nonce)); } else { - proof = dpopFactory.createDPoPJWT(method, url.toURI(), t); + proof = dpopFactory.createDPoPJWT(method, htu, t); } dpopProof = proof.serialize(); } catch (URISyntaxException e) { @@ -157,6 +158,19 @@ public void cacheNonce(URL url, String nonce) { } } + // RFC 9449 §4.2: the htu claim is the request URI with query and fragment removed. + // Nimbus rejects any URI carrying a query, so strip both before handing it off. + private static URI htuOf(URI uri) { + if (uri.getRawQuery() == null && uri.getRawFragment() == null) { + return uri; + } + try { + return new URI(uri.getScheme(), uri.getAuthority(), uri.getPath(), null, null); + } catch (URISyntaxException e) { + throw new SDKException("failed to normalize URI for DPoP htu claim: " + uri, e); + } + } + /** * Get the origin (scheme://host:port) from a URL for nonce caching. * @@ -194,9 +208,10 @@ private synchronized AccessToken getToken() { if (sslSocketFactory != null) { httpRequest.setSSLSocketFactory(sslSocketFactory); } + URI tokenHtu = htuOf(httpRequest.getURI()); SignedJWT proof = (cachedNonce != null) - ? dpopFactory.createDPoPJWT(httpRequest.getMethod().name(), httpRequest.getURI(), new Nonce(cachedNonce)) - : dpopFactory.createDPoPJWT(httpRequest.getMethod().name(), httpRequest.getURI()); + ? dpopFactory.createDPoPJWT(httpRequest.getMethod().name(), tokenHtu, new Nonce(cachedNonce)) + : dpopFactory.createDPoPJWT(httpRequest.getMethod().name(), tokenHtu); httpRequest.setDPoP(proof); HTTPResponse httpResponse = httpRequest.send(); @@ -217,7 +232,7 @@ private synchronized AccessToken getToken() { } SignedJWT retryProof = dpopFactory.createDPoPJWT( retryHttpRequest.getMethod().name(), - retryHttpRequest.getURI(), + htuOf(retryHttpRequest.getURI()), new Nonce(dpopNonce)); retryHttpRequest.setDPoP(retryProof); httpResponse = retryHttpRequest.send(); diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/TokenSourceTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/TokenSourceTest.java index 6bd43768..243fd357 100644 --- a/sdk/src/test/java/io/opentdf/platform/sdk/TokenSourceTest.java +++ b/sdk/src/test/java/io/opentdf/platform/sdk/TokenSourceTest.java @@ -126,6 +126,31 @@ void noNonceClaimWhenNoCachedNonce() throws Exception { } } + @Test + void htuClaimStripsQueryAndFragment() throws Exception { + // RFC 9449 §4.2: htu must omit query and fragment, and Nimbus rejects + // anything else with IllegalArgumentException. + RSAKey rsaKey = new RSAKeyGenerator(2048) + .keyUse(KeyUse.SIGNATURE) + .keyID(UUID.randomUUID().toString()) + .generate(); + try (MockWebServer tokenServer = new MockWebServer()) { + tokenServer.enqueue(new MockResponse() + .setBody(FAKE_TOKEN_RESPONSE) + .setHeader("Content-Type", "application/json")); + tokenServer.start(); + + TokenSource ts = buildTokenSource(tokenServer, rsaKey); + URL urlWithQuery = new URL("https://kas.example.com/kas?foo=bar&baz=qux#frag"); + + TokenSource.AuthHeaders headers = ts.getAuthHeaders(urlWithQuery, "POST"); + SignedJWT dpopJwt = SignedJWT.parse(headers.getDpopHeader()); + String htu = dpopJwt.getJWTClaimsSet().getStringClaim("htu"); + + assertThat(htu).isEqualTo("https://kas.example.com/kas"); + } + } + @Test void ecKeyGeneratesDPoPProof() throws Exception { ECKey ecKey = new ECKeyGenerator(Curve.P_256) From dac58d14a35d633a02f1a2b2e9cd0844bb55cfdc Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Tue, 16 Jun 2026 16:13:34 -0400 Subject: [PATCH 19/24] test(sdk): advertise DPoP token_type in SDKBuilder mock IdP After 38ac01f the SDK correctly downgrades to the Bearer scheme when the token endpoint returns token_type=Bearer, so the shared sdkServicesSetup helper stopped sending a DPoP header and the three tests calling it (testCreatingSDKServicesPlainText, testPlatformPlainTextAndIDPWithSSL, testSDKServicesWithTruststore) failed at the 'expecting DPoP header' assertion. The test intent is the DPoP path, so make the mock token endpoint advertise DPoP. --- sdk/src/test/java/io/opentdf/platform/sdk/SDKBuilderTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/SDKBuilderTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/SDKBuilderTest.java index 0cf07476..3b49b2df 100644 --- a/sdk/src/test/java/io/opentdf/platform/sdk/SDKBuilderTest.java +++ b/sdk/src/test/java/io/opentdf/platform/sdk/SDKBuilderTest.java @@ -462,7 +462,7 @@ public ServerCall.Listener interceptCall(ServerCall Date: Tue, 16 Jun 2026 18:17:19 -0400 Subject: [PATCH 20/24] docs(sdk): correct RFC 9449 section citations and remove stale plan/spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - §8.1 covers nonce *syntax*; the rotation/provision mechanic is §8.2. - The OkHttp dpopRetryInterceptor handles resource-server traffic (KAS, platform-services Connect client); that is RFC 9449 §9, not §8. - Delete the obsolete docs/superpowers/{plans,specs}/2026-06-16-* files: they describe a ~25-line, 3-file plan that no longer matches what shipped (2200+ lines, 14 files, new public API surface). --- .../plans/2026-06-16-dpop-nonce-challenge.md | 377 ------------------ .../2026-06-16-dpop-nonce-challenge-design.md | 109 ----- .../io/opentdf/platform/sdk/TokenSource.java | 6 +- .../opentdf/platform/sdk/AuthInterceptor.kt | 5 +- .../sdk/DPoPRetryInterceptorTest.java | 4 +- 5 files changed, 8 insertions(+), 493 deletions(-) delete mode 100644 docs/superpowers/plans/2026-06-16-dpop-nonce-challenge.md delete mode 100644 docs/superpowers/specs/2026-06-16-dpop-nonce-challenge-design.md diff --git a/docs/superpowers/plans/2026-06-16-dpop-nonce-challenge.md b/docs/superpowers/plans/2026-06-16-dpop-nonce-challenge.md deleted file mode 100644 index 03f3f895..00000000 --- a/docs/superpowers/plans/2026-06-16-dpop-nonce-challenge.md +++ /dev/null @@ -1,377 +0,0 @@ -# DPoP Nonce Challenge Support Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Make the Java SDK correctly handle DPoP nonce challenges from the authorization server (token endpoint) and declare `dpop_nonce_challenge` support, so that legacy TDF decryption and explicit DPoP nonce tests pass when the platform runs with `require_nonce: true`. - -**Architecture:** Add proactive nonce cache lookup and a one-shot retry to `TokenSource.getToken()` — when the token endpoint returns `error=use_dpop_nonce`, extract the `DPoP-Nonce` response header, cache it per-origin, rebuild the token request with the nonce in the DPoP proof, and retry once. Then declare support in `Command.java` and the xtest CLI shim. - -**Tech Stack:** Java 11, Nimbus oauth2-oidc-sdk 11.10.1, OkHttp MockWebServer (tests), JUnit 5, AssertJ, Picocli - ---- - -## File Map - -| File | Repo | Change | -|------|------|--------| -| `sdk/src/main/java/io/opentdf/platform/sdk/TokenSource.java` | java-sdk | Nonce lookup + retry in `getToken()` | -| `sdk/src/test/java/io/opentdf/platform/sdk/TokenSourceTest.java` | java-sdk | Two new tests: retry and proactive | -| `cmdline/src/main/java/io/opentdf/platform/Command.java` | java-sdk | Declare `dpop_nonce_challenge` supported | -| `cmdline/src/test/java/io/opentdf/platform/CommandTest.java` | java-sdk | New: test supports command exit codes | -| `xtest/sdk/java/cli.sh` | tests | Delegate `dpop_nonce_challenge` detection to binary | - ---- - -## Task 1: Token endpoint nonce retry in TokenSource - -**Files:** -- Modify: `sdk/src/main/java/io/opentdf/platform/sdk/TokenSource.java` (`getToken()` method, lines 162–216) -- Modify: `sdk/src/test/java/io/opentdf/platform/sdk/TokenSourceTest.java` - -### - [ ] Step 1: Write two failing tests - -Add these two tests to `TokenSourceTest.java`, after the `emptyNonceIsNotCached` test. The existing `buildTokenSource` helper and imports cover everything needed; add `RecordedRequest` to the imports. - -```java -// Add to imports: -import okhttp3.mockwebserver.RecordedRequest; -``` - -```java -@Test -void getToken_retriesWithNonceOnUseDpopNonce() throws Exception { - RSAKey rsaKey = new RSAKeyGenerator(2048) - .keyUse(KeyUse.SIGNATURE) - .keyID(UUID.randomUUID().toString()) - .generate(); - try (MockWebServer tokenServer = new MockWebServer()) { - // First: 401 use_dpop_nonce - tokenServer.enqueue(new MockResponse() - .setResponseCode(401) - .setHeader("Content-Type", "application/json") - .addHeader("DPoP-Nonce", "retry-nonce-abc") - .setBody("{\"error\":\"use_dpop_nonce\",\"error_description\":\"nonce required\"}")); - // Second: success - tokenServer.enqueue(new MockResponse() - .setResponseCode(200) - .setHeader("Content-Type", "application/json") - .setBody("{\"access_token\":\"real-token\",\"token_type\":\"DPoP\",\"expires_in\":3600}")); - tokenServer.start(); - - TokenSource ts = buildTokenSource(tokenServer, rsaKey); - URL resourceUrl = new URL("https://kas.example.com/kas"); - - TokenSource.AuthHeaders headers = ts.getAuthHeaders(resourceUrl, "POST"); - - assertThat(headers.getAuthHeader()).isEqualTo("DPoP real-token"); - assertThat(tokenServer.getRequestCount()).isEqualTo(2); - - RecordedRequest first = tokenServer.takeRequest(); - String firstNonce = SignedJWT.parse(first.getHeader("DPoP")) - .getJWTClaimsSet().getStringClaim("nonce"); - assertThat(firstNonce).isNull(); - - RecordedRequest second = tokenServer.takeRequest(); - String secondNonce = SignedJWT.parse(second.getHeader("DPoP")) - .getJWTClaimsSet().getStringClaim("nonce"); - assertThat(secondNonce).isEqualTo("retry-nonce-abc"); - } -} - -@Test -void getToken_usesProactivelyCachedNonce() throws Exception { - RSAKey rsaKey = new RSAKeyGenerator(2048) - .keyUse(KeyUse.SIGNATURE) - .keyID(UUID.randomUUID().toString()) - .generate(); - try (MockWebServer tokenServer = new MockWebServer()) { - tokenServer.enqueue(new MockResponse() - .setResponseCode(200) - .setHeader("Content-Type", "application/json") - .setBody("{\"access_token\":\"proactive-token\",\"token_type\":\"DPoP\",\"expires_in\":3600}")); - tokenServer.start(); - - TokenSource ts = buildTokenSource(tokenServer, rsaKey); - // Pre-seed the cache for the token endpoint origin - ts.cacheNonce(tokenServer.url("/token").url(), "proactive-nonce"); - - ts.getAuthHeaders(new URL("https://kas.example.com/kas"), "POST"); - - assertThat(tokenServer.getRequestCount()).isEqualTo(1); - - RecordedRequest request = tokenServer.takeRequest(); - String nonceClaim = SignedJWT.parse(request.getHeader("DPoP")) - .getJWTClaimsSet().getStringClaim("nonce"); - assertThat(nonceClaim).isEqualTo("proactive-nonce"); - } -} -``` - -### - [ ] Step 2: Run tests to confirm they fail - -```bash -cd /Users/dmihalcik/Documents/GitHub/worktrees/DSPX-3397-java-sdk/sdk -mvn test -Dtest=TokenSourceTest#getToken_retriesWithNonceOnUseDpopNonce+getToken_usesProactivelyCachedNonce -q -``` - -Expected: both tests FAIL. `getToken_retriesWithNonceOnUseDpopNonce` fails because `getToken()` throws `SDKException("failure to get token ... error code = [use_dpop_nonce]")` on the first 401. `getToken_usesProactivelyCachedNonce` fails because the token endpoint 401 is not expected (only one response is queued). - -### - [ ] Step 3: Implement the fix in TokenSource.getToken() - -Replace the body of the `if (token == null || isTokenExpired())` block in `TokenSource.java` (approximately lines 168–206). Keep the surrounding `try/catch` and `synchronized` untouched. - -The `Nonce` import is already present at line 18. No new imports needed. - -```java -if (token == null || isTokenExpired()) { - logger.trace("The current access token is expired or empty, getting a new one"); - - DPoPProofFactory dpopFactory = new DefaultDPoPProofFactory(dpopJwk, dpopAlg); - - // Proactively use any cached nonce for the token endpoint (RFC 9449 §8) - String cachedNonce = nonceCache.get(getOrigin(tokenEndpointURI.toURL())); - - TokenRequest tokenRequest = new TokenRequest(this.tokenEndpointURI, clientAuth, authzGrant, null); - HTTPRequest httpRequest = tokenRequest.toHTTPRequest(); - if (sslSocketFactory != null) { - httpRequest.setSSLSocketFactory(sslSocketFactory); - } - SignedJWT proof = (cachedNonce != null) - ? dpopFactory.createDPoPJWT(httpRequest.getMethod().name(), httpRequest.getURI(), new Nonce(cachedNonce)) - : dpopFactory.createDPoPJWT(httpRequest.getMethod().name(), httpRequest.getURI()); - httpRequest.setDPoP(proof); - - HTTPResponse httpResponse = httpRequest.send(); - TokenResponse tokenResponse = TokenResponse.parse(httpResponse); - - // RFC 9449 §8: if AS requires a nonce, cache it and retry once - if (!tokenResponse.indicatesSuccess()) { - ErrorObject error = tokenResponse.toErrorResponse().getErrorObject(); - if ("use_dpop_nonce".equals(error.getCode().getValue())) { - String dpopNonce = httpResponse.getHeaderValue("DPoP-Nonce"); - if (dpopNonce != null) { - cacheNonce(tokenEndpointURI.toURL(), dpopNonce); - TokenRequest retryRequest = new TokenRequest(tokenEndpointURI, clientAuth, authzGrant, null); - HTTPRequest retryHttpRequest = retryRequest.toHTTPRequest(); - if (sslSocketFactory != null) { - retryHttpRequest.setSSLSocketFactory(sslSocketFactory); - } - SignedJWT retryProof = dpopFactory.createDPoPJWT( - retryHttpRequest.getMethod().name(), - retryHttpRequest.getURI(), - new Nonce(dpopNonce)); - retryHttpRequest.setDPoP(retryProof); - httpResponse = retryHttpRequest.send(); - tokenResponse = TokenResponse.parse(httpResponse); - } - } - if (!tokenResponse.indicatesSuccess()) { - ErrorObject finalError = tokenResponse.toErrorResponse().getErrorObject(); - throw new SDKException("failure to get token. description = [" + finalError.getDescription() - + "] error code = [" + finalError.getCode() - + "] error uri = [" + finalError.getURI() + "]"); - } - } - - var tokens = tokenResponse.toSuccessResponse().getTokens(); - if (tokens.getDPoPAccessToken() != null) { - logger.trace("retrieved a new DPoP access token"); - } else if (tokens.getAccessToken() != null) { - logger.trace("retrieved a new access token"); - } else { - logger.trace("got an access token of unknown type"); - } - - this.token = tokens.getAccessToken(); - - if (token.getLifetime() != 0) { - this.tokenExpiryTime = Instant.now().plusSeconds(token.getLifetime() / 3); - } -} -``` - -### - [ ] Step 4: Run the full TokenSourceTest suite - -```bash -cd /Users/dmihalcik/Documents/GitHub/worktrees/DSPX-3397-java-sdk/sdk -mvn test -Dtest=TokenSourceTest -q -``` - -Expected: all tests PASS (the 6 existing + 2 new). - -### - [ ] Step 5: Commit - -```bash -cd /Users/dmihalcik/Documents/GitHub/worktrees/DSPX-3397-java-sdk -git add sdk/src/main/java/io/opentdf/platform/sdk/TokenSource.java \ - sdk/src/test/java/io/opentdf/platform/sdk/TokenSourceTest.java -git commit -m "feat(sdk): retry token request with nonce on use_dpop_nonce (RFC 9449 §8)" -``` - ---- - -## Task 2: Declare dpop_nonce_challenge in Command.java - -**Files:** -- Modify: `cmdline/src/main/java/io/opentdf/platform/Command.java` (line 81) -- Create: `cmdline/src/test/java/io/opentdf/platform/CommandTest.java` - -### - [ ] Step 6: Create the test file - -Create `cmdline/src/test/java/io/opentdf/platform/CommandTest.java`: - -```java -package io.opentdf.platform; - -import org.junit.jupiter.api.Test; -import picocli.CommandLine; - -import static org.assertj.core.api.Assertions.assertThat; - -class CommandTest { - - @Test - void supports_dpop_exits_0() { - int code = new CommandLine(new Command()).execute("supports", "dpop"); - assertThat(code).isEqualTo(0); - } - - @Test - void supports_dpop_nonce_challenge_exits_0() { - int code = new CommandLine(new Command()).execute("supports", "dpop_nonce_challenge"); - assertThat(code).isEqualTo(0); - } - - @Test - void supports_unknown_feature_exits_1() { - int code = new CommandLine(new Command()).execute("supports", "unknown_feature"); - assertThat(code).isEqualTo(1); - } -} -``` - -### - [ ] Step 7: Run test to confirm it fails - -```bash -cd /Users/dmihalcik/Documents/GitHub/worktrees/DSPX-3397-java-sdk/cmdline -mvn test -Dtest=CommandTest#supports_dpop_nonce_challenge_exits_0 -q -``` - -Expected: FAIL — exit code is 1, not 0. - -### - [ ] Step 8: Update Command.java line 81 - -Change the single line inside `Supports.call()`: - -```java -// Before: -return "dpop".equalsIgnoreCase(feature) ? 0 : 1; - -// After: -return ("dpop".equalsIgnoreCase(feature) || "dpop_nonce_challenge".equalsIgnoreCase(feature)) ? 0 : 1; -``` - -### - [ ] Step 9: Run CommandTest suite - -```bash -cd /Users/dmihalcik/Documents/GitHub/worktrees/DSPX-3397-java-sdk/cmdline -mvn test -Dtest=CommandTest -q -``` - -Expected: all 3 tests PASS. - -### - [ ] Step 10: Commit - -```bash -cd /Users/dmihalcik/Documents/GitHub/worktrees/DSPX-3397-java-sdk -git add cmdline/src/main/java/io/opentdf/platform/Command.java \ - cmdline/src/test/java/io/opentdf/platform/CommandTest.java -git commit -m "feat(cmdline): declare dpop_nonce_challenge support" -``` - ---- - -## Task 3: Update xtest cli.sh feature detection - -**Files:** -- Modify: `xtest/sdk/java/cli.sh` in the **tests repo** (`/Users/dmihalcik/Documents/GitHub/opentdf/tests`) - -### - [ ] Step 11: Update cli.sh - -In `xtest/sdk/java/cli.sh`, find the `dpop_nonce_challenge)` case (around line 115) and replace the hardcoded failure with a delegation to the binary: - -```bash -# Before: -dpop_nonce_challenge) - echo "dpop_nonce_challenge not supported" - exit 1 - ;; - -# After: -dpop_nonce_challenge) - java -jar "$SCRIPT_DIR"/cmdline.jar supports dpop_nonce_challenge - exit $? - ;; -``` - -### - [ ] Step 12: Smoke-test the detection locally - -After building the cmdline jar (`mvn package -pl cmdline -DskipTests -q` in the java-sdk worktree) and placing it where the test harness expects it: - -```bash -cd /Users/dmihalcik/Documents/GitHub/opentdf/tests/xtest/sdk/java -bash cli.sh dpop_nonce_challenge -echo "Exit code: $?" -``` - -Expected output: exit code `0`. - -### - [ ] Step 13: Commit in the tests repo - -```bash -cd /Users/dmihalcik/Documents/GitHub/opentdf/tests -git add xtest/sdk/java/cli.sh -git commit -m "feat(java-sdk): delegate dpop_nonce_challenge detection to binary" -``` - ---- - -## Task 4: Full test run - -### - [ ] Step 14: Run the full sdk module test suite - -```bash -cd /Users/dmihalcik/Documents/GitHub/worktrees/DSPX-3397-java-sdk/sdk -mvn test -q -``` - -Expected: all tests PASS, no regressions. - -### - [ ] Step 15: Build the cmdline jar - -```bash -cd /Users/dmihalcik/Documents/GitHub/worktrees/DSPX-3397-java-sdk -mvn package -DskipTests -q -``` - -Expected: `cmdline/target/cmdline-*.jar` produced with no errors. - -### - [ ] Step 16: Push java-sdk branch and trigger CI - -```bash -cd /Users/dmihalcik/Documents/GitHub/worktrees/DSPX-3397-java-sdk -git push origin DSPX-3397-java-sdk -``` - -Then re-trigger `xtest.yml` in the tests repo against the updated branches: - -```bash -gh workflow run xtest.yml \ - --repo opentdf/tests \ - --ref fix-dpop-nonce-challenge \ - --field platform-ref=DSPX-3397-platform-service \ - --field js-ref=DSPX-3397-web-sdk \ - --field java-ref=DSPX-3397-java-sdk -``` - -Expected: Java legacy tests (`test_decrypt_*`) pass; `test_dpop_server_issued_nonce_retry` and related nonce tests run and pass (no longer skipped). diff --git a/docs/superpowers/specs/2026-06-16-dpop-nonce-challenge-design.md b/docs/superpowers/specs/2026-06-16-dpop-nonce-challenge-design.md deleted file mode 100644 index 6341bcb4..00000000 --- a/docs/superpowers/specs/2026-06-16-dpop-nonce-challenge-design.md +++ /dev/null @@ -1,109 +0,0 @@ -# DPoP Nonce Challenge Support — Java SDK - -**Date:** 2026-06-16 -**Branch:** DSPX-3397-java-sdk - -## Problem - -When the platform runs with `server.auth.dpop.require_nonce: true`, every DPoP token -request without a nonce claim receives an HTTP 401 with `error=use_dpop_nonce` and a -`DPoP-Nonce` response header. `TokenSource.getToken()` currently throws an -`SDKException` on any non-success token response, so all authenticated operations -fail — including legacy TDF decryption — and the test matrix skips -`dpop_nonce_challenge` tests because `Command.java` and `cli.sh` declare the feature -unsupported. - -## Scope - -Three files, ~25 lines of change total. No new dependencies. No architectural changes. - -## Design - -### 1. `TokenSource.getToken()` — proactive lookup + one-shot retry - -**Proactive lookup (first attempt):** -Before generating the initial DPoP proof for the token endpoint, look up the nonce -cache by the token endpoint's origin: - -```java -String origin = getOrigin(tokenEndpointURI.toURL()); -String cachedNonce = nonceCache.get(origin); -SignedJWT proof = (cachedNonce != null) - ? dpopFactory.createDPoPJWT(method, uri, new Nonce(cachedNonce)) - : dpopFactory.createDPoPJWT(method, uri); -``` - -This mirrors what `getAuthHeaders()` already does for resource requests, so after the -first nonce handshake all future token refreshes succeed on the first try. - -**Retry on `use_dpop_nonce`:** -After `TokenResponse.parse(httpResponse)`, if the response is not a success and the -error code is `use_dpop_nonce`: - -1. Extract `DPoP-Nonce` from `httpResponse.getHeaderValue("DPoP-Nonce")`. -2. Call `cacheNonce(tokenEndpointURI.toURL(), nonce)`. -3. Rebuild a fresh `TokenRequest → HTTPRequest`, set the SSL factory, generate a new - proof using `createDPoPJWT(method, uri, new Nonce(nonce))` (the Nimbus 11.10.1 - `DPoPProofFactory` interface provides this signature), call `send()`. -4. Parse the response. Apply the normal success/failure check — no second retry. - -`getOrigin()` and `cacheNonce()` are existing package-private helpers on `TokenSource`; -no new methods needed. - -### 2. `Command.java` — declare `dpop_nonce_challenge` supported - -File: `cmdline/src/main/java/io/opentdf/platform/Command.java`, line 81. - -```java -// before -return "dpop".equalsIgnoreCase(feature) ? 0 : 1; - -// after -return ("dpop".equalsIgnoreCase(feature) - || "dpop_nonce_challenge".equalsIgnoreCase(feature)) ? 0 : 1; -``` - -### 3. `xtest/sdk/java/cli.sh` — delegate detection to the binary - -File: `xtest/sdk/java/cli.sh` (in the **tests repo**), lines 115-116. - -```bash -# before -dpop_nonce_challenge) - echo "dpop_nonce_challenge not supported" - exit 1 - ;; - -# after -dpop_nonce_challenge) - java -jar "$SCRIPT_DIR"/cmdline.jar supports dpop_nonce_challenge - exit $? - ;; -``` - -## Error handling - -- If the retry also fails (wrong nonce, network error, etc.), the existing - `SDKException` path applies — same message format as today. -- If `DPoP-Nonce` is absent from the 401 body (malformed server response), the - `use_dpop_nonce` branch falls through to the normal failure path without retrying. - -## Testing - -**Automated (CI):** Re-run the `xtest.yml` workflow against `DSPX-3397-platform-service` -with `dpop-challenge-enabled: true`. Previously-failing legacy tests (`test_decrypt_*`) -and previously-skipped nonce tests (`test_dpop_server_issued_nonce_retry`) should both -pass. - -**Unit test:** Add a test in `TokenSourceTest` (or the existing DPoP test class) that -mocks an HTTP token endpoint returning 401 + `use_dpop_nonce` + `DPoP-Nonce: ` -on the first call and 200 on the second, verifying the nonce is included in the retry -proof's JWT claims. - -## Files changed - -| File | Repo | Change | -|------|------|--------| -| `sdk/src/main/java/io/opentdf/platform/sdk/TokenSource.java` | java-sdk | Nonce lookup + retry in `getToken()` | -| `cmdline/src/main/java/io/opentdf/platform/Command.java` | java-sdk | Declare `dpop_nonce_challenge` supported | -| `xtest/sdk/java/cli.sh` | tests | Delegate detection to binary | diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/TokenSource.java b/sdk/src/main/java/io/opentdf/platform/sdk/TokenSource.java index f637950b..10ff0209 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/TokenSource.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/TokenSource.java @@ -199,7 +199,7 @@ private synchronized AccessToken getToken() { DPoPProofFactory dpopFactory = new DefaultDPoPProofFactory(dpopJwk, dpopAlg); - // Proactively use any cached nonce for the token endpoint origin (RFC 9449 §8) + // Proactively use any cached nonce for the token endpoint origin (RFC 9449 §8.2) URL tokenEndpointUrl = tokenEndpointURI.toURL(); String cachedNonce = nonceCache.get(getOrigin(tokenEndpointUrl)); @@ -218,7 +218,7 @@ private synchronized AccessToken getToken() { TokenResponse tokenResponse = TokenResponse.parse(httpResponse); - // RFC 9449 §8: if AS requires a nonce, cache it and retry once + // RFC 9449 §8.2: if AS requires a nonce, cache it and retry once if (!tokenResponse.indicatesSuccess()) { ErrorObject error = tokenResponse.toErrorResponse().getErrorObject(); if ("use_dpop_nonce".equals(error.getCode())) { @@ -237,7 +237,7 @@ private synchronized AccessToken getToken() { retryHttpRequest.setDPoP(retryProof); httpResponse = retryHttpRequest.send(); tokenResponse = TokenResponse.parse(httpResponse); - // Cache any nonce rotation from the AS (RFC 9449 §8.1) + // Cache any nonce rotation from the AS (RFC 9449 §8.2) String rotatedNonce = httpResponse.getHeaderValue("DPoP-Nonce"); if (rotatedNonce != null) { cacheNonce(tokenEndpointUrl, rotatedNonce); diff --git a/sdk/src/main/kotlin/io/opentdf/platform/sdk/AuthInterceptor.kt b/sdk/src/main/kotlin/io/opentdf/platform/sdk/AuthInterceptor.kt index fd972333..5fb8cb42 100644 --- a/sdk/src/main/kotlin/io/opentdf/platform/sdk/AuthInterceptor.kt +++ b/sdk/src/main/kotlin/io/opentdf/platform/sdk/AuthInterceptor.kt @@ -85,7 +85,8 @@ internal class AuthInterceptor(private val ts: TokenSource) : Interceptor { } /** - * Returns an OkHttp interceptor that retries on RFC 9449 §8 DPoP nonce challenges. + * Returns an OkHttp interceptor that retries on RFC 9449 §9 DPoP nonce challenges + * from resource servers (KAS and the platform-services Connect client). * A 401 is retried only when WWW-Authenticate carries scheme=DPoP and error=use_dpop_nonce; * any other 401 (or any 401 with only a stray DPoP-Nonce header) is passed through unchanged. * Rotated nonces are cached after every successful proceed so the next request picks them up. @@ -94,7 +95,7 @@ internal class AuthInterceptor(private val ts: TokenSource) : Interceptor { val url = chain.request().url.toUrl() var response = chain.proceed(chain.request()) - // RFC 9449 §8.1: cache any rotated nonce from the response, regardless of status. + // RFC 9449 §9: cache any rotated nonce from the response, regardless of status. cacheNonceIfPresent(url, response) if (response.code == 401 && isDpopNonceChallenge(response)) { diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/DPoPRetryInterceptorTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/DPoPRetryInterceptorTest.java index 4cdf80b6..8091bb3f 100644 --- a/sdk/src/test/java/io/opentdf/platform/sdk/DPoPRetryInterceptorTest.java +++ b/sdk/src/test/java/io/opentdf/platform/sdk/DPoPRetryInterceptorTest.java @@ -353,7 +353,7 @@ void noRetryOn401WithNonDpopChallenge() throws Exception { @Test void noRetryOn401WithDpopErrorOtherThanUseDpopNonce() throws Exception { - // RFC 9449 §8 only signals retry on error=use_dpop_nonce. Other DPoP errors + // RFC 9449 §9 only signals retry on error=use_dpop_nonce. Other DPoP errors // (invalid_token, insufficient_scope, etc.) must surface to the caller. RSAKey rsaKey = new RSAKeyGenerator(2048) .keyUse(KeyUse.SIGNATURE) @@ -390,7 +390,7 @@ void noRetryOn401WithDpopErrorOtherThanUseDpopNonce() throws Exception { @Test void rotatedNonceFromSuccessfulResponseIsCachedForNextRequest() throws Exception { - // RFC 9449 §8.1: any response (including 200) may rotate the nonce. The retry + // RFC 9449 §9: any response (including 200) may rotate the nonce. The retry // interceptor must pick that up so the *next* request picks it from the cache. // Note: the retry interceptor itself does not stamp DPoP headers on the initial // request — those come from the auth path that builds the request — so we From 8b9c0b396f1d57ebb29c729bfb19c0893afc944f Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Tue, 16 Jun 2026 18:17:32 -0400 Subject: [PATCH 21/24] fix(sdk): fail loudly when DPoP is requested but well-known omits platform_issuer Previously SDKBuilder.getAuthInterceptor would silently warn-and-return-null when the well-known configuration omitted platform_issuer, which also disabled the dpopRetryInterceptor (gated on authInterceptor != null). A caller who explicitly opted into DPoP via dpopKey()/dpopAlgorithm() would then watch every request silently downgrade to no-auth and 401 from a DPoP-enforcing resource server with no client-side breadcrumb. Now the explicit-DPoP case throws SDKException with a message naming both DPoP and platform_issuer. The pre-existing no-auth fallback (no DPoP key configured) still warn-and-returns-null. The two SRT-derivation tests that relied on the silent fallback path are updated to mock a real OIDC endpoint, since 'dpopKey set + no token endpoint' is no longer a supported configuration. --- .../io/opentdf/platform/sdk/SDKBuilder.java | 6 + .../opentdf/platform/sdk/SDKBuilderTest.java | 183 +++++++++++------- 2 files changed, 120 insertions(+), 69 deletions(-) diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/SDKBuilder.java b/sdk/src/main/java/io/opentdf/platform/sdk/SDKBuilder.java index dee26790..ae94ef36 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/SDKBuilder.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/SDKBuilder.java @@ -274,6 +274,12 @@ private AuthInterceptor getAuthInterceptor(JWK dpopJwk, JWSAlgorithm dpopAlgorit .getFieldsOrThrow(PLATFORM_ISSUER) .getStringValue(); } catch (IllegalArgumentException e) { + if (this.dpopKey != null || this.dpopAlg != null) { + throw new SDKException( + "DPoP was requested but the platform_issuer is missing from the well-known " + + "configuration at " + platformEndpoint + + "; the SDK cannot configure DPoP without a token endpoint", e); + } logger.warn( "no `platform_issuer` found in well known configuration. requests from the SDK will be unauthenticated", e); diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/SDKBuilderTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/SDKBuilderTest.java index 3b49b2df..bc0f2659 100644 --- a/sdk/src/test/java/io/opentdf/platform/sdk/SDKBuilderTest.java +++ b/sdk/src/test/java/io/opentdf/platform/sdk/SDKBuilderTest.java @@ -204,45 +204,30 @@ void ecDpopKeyAutoGeneratesRsaSrtSigner() throws Exception { // RSA-2048 key for SRT signing because DefaultSrtSigner uses RSASSASigner which // rejects non-RSA keys. Without this separation, build() would throw inside // DefaultSrtSigner's constructor. - WellKnownServiceGrpc.WellKnownServiceImplBase wellKnownService = new WellKnownServiceGrpc.WellKnownServiceImplBase() { - @Override - public void getWellKnownConfiguration(GetWellKnownConfigurationRequest request, - StreamObserver responseObserver) { - responseObserver.onNext(GetWellKnownConfigurationResponse.getDefaultInstance()); - responseObserver.onCompleted(); - } - }; - - Server platformServices = null; - try { - platformServices = ServerBuilder - .forPort(getRandomPort()) - .directExecutor() - .addService(wellKnownService) - .build() - .start(); - - com.nimbusds.jose.jwk.ECKey ecDpopKey = new com.nimbusds.jose.jwk.gen.ECKeyGenerator(com.nimbusds.jose.jwk.Curve.P_256) - .keyUse(com.nimbusds.jose.jwk.KeyUse.SIGNATURE) - .keyID(java.util.UUID.randomUUID().toString()) - .generate(); - - var sdk = SDKBuilder.newBuilder() - .clientSecret("user", "password") - .platformEndpoint("http://localhost:" + platformServices.getPort()) - .useInsecurePlaintextConnection(true) - .protocol(ProtocolType.GRPC) - .dpopKey(ecDpopKey) - .build(); + try (MockWebServer oidcServer = startMockOidcServer()) { + String issuer = oidcServer.url("my_realm").toString(); + Server platformServices = startWellKnownGrpcServer(issuer); + try { + com.nimbusds.jose.jwk.ECKey ecDpopKey = new com.nimbusds.jose.jwk.gen.ECKeyGenerator(com.nimbusds.jose.jwk.Curve.P_256) + .keyUse(com.nimbusds.jose.jwk.KeyUse.SIGNATURE) + .keyID(java.util.UUID.randomUUID().toString()) + .generate(); + + var sdk = SDKBuilder.newBuilder() + .clientSecret("user", "password") + .platformEndpoint("http://localhost:" + platformServices.getPort()) + .useInsecurePlaintextConnection(true) + .protocol(ProtocolType.GRPC) + .dpopKey(ecDpopKey) + .build(); - assertThat(sdk.getSrtSigner()).isPresent(); - assertThat(sdk.getSrtSigner().get().alg()).isEqualTo("RS256"); - // Sanity-check: the SRT signer can actually sign, which would fail if it was - // mistakenly handed the EC key (RSASSASigner constructor would have thrown). - byte[] signed = sdk.getSrtSigner().get().sign(new byte[]{1, 2, 3}); - assertThat(signed).isNotEmpty(); - } finally { - if (platformServices != null) { + assertThat(sdk.getSrtSigner()).isPresent(); + assertThat(sdk.getSrtSigner().get().alg()).isEqualTo("RS256"); + // Sanity-check: the SRT signer can actually sign, which would fail if it was + // mistakenly handed the EC key (RSASSASigner constructor would have thrown). + byte[] signed = sdk.getSrtSigner().get().sign(new byte[]{1, 2, 3}); + assertThat(signed).isNotEmpty(); + } finally { platformServices.shutdownNow(); } } @@ -253,44 +238,62 @@ void rsaDpopKeyReusesSameKeyForSrt() throws Exception { // When the caller supplies an RSA DPoP key, SDKBuilder reuses it for SRT signing // (no second RSA key is generated). This test pins that behavior so a regression // that splits the keys (and burns a key-generation per build) is caught. + try (MockWebServer oidcServer = startMockOidcServer()) { + String issuer = oidcServer.url("my_realm").toString(); + Server platformServices = startWellKnownGrpcServer(issuer); + try { + com.nimbusds.jose.jwk.RSAKey rsaDpopKey = new com.nimbusds.jose.jwk.gen.RSAKeyGenerator(2048) + .keyUse(com.nimbusds.jose.jwk.KeyUse.SIGNATURE) + .keyID(java.util.UUID.randomUUID().toString()) + .generate(); + + var sdk = SDKBuilder.newBuilder() + .clientSecret("user", "password") + .platformEndpoint("http://localhost:" + platformServices.getPort()) + .useInsecurePlaintextConnection(true) + .protocol(ProtocolType.GRPC) + .dpopKey(rsaDpopKey) + .build(); + + assertThat(sdk.getSrtSigner()).isPresent(); + assertThat(sdk.getSrtSigner().get().alg()).isEqualTo("RS256"); + } finally { + platformServices.shutdownNow(); + } + } + } + + private MockWebServer startMockOidcServer() throws IOException { + MockWebServer httpServer = new MockWebServer(); + httpServer.start(); + String issuer = httpServer.url("my_realm").toString(); + String tokenEndpoint = httpServer.url("tokens").toString(); + String oidcConfig; + try (var in = SDKBuilderTest.class.getResourceAsStream("/oidc-config.json")) { + oidcConfig = new String(in.readAllBytes(), StandardCharsets.UTF_8) + .replace("", issuer) + .replace("", tokenEndpoint); + } + httpServer.enqueue(new MockResponse().setBody(oidcConfig).setHeader("Content-type", "application/json")); + return httpServer; + } + + private Server startWellKnownGrpcServer(String issuer) throws IOException { WellKnownServiceGrpc.WellKnownServiceImplBase wellKnownService = new WellKnownServiceGrpc.WellKnownServiceImplBase() { @Override public void getWellKnownConfiguration(GetWellKnownConfigurationRequest request, StreamObserver responseObserver) { - responseObserver.onNext(GetWellKnownConfigurationResponse.getDefaultInstance()); + var val = Value.newBuilder().setStringValue(issuer).build(); + var config = Struct.newBuilder().putFields("platform_issuer", val).build(); + responseObserver.onNext(GetWellKnownConfigurationResponse.newBuilder().setConfiguration(config).build()); responseObserver.onCompleted(); } }; - - Server platformServices = null; - try { - platformServices = ServerBuilder - .forPort(getRandomPort()) - .directExecutor() - .addService(wellKnownService) - .build() - .start(); - - com.nimbusds.jose.jwk.RSAKey rsaDpopKey = new com.nimbusds.jose.jwk.gen.RSAKeyGenerator(2048) - .keyUse(com.nimbusds.jose.jwk.KeyUse.SIGNATURE) - .keyID(java.util.UUID.randomUUID().toString()) - .generate(); - - var sdk = SDKBuilder.newBuilder() - .clientSecret("user", "password") - .platformEndpoint("http://localhost:" + platformServices.getPort()) - .useInsecurePlaintextConnection(true) - .protocol(ProtocolType.GRPC) - .dpopKey(rsaDpopKey) - .build(); - - assertThat(sdk.getSrtSigner()).isPresent(); - assertThat(sdk.getSrtSigner().get().alg()).isEqualTo("RS256"); - } finally { - if (platformServices != null) { - platformServices.shutdownNow(); - } - } + return ServerBuilder.forPort(getRandomPort()) + .directExecutor() + .addService(wellKnownService) + .build() + .start(); } void sdkServicesSetup(boolean useSSLPlatform, boolean useSSLIDP) throws Exception { @@ -606,6 +609,48 @@ public void getNamespace(GetNamespaceRequest request, } } + @Test + public void dpopRequestedButPlatformIssuerMissingThrows() throws Exception { + // If the well-known config omits platform_issuer but the caller explicitly opted into + // DPoP via dpopKey(...), the builder must fail loudly — we cannot configure DPoP + // without a token endpoint, and silently dropping DPoP would surface as a confusing + // 401 from a DPoP-enforcing resource server. + WellKnownServiceGrpc.WellKnownServiceImplBase wellKnownService = new WellKnownServiceGrpc.WellKnownServiceImplBase() { + @Override + public void getWellKnownConfiguration(GetWellKnownConfigurationRequest request, + StreamObserver responseObserver) { + responseObserver.onNext(GetWellKnownConfigurationResponse.getDefaultInstance()); + responseObserver.onCompleted(); + } + }; + + Server platformServices = ServerBuilder + .forPort(getRandomPort()) + .directExecutor() + .addService(wellKnownService) + .build(); + try { + platformServices.start(); + + com.nimbusds.jose.jwk.RSAKey dpopKey = new com.nimbusds.jose.jwk.gen.RSAKeyGenerator(2048) + .keyUse(com.nimbusds.jose.jwk.KeyUse.SIGNATURE) + .keyID(java.util.UUID.randomUUID().toString()) + .generate(); + + SDKBuilder builder = SDKBuilder.newBuilder() + .clientSecret("user", "password") + .platformEndpoint("http://localhost:" + platformServices.getPort()) + .useInsecurePlaintextConnection(true) + .protocol(ProtocolType.GRPC) + .dpopKey(dpopKey); + + SDKException ex = assertThrows(SDKException.class, builder::build); + assertThat(ex.getMessage()).contains("DPoP").contains("platform_issuer"); + } finally { + platformServices.shutdownNow(); + } + } + @Test void testProtocolConfiguration() { // Test protocol setter and getter functionality From fde7c5a27a5dc927c771a28c853e2e4dc1b4a312 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Tue, 16 Jun 2026 18:17:47 -0400 Subject: [PATCH 22/24] fix(cmdline): validate DPoP key options at parse time + add coverage Previously Command.applyDPoPOptions caught Exception and wrapped everything as 'Failed to configure DPoP: ', collapsing file-not-found, malformed PEM, unsupported algorithm, and key generation failures into one opaque RuntimeException. A public-key-only PEM was accepted without complaint and only failed deep inside proof generation. A --dpop-key with --dpop= mismatch was deferred to TokenSource construction. Refactor: - New CliDpopOptions static helper owns parse + validate + private-key check. Returns DpopMaterial (jwk + alg) or Optional.empty(); throws IllegalArgumentException with user-actionable messages. - applyDPoPOptions delegates to the helper and wraps IAE as CommandLine.ParameterException so picocli exits with USAGE (2) instead of a generic stack trace. - Promote DpopKeyValidation to public so cmdline can reuse the validation rules instead of duplicating them. Latent bug fixed: bcpkix-jdk18on was only test-scoped via the sdk module, so the production CLI's JWK.parseFromPEMEncodedObjects call for --dpop-key would have thrown NoClassDefFoundError. Adding it as a cmdline runtime dependency. Tests: - New CliDpopOptionsTest (16 cases): all six supported algorithms, default RS256, unsupported algorithm, missing/malformed/public-only PEM, RSA vs EC PEM acceptance and alg inference, RSA-key + ES256 mismatch. - Two new end-to-end CommandTest cases covering the unsupported-algorithm and missing-key-file ParameterException paths through CommandLine.execute. --- cmdline/pom.xml | 7 + .../io/opentdf/platform/CliDpopOptions.java | 127 ++++++++++++++ .../java/io/opentdf/platform/Command.java | 79 +-------- .../opentdf/platform/CliDpopOptionsTest.java | 165 ++++++++++++++++++ .../java/io/opentdf/platform/CommandTest.java | 39 +++++ .../platform/sdk/DpopKeyValidation.java | 6 +- 6 files changed, 347 insertions(+), 76 deletions(-) create mode 100644 cmdline/src/main/java/io/opentdf/platform/CliDpopOptions.java create mode 100644 cmdline/src/test/java/io/opentdf/platform/CliDpopOptionsTest.java diff --git a/cmdline/pom.xml b/cmdline/pom.xml index ac577d48..82b9a924 100644 --- a/cmdline/pom.xml +++ b/cmdline/pom.xml @@ -77,6 +77,13 @@ sdk ${project.version} + + + org.bouncycastle + bcpkix-jdk18on + org.junit.jupiter diff --git a/cmdline/src/main/java/io/opentdf/platform/CliDpopOptions.java b/cmdline/src/main/java/io/opentdf/platform/CliDpopOptions.java new file mode 100644 index 00000000..30d155ac --- /dev/null +++ b/cmdline/src/main/java/io/opentdf/platform/CliDpopOptions.java @@ -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 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); + } + } +} diff --git a/cmdline/src/main/java/io/opentdf/platform/Command.java b/cmdline/src/main/java/io/opentdf/platform/Command.java index f360b888..bc9ba5cc 100644 --- a/cmdline/src/main/java/io/opentdf/platform/Command.java +++ b/cmdline/src/main/java/io/opentdf/platform/Command.java @@ -9,12 +9,6 @@ import com.google.gson.JsonParseException; import com.google.gson.JsonSyntaxException; import com.google.gson.reflect.TypeToken; -import com.nimbusds.jose.JWSAlgorithm; -import com.nimbusds.jose.jwk.Curve; -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 java.io.BufferedInputStream; import java.io.BufferedOutputStream; import java.io.File; @@ -38,7 +32,6 @@ import java.util.List; import java.util.Map; import java.util.Optional; -import java.util.UUID; import java.util.concurrent.Callable; import java.util.function.Consumer; import io.opentdf.platform.sdk.AssertionConfig; @@ -338,75 +331,15 @@ private SDK buildSDK() { .build(); } - /** - * Apply --dpop and --dpop-key options to the SDK builder. - * --dpop-key loads a PEM private key; --dpop specifies the algorithm (default - * RS256). - * If neither flag is set, the SDK auto-generates an ephemeral RSA-2048 DPoP - * key. - */ private void applyDPoPOptions(SDKBuilder builder) { try { - if (dpopKeyPath != null) { - String pem = Files.readString(dpopKeyPath); - JWK jwk = JWK.parseFromPEMEncodedObjects(pem); - builder.dpopKey(jwk); - if (dpopAlg != null && !dpopAlg.isEmpty()) { - builder.dpopAlgorithm(parseAlgorithm(dpopAlg)); - } - } else if (dpopAlg != null) { - JWSAlgorithm alg = dpopAlg.isEmpty() ? JWSAlgorithm.RS256 : parseAlgorithm(dpopAlg); - JWK jwk = generateKeyForAlgorithm(alg); - builder.dpopKey(jwk).dpopAlgorithm(alg); - } - } catch (Exception e) { - throw new RuntimeException("Failed to configure DPoP: " + e.getMessage(), e); - } - } - - private 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 RuntimeException("Unsupported DPoP algorithm: " + alg - + ". Supported: RS256, RS384, RS512, ES256, ES384, ES512"); - } - } - - private static JWK generateKeyForAlgorithm(JWSAlgorithm alg) throws Exception { - 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(); - } else if (JWSAlgorithm.ES256.equals(alg)) { - return new ECKeyGenerator(Curve.P_256) - .keyUse(KeyUse.SIGNATURE) - .keyID(UUID.randomUUID().toString()) - .generate(); - } else if (JWSAlgorithm.ES384.equals(alg)) { - return new ECKeyGenerator(Curve.P_384) - .keyUse(KeyUse.SIGNATURE) - .keyID(UUID.randomUUID().toString()) - .generate(); - } else if (JWSAlgorithm.ES512.equals(alg)) { - return new ECKeyGenerator(Curve.P_521) - .keyUse(KeyUse.SIGNATURE) - .keyID(UUID.randomUUID().toString()) - .generate(); + 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()); } - throw new RuntimeException("Cannot generate key for algorithm: " + alg); } @CommandLine.Command(name = "decrypt") diff --git a/cmdline/src/test/java/io/opentdf/platform/CliDpopOptionsTest.java b/cmdline/src/test/java/io/opentdf/platform/CliDpopOptionsTest.java new file mode 100644 index 00000000..0e5a22cc --- /dev/null +++ b/cmdline/src/test/java/io/opentdf/platform/CliDpopOptionsTest.java @@ -0,0 +1,165 @@ +package io.opentdf.platform; + +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.jwk.Curve; +import com.nimbusds.jose.jwk.ECKey; +import com.nimbusds.jose.jwk.KeyUse; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.jwk.gen.ECKeyGenerator; +import com.nimbusds.jose.jwk.gen.RSAKeyGenerator; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class CliDpopOptionsTest { + + @Test + void parse_returnsEmpty_whenNeitherFlagSet() { + assertThat(CliDpopOptions.parse(null, null)).isEmpty(); + } + + @ParameterizedTest + @ValueSource(strings = {"RS256", "RS384", "RS512", "ES256", "ES384", "ES512"}) + void parse_generatesKeyForExplicitAlgorithm(String alg) { + Optional result = CliDpopOptions.parse(alg, null); + assertThat(result).isPresent(); + assertThat(result.get().alg).isEqualTo(JWSAlgorithm.parse(alg)); + assertThat(result.get().jwk.isPrivate()).isTrue(); + } + + @Test + void parse_defaultsToRs256_whenDpopFlagWithoutValue() { + Optional result = CliDpopOptions.parse("", null); + assertThat(result).isPresent(); + assertThat(result.get().alg).isEqualTo(JWSAlgorithm.RS256); + assertThat(result.get().jwk).isInstanceOf(RSAKey.class); + } + + @Test + void parse_throwsForUnsupportedAlgorithm() { + assertThatThrownBy(() -> CliDpopOptions.parse("HS256", null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Unsupported DPoP algorithm") + .hasMessageContaining("HS256"); + } + + @Test + void parse_throwsForMissingKeyFile() { + Path nonexistent = Path.of("/tmp/definitely-does-not-exist-" + UUID.randomUUID() + ".pem"); + assertThatThrownBy(() -> CliDpopOptions.parse(null, nonexistent)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Cannot read DPoP key file") + .hasMessageContaining(nonexistent.toString()); + } + + @Test + void parse_throwsForMalformedPem(@TempDir Path tmp) throws Exception { + Path badPem = tmp.resolve("bad.pem"); + Files.writeString(badPem, "this is not a PEM file"); + assertThatThrownBy(() -> CliDpopOptions.parse(null, badPem)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("not a valid PEM-encoded key"); + } + + @Test + void parse_throwsForPublicKeyOnlyPem(@TempDir Path tmp) throws Exception { + RSAKey rsa = new RSAKeyGenerator(2048) + .keyUse(KeyUse.SIGNATURE) + .keyID(UUID.randomUUID().toString()) + .generate(); + Path publicOnly = tmp.resolve("public.pem"); + Files.writeString(publicOnly, encodePublicKey(rsa)); + assertThatThrownBy(() -> CliDpopOptions.parse(null, publicOnly)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("public key only") + .hasMessageContaining("private key is required"); + } + + @Test + void parse_acceptsRsaPrivateKeyPemAndDefaultsToRs256(@TempDir Path tmp) throws Exception { + RSAKey rsa = new RSAKeyGenerator(2048) + .keyUse(KeyUse.SIGNATURE) + .keyID(UUID.randomUUID().toString()) + .generate(); + Path keyFile = tmp.resolve("rsa.pem"); + Files.writeString(keyFile, encodePrivateKey(rsa.toPrivateKey().getEncoded())); + + Optional result = CliDpopOptions.parse(null, keyFile); + assertThat(result).isPresent(); + assertThat(result.get().alg).isEqualTo(JWSAlgorithm.RS256); + assertThat(result.get().jwk).isInstanceOf(RSAKey.class); + assertThat(result.get().jwk.isPrivate()).isTrue(); + } + + @Test + void parse_acceptsEcPrivateKeyAndInfersAlgorithm(@TempDir Path tmp) throws Exception { + ECKey ec = new ECKeyGenerator(Curve.P_256) + .keyUse(KeyUse.SIGNATURE) + .keyID(UUID.randomUUID().toString()) + .generate(); + Path keyFile = tmp.resolve("ec.pem"); + Files.writeString(keyFile, encodeEcKeyPair(ec)); + + Optional result = CliDpopOptions.parse(null, keyFile); + assertThat(result).isPresent(); + assertThat(result.get().alg).isEqualTo(JWSAlgorithm.ES256); + assertThat(result.get().jwk).isInstanceOf(ECKey.class); + } + + @Test + void parse_rejectsRsaKeyWithEcAlgorithm(@TempDir Path tmp) throws Exception { + RSAKey rsa = new RSAKeyGenerator(2048) + .keyUse(KeyUse.SIGNATURE) + .keyID(UUID.randomUUID().toString()) + .generate(); + Path keyFile = tmp.resolve("rsa.pem"); + Files.writeString(keyFile, encodePrivateKey(rsa.toPrivateKey().getEncoded())); + + assertThatThrownBy(() -> CliDpopOptions.parse("ES256", keyFile)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("incompatible with --dpop=ES256"); + } + + @Test + void parse_explicitAlgorithmOverridesEcInferenceWhenCompatible(@TempDir Path tmp) throws Exception { + ECKey ec = new ECKeyGenerator(Curve.P_256) + .keyUse(KeyUse.SIGNATURE) + .keyID(UUID.randomUUID().toString()) + .generate(); + Path keyFile = tmp.resolve("ec.pem"); + Files.writeString(keyFile, encodeEcKeyPair(ec)); + + Optional result = CliDpopOptions.parse("ES256", keyFile); + assertThat(result).isPresent(); + assertThat(result.get().alg).isEqualTo(JWSAlgorithm.ES256); + } + + private static String encodePrivateKey(byte[] pkcs8) { + String base64 = java.util.Base64.getMimeEncoder(64, "\n".getBytes()).encodeToString(pkcs8); + return "-----BEGIN PRIVATE KEY-----\n" + base64 + "\n-----END PRIVATE KEY-----\n"; + } + + private static String encodePublicKey(RSAKey key) throws Exception { + byte[] x509 = key.toPublicKey().getEncoded(); + String base64 = java.util.Base64.getMimeEncoder(64, "\n".getBytes()).encodeToString(x509); + return "-----BEGIN PUBLIC KEY-----\n" + base64 + "\n-----END PUBLIC KEY-----\n"; + } + + private static String encodeEcKeyPair(ECKey ec) throws Exception { + byte[] pubX509 = ec.toPublicKey().getEncoded(); + byte[] privPkcs8 = ec.toPrivateKey().getEncoded(); + String pubB64 = java.util.Base64.getMimeEncoder(64, "\n".getBytes()).encodeToString(pubX509); + String privB64 = java.util.Base64.getMimeEncoder(64, "\n".getBytes()).encodeToString(privPkcs8); + return "-----BEGIN PUBLIC KEY-----\n" + pubB64 + "\n-----END PUBLIC KEY-----\n" + + "-----BEGIN PRIVATE KEY-----\n" + privB64 + "\n-----END PRIVATE KEY-----\n"; + } +} diff --git a/cmdline/src/test/java/io/opentdf/platform/CommandTest.java b/cmdline/src/test/java/io/opentdf/platform/CommandTest.java index ef7ef15c..54ae3c44 100644 --- a/cmdline/src/test/java/io/opentdf/platform/CommandTest.java +++ b/cmdline/src/test/java/io/opentdf/platform/CommandTest.java @@ -66,4 +66,43 @@ void verbose_flag_sets_verbose_field() { new CommandLine(command).parseArgs("--verbose", "supports", "dpop"); assertThat(command.verbose).isTrue(); } + + @Test + void encrypt_withUnsupportedDpopAlgorithm_failsWithUsage() { + StringWriter err = new StringWriter(); + CommandLine cli = new CommandLine(new Command()); + cli.setErr(new PrintWriter(err)); + + int code = cli.execute( + "--platform-endpoint", "https://example.invalid", + "--client-id", "x", + "--client-secret", "x", + "encrypt", + "--dpop=HS256", + "-k", "https://kas.example.invalid", + "-f", "/dev/null"); + + assertThat(code).isEqualTo(CommandLine.ExitCode.USAGE); + assertThat(err.toString()).contains("Unsupported DPoP algorithm").contains("HS256"); + } + + @Test + void encrypt_withMissingDpopKeyFile_failsWithUsage() { + StringWriter err = new StringWriter(); + CommandLine cli = new CommandLine(new Command()); + cli.setErr(new PrintWriter(err)); + + int code = cli.execute( + "--platform-endpoint", "https://example.invalid", + "--client-id", "x", + "--client-secret", "x", + "encrypt", + "--dpop-key", "/tmp/does-not-exist-dpop-key.pem", + "-k", "https://kas.example.invalid", + "-f", "/dev/null"); + + assertThat(code).isEqualTo(CommandLine.ExitCode.USAGE); + assertThat(err.toString()).contains("Cannot read DPoP key file") + .contains("/tmp/does-not-exist-dpop-key.pem"); + } } diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/DpopKeyValidation.java b/sdk/src/main/java/io/opentdf/platform/sdk/DpopKeyValidation.java index 5022c589..d236b7d5 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/DpopKeyValidation.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/DpopKeyValidation.java @@ -6,11 +6,11 @@ import com.nimbusds.jose.jwk.JWK; import com.nimbusds.jose.jwk.RSAKey; -final class DpopKeyValidation { +public final class DpopKeyValidation { private DpopKeyValidation() { } - static void validate(JWK jwk, JWSAlgorithm alg) { + public static void validate(JWK jwk, JWSAlgorithm alg) { if (jwk == null) { throw new IllegalArgumentException("DPoP JWK cannot be null"); } @@ -35,7 +35,7 @@ static void validate(JWK jwk, JWSAlgorithm alg) { } } - static JWSAlgorithm inferEcAlgorithm(Curve curve) { + public static JWSAlgorithm inferEcAlgorithm(Curve curve) { if (Curve.P_256.equals(curve)) { return JWSAlgorithm.ES256; } From 5ca622925f60624a8a17f2fac5e787b6132397dc Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Wed, 17 Jun 2026 08:27:56 -0400 Subject: [PATCH 23/24] debug(sdk): add DPoP path/method/claims logging to AuthInterceptor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six DEBUG-level log sites tagged 'DPoP path=' to triage the CI failure where the platform DPoP validator rejected proofs with htm=GET but expected POST. Each entry records the URL, the HTTP method that flows into the proof, the Authorization scheme (Bearer/DPoP — never the token), and a parsed DPoP claim summary (htm, htu, jti, nonce). Both the connect-layer method (request.httpMethod.name) and the outgoing OkHttp method (chain.request().method) are logged so a divergence between the two is visible. Helpers: - authScheme(): redacts the Authorization header to just its scheme. - dpopSummary(): parses the DPoP proof JWT and emits the claims that matter, falling back to on error. DEBUG level only — zero cost in production; surfaces via --verbose on the CLI. --- .../opentdf/platform/sdk/AuthInterceptor.kt | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/sdk/src/main/kotlin/io/opentdf/platform/sdk/AuthInterceptor.kt b/sdk/src/main/kotlin/io/opentdf/platform/sdk/AuthInterceptor.kt index 5fb8cb42..08d82865 100644 --- a/sdk/src/main/kotlin/io/opentdf/platform/sdk/AuthInterceptor.kt +++ b/sdk/src/main/kotlin/io/opentdf/platform/sdk/AuthInterceptor.kt @@ -5,6 +5,7 @@ import com.connectrpc.StreamFunction import com.connectrpc.UnaryFunction import com.connectrpc.http.UnaryHTTPRequest import com.connectrpc.http.clone +import com.nimbusds.jwt.SignedJWT import org.slf4j.LoggerFactory import java.net.URL @@ -27,6 +28,9 @@ internal class AuthInterceptor(private val ts: TokenSource) : Interceptor { requestHeaders["Authorization"] = listOf(authHeaders.authHeader) authHeaders.dpopHeader?.let { requestHeaders["DPoP"] = listOf(it) } + logger.debug("DPoP path=stream url={} method=POST authScheme={} {}", + request.url, authScheme(authHeaders.authHeader), dpopSummary(authHeaders.dpopHeader)) + return@StreamFunction request.clone( url = request.url, contentType = request.contentType, @@ -53,6 +57,10 @@ internal class AuthInterceptor(private val ts: TokenSource) : Interceptor { requestHeaders["Authorization"] = listOf(authHeaders.authHeader) authHeaders.dpopHeader?.let { requestHeaders["DPoP"] = listOf(it) } + logger.debug("DPoP path=unary url={} method={} authScheme={} {}", + request.url, request.httpMethod.name, + authScheme(authHeaders.authHeader), dpopSummary(authHeaders.dpopHeader)) + UnaryHTTPRequest( url = request.url, contentType = request.contentType, @@ -79,6 +87,8 @@ internal class AuthInterceptor(private val ts: TokenSource) : Interceptor { if (dpopNonce != null && url != null) { ts.cacheNonce(url, dpopNonce) } + logger.debug("DPoP path=unary-response url={} nonceCached={} status={}", + url, dpopNonce != null && url != null, resp.status) resp }, ) @@ -93,11 +103,17 @@ internal class AuthInterceptor(private val ts: TokenSource) : Interceptor { */ fun dpopRetryInterceptor(): okhttp3.Interceptor = okhttp3.Interceptor { chain -> val url = chain.request().url.toUrl() + val outgoingMethod = chain.request().method var response = chain.proceed(chain.request()) // RFC 9449 §9: cache any rotated nonce from the response, regardless of status. cacheNonceIfPresent(url, response) + logger.debug("DPoP path=okhttp url={} method={} status={} authScheme={} {}", + url, outgoingMethod, response.code, + authScheme(chain.request().header("Authorization")), + dpopSummary(chain.request().header("DPoP"))) + if (response.code == 401 && isDpopNonceChallenge(response)) { val dpopNonce = response.header("dpop-nonce") ?: response.header("DPoP-Nonce") if (dpopNonce != null) { @@ -108,6 +124,9 @@ internal class AuthInterceptor(private val ts: TokenSource) : Interceptor { .header("Authorization", authHeaders.authHeader) authHeaders.dpopHeader?.let { newRequestBuilder.header("DPoP", it) } val newRequest = newRequestBuilder.build() + logger.debug("DPoP path=okhttp-retry url={} method={} nonce={} authScheme={} {}", + url, chain.request().method, dpopNonce, + authScheme(authHeaders.authHeader), dpopSummary(authHeaders.dpopHeader)) response = try { chain.proceed(newRequest) } catch (e: Exception) { @@ -115,6 +134,7 @@ internal class AuthInterceptor(private val ts: TokenSource) : Interceptor { throw e } cacheNonceIfPresent(url, response) + logger.debug("DPoP path=okhttp-retry-response url={} status={}", url, response.code) } } response @@ -133,4 +153,21 @@ internal class AuthInterceptor(private val ts: TokenSource) : Interceptor { challenge.authParams["error"].equals("use_dpop_nonce", ignoreCase = true) } } + + private fun authScheme(authHeader: String?): String { + if (authHeader == null) return "" + val idx = authHeader.indexOf(' ') + return if (idx > 0) authHeader.substring(0, idx) else "?" + } + + private fun dpopSummary(dpopProof: String?): String { + if (dpopProof == null) return "dpop=" + return try { + val claims = SignedJWT.parse(dpopProof).jwtClaimsSet + "dpop[htm=${claims.getStringClaim("htm")} htu=${claims.getStringClaim("htu")}" + + " jti=${claims.getStringClaim("jti")} nonce=${claims.getStringClaim("nonce")}]" + } catch (e: Exception) { + "dpop=" + } + } } From 95ce0d007e457e9e33814aa58de0ad964575ae82 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Wed, 17 Jun 2026 09:06:02 -0400 Subject: [PATCH 24/24] fix(sdk): disable Connect-GET on authenticated client (DPoP htm drift) Connect-RPC's GET extension rewrites idempotent POST RPCs to GET on the wire (with the request payload moved into the query string). The Connect interceptor stamps the DPoP proof before that rewrite happens, so htm=POST ends up on a GET request and the server rejects: 'incorrect htm claim in DPoP JWT; received [POST], but should match [[GET]]' This is the same class of mismatch that commit 16f16c9 fixed for htu (Connect-GET appends ?base64=&connect=v1&...&message=... to the URL, drifting htu the same way). htm wasn't covered. Disable Connect-GET on the authenticated ProtocolClient where DPoP proofs are attached. Keep it enabled on the unauthenticated bootstrap client (well-known config fetch) since no proof is sent there. Cost: authenticated unary RPCs (ListKeyAccessServers, etc.) round-trip as POST instead of GET. Those calls were never going to be CDN-cacheable anyway because each carries a per-request DPoP proof. --- sdk/src/main/java/io/opentdf/platform/sdk/SDKBuilder.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/SDKBuilder.java b/sdk/src/main/java/io/opentdf/platform/sdk/SDKBuilder.java index ae94ef36..e612404f 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/SDKBuilder.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/SDKBuilder.java @@ -479,12 +479,18 @@ private ProtocolClient getUnauthenticatedProtocolClient(String endpoint, OkHttpC } private ProtocolClient getProtocolClient(String endpoint, OkHttpClient httpClient, AuthInterceptor authInterceptor) { + // Connect-GET would rewrite idempotent POST RPCs to GET on the wire, which invalidates + // the DPoP proof's htm claim (stamped before the rewrite). Keep it enabled only on the + // unauthenticated bootstrap path where no DPoP proof is attached. + GETConfiguration getConfig = authInterceptor != null + ? GETConfiguration.Disabled.INSTANCE + : GETConfiguration.Enabled.INSTANCE; var protocolClientConfig = new ProtocolClientConfig( endpoint, new GoogleJavaProtobufStrategy(), protocolType.getNetworkProtocol(), null, - GETConfiguration.Enabled.INSTANCE, + getConfig, authInterceptor == null ? Collections.emptyList() : List.of(ignoredConfig -> authInterceptor) );