From fb8b27b78e178fb342c1477229996ba0aad38b96 Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Tue, 25 Aug 2026 02:42:18 +0000 Subject: [PATCH] feat(auth): add fixed-alias X.509 transport capability --- .../com/openai/client/okhttp/X509Transport.kt | 193 ++++++++++++ .../openai/client/okhttp/X509TransportTest.kt | 275 ++++++++++++++++++ 2 files changed, 468 insertions(+) create mode 100644 openai-java-client-okhttp/src/main/kotlin/com/openai/client/okhttp/X509Transport.kt create mode 100644 openai-java-client-okhttp/src/test/kotlin/com/openai/client/okhttp/X509TransportTest.kt diff --git a/openai-java-client-okhttp/src/main/kotlin/com/openai/client/okhttp/X509Transport.kt b/openai-java-client-okhttp/src/main/kotlin/com/openai/client/okhttp/X509Transport.kt new file mode 100644 index 000000000..b0d0cc247 --- /dev/null +++ b/openai-java-client-okhttp/src/main/kotlin/com/openai/client/okhttp/X509Transport.kt @@ -0,0 +1,193 @@ +package com.openai.client.okhttp + +import com.openai.core.Timeout +import com.openai.core.checkRequired +import java.net.Proxy +import java.net.Socket +import java.security.Principal +import java.security.PrivateKey +import java.security.cert.X509Certificate +import javax.net.ssl.KeyManager +import javax.net.ssl.SSLContext +import javax.net.ssl.SSLEngine +import javax.net.ssl.X509ExtendedKeyManager +import javax.net.ssl.X509TrustManager + +/** + * Preview: an immutable, caller-attested TLS capability for X.509 workload identity federation. + * + * The caller attests that the configured certificate alias resolves to the same private key and + * certificate chain, and that the trust manager makes stable trust decisions, for this capability's + * lifetime. The SDK does not copy private-key material. To rotate the identity or change server + * trust, build a new capability; each capability is a distinct TLS generation with its own context. + * Each bound session creates and owns its own isolated connection pools. + * + * This initial capability is direct-connect only. It always uses native hostname verification, + * disables redirects, and does not accept arbitrary clients, interceptors, or socket factories. + */ +class X509Transport +private constructor( + private val sslContext: SSLContext, + private val trustManager: X509TrustManager, +) { + + companion object { + @JvmStatic fun builder() = Builder() + } + + /** A builder for [X509Transport]. */ + class Builder internal constructor() { + + private var keyManager: X509ExtendedKeyManager? = null + private var certificateAlias: String? = null + private var trustManager: X509TrustManager? = null + + /** + * Sets the caller-owned key manager. Its configured [certificateAlias] must remain stable + * for the lifetime of the built capability. + */ + fun keyManager(keyManager: X509ExtendedKeyManager) = apply { this.keyManager = keyManager } + + /** Sets the one client-certificate alias allowed on both network legs. */ + fun certificateAlias(certificateAlias: String) = apply { + this.certificateAlias = certificateAlias + } + + /** + * Sets the caller-owned trust manager used for server authentication. Client identity and + * server trust remain independent. The caller attests that its trust decisions remain + * stable for the lifetime of the built capability. To change the trust policy, build a new + * capability. + */ + fun trustManager(trustManager: X509TrustManager) = apply { + this.trustManager = trustManager + } + + fun build(): X509Transport { + val keyManager = checkRequired("keyManager", keyManager) + val alias = + checkRequired("certificateAlias", certificateAlias).also { + require(it.isNotBlank()) { "certificateAlias must not be blank" } + } + val trustManager = checkRequired("trustManager", trustManager) + requireNotNull(keyManager.getPrivateKey(alias)) { + "certificateAlias does not identify a private key" + } + require(!keyManager.getCertificateChain(alias).isNullOrEmpty()) { + "certificateAlias does not identify a certificate chain" + } + + val fixedAliasKeyManager = FixedAliasKeyManager(keyManager, alias) + val sslContext = + SSLContext.getInstance("TLS").apply { + init(arrayOf(fixedAliasKeyManager), arrayOf(trustManager), null) + } + return X509Transport(sslContext, trustManager) + } + } + + /** + * Binds this TLS generation to two isolated direct-connect clients. The returned object owns + * both clients and must be closed by the SDK client that receives it. + */ + @JvmSynthetic + internal fun bind(timeout: Timeout): BoundX509Transport = + bind(timeout, Proxy.NO_PROXY, Proxy.NO_PROXY) + + /** Test seam for the loopback HTTP CONNECT oracle. Production integration uses [bind]. */ + @JvmSynthetic + internal fun bindForTest( + timeout: Timeout, + exchangeProxy: Proxy, + apiProxy: Proxy, + ): BoundX509Transport = bind(timeout, exchangeProxy, apiProxy) + + private fun bind(timeout: Timeout, exchangeProxy: Proxy, apiProxy: Proxy): BoundX509Transport { + fun client(proxy: Proxy): OkHttpClient = + OkHttpClient.builder() + .timeout(timeout) + .followRedirects(false) + .proxy(proxy) + .sslSocketFactory(sslContext.socketFactory) + .trustManager(trustManager) + .build() + + val exchangeClient = client(exchangeProxy) + return try { + BoundX509Transport(exchangeClient, client(apiProxy)) + } catch (error: Throwable) { + try { + exchangeClient.close() + } catch (closeError: Throwable) { + if (closeError !== error) { + error.addSuppressed(closeError) + } + } + throw error + } + } +} + +internal class BoundX509Transport(val exchangeClient: OkHttpClient, val apiClient: OkHttpClient) : + AutoCloseable { + + override fun close() { + apiClient.use { exchangeClient.close() } + } +} + +private class FixedAliasKeyManager( + private val delegate: X509ExtendedKeyManager, + private val alias: String, +) : X509ExtendedKeyManager() { + + override fun getClientAliases(keyType: String, issuers: Array?): Array? = + delegate + .getClientAliases(keyType, issuers) + ?.takeIf { aliases -> alias in aliases } + ?.let { arrayOf(alias) } + + override fun chooseClientAlias( + keyType: Array?, + issuers: Array?, + socket: Socket?, + ): String? = chooseEligibleAlias(keyType, issuers) + + override fun chooseEngineClientAlias( + keyType: Array?, + issuers: Array?, + engine: SSLEngine?, + ): String? = chooseEligibleAlias(keyType, issuers) + + private fun chooseEligibleAlias( + keyTypes: Array?, + issuers: Array?, + ): String? = + keyTypes + ?.asSequence() + ?.flatMap { keyType -> + delegate.getClientAliases(keyType, issuers).orEmpty().asSequence() + } + ?.firstOrNull { it == alias } + + override fun getServerAliases(keyType: String, issuers: Array?): Array? = + null + + override fun chooseServerAlias( + keyType: String, + issuers: Array?, + socket: Socket?, + ): String? = null + + override fun chooseEngineServerAlias( + keyType: String, + issuers: Array?, + engine: SSLEngine?, + ): String? = null + + override fun getCertificateChain(alias: String?): Array? = + alias?.takeIf { it == this.alias }?.let(delegate::getCertificateChain) + + override fun getPrivateKey(alias: String?): PrivateKey? = + alias?.takeIf { it == this.alias }?.let(delegate::getPrivateKey) +} diff --git a/openai-java-client-okhttp/src/test/kotlin/com/openai/client/okhttp/X509TransportTest.kt b/openai-java-client-okhttp/src/test/kotlin/com/openai/client/okhttp/X509TransportTest.kt new file mode 100644 index 000000000..062875ae3 --- /dev/null +++ b/openai-java-client-okhttp/src/test/kotlin/com/openai/client/okhttp/X509TransportTest.kt @@ -0,0 +1,275 @@ +package com.openai.client.okhttp + +import com.openai.core.Timeout +import com.openai.core.http.HttpMethod +import com.openai.core.http.HttpRequest +import java.net.Socket +import java.security.KeyStore +import java.security.Principal +import java.security.PrivateKey +import java.security.cert.X509Certificate +import javax.net.ssl.KeyManagerFactory +import javax.net.ssl.SSLEngine +import javax.net.ssl.X509ExtendedKeyManager +import okhttp3.mockwebserver.MockResponse +import okhttp3.tls.HandshakeCertificates +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.jupiter.api.Test + +internal class X509TransportTest { + + @Test + fun productionBindingIsDirectNonRetryingAndIsolated() { + val pinned = X509TestIdentity.create("production binding identity") + val transport = transport(pinned, emptyList()) + + transport.bind(Timeout.default()).use { bound -> + val exchange = bound.exchangeClient.okHttpClient + val api = bound.apiClient.okHttpClient + + assertThat(exchange.proxy).isEqualTo(java.net.Proxy.NO_PROXY) + assertThat(api.proxy).isEqualTo(java.net.Proxy.NO_PROXY) + assertThat(exchange.followRedirects).isFalse() + assertThat(api.followRedirects).isFalse() + assertThat(exchange.followSslRedirects).isFalse() + assertThat(api.followSslRedirects).isFalse() + assertThat(exchange.retryOnConnectionFailure).isFalse() + assertThat(api.retryOnConnectionFailure).isFalse() + assertThat(exchange.connectionPool).isNotSameAs(api.connectionPool) + assertThat(exchange.dispatcher).isNotSameAs(api.dispatcher) + assertThat(exchange.dispatcher.executorService) + .isNotSameAs(api.dispatcher.executorService) + } + } + + @Test + fun productionTransportPinsOneAliasAcrossBothExactAuthorities() { + val pinned = X509TestIdentity.create("pinned identity") + val alternate = X509TestIdentity.create("alternate identity") + X509TestPeer(AUTH_HOST, pinned.root.certificate).use { authPeer -> + X509TestPeer(API_HOST, pinned.root.certificate).use { apiPeer -> + authPeer.enqueue(MockResponse().setBody("auth")) + apiPeer.enqueue(MockResponse().setBody("api")) + val transport = + adversarialTransport( + pinned, + alternate, + listOf(authPeer.serverRootCertificate, apiPeer.serverRootCertificate), + ) + + transport.bindForTest(Timeout.default(), authPeer.proxy, apiPeer.proxy).use { bound + -> + bound.exchangeClient.execute(request(AUTH_URL)).use { response -> + assertThat(response.statusCode()).isEqualTo(200) + } + // Closing one path must not drain the other path's pool or dispatcher. + bound.exchangeClient.close() + bound.apiClient.execute(request("$API_URL/v1/files")).use { response -> + assertThat(response.statusCode()).isEqualTo(200) + } + } + + val authConnect = authPeer.takeRequest() + val authRequest = authPeer.takeRequest() + val apiConnect = apiPeer.takeRequest() + val apiRequest = apiPeer.takeRequest() + assertThat(authConnect.requestLine).isEqualTo("CONNECT $AUTH_HOST:443 HTTP/1.1") + assertThat(apiConnect.requestLine).isEqualTo("CONNECT $API_HOST:443 HTTP/1.1") + assertThat(authRequest.path).isEqualTo("/oauth/token") + assertThat(apiRequest.path).isEqualTo("/v1/files") + assertThat(requireNotNull(authRequest.handshake).peerCertificates.first()) + .isEqualTo(pinned.leaf.certificate) + assertThat(requireNotNull(apiRequest.handshake).peerCertificates.first()) + .isEqualTo(pinned.leaf.certificate) + assertThat(authRequest.handshake!!.peerCertificates) + .doesNotContain(alternate.leaf.certificate) + assertThat(apiRequest.handshake!!.peerCertificates) + .doesNotContain(alternate.leaf.certificate) + assertThat(authPeer.requestedServerNames).containsExactly(AUTH_HOST) + assertThat(apiPeer.requestedServerNames).containsExactly(API_HOST) + } + } + } + + @Test + fun productionTransportDoesNotFollowRedirects() { + val identity = X509TestIdentity.create("redirect identity") + X509TestPeer(AUTH_HOST, identity.root.certificate).use { authPeer -> + X509TestPeer(API_HOST, identity.root.certificate).use { apiPeer -> + authPeer.enqueue( + MockResponse().setResponseCode(307).setHeader("Location", "$API_URL/v1/files") + ) + val transport = + transport( + identity, + listOf(authPeer.serverRootCertificate, apiPeer.serverRootCertificate), + ) + + transport.bindForTest(Timeout.default(), authPeer.proxy, apiPeer.proxy).use { bound + -> + bound.exchangeClient.execute(request(AUTH_URL)).use { response -> + assertThat(response.statusCode()).isEqualTo(307) + } + } + + assertThat(authPeer.server.requestCount).isEqualTo(2) + assertThat(apiPeer.server.requestCount).isZero() + } + } + } + + @Test + fun productionTransportRetainsNativeHostnameVerification() { + val identity = X509TestIdentity.create("hostname identity") + X509TestPeer(AUTH_HOST, identity.root.certificate).use { authPeer -> + authPeer.enqueue(MockResponse()) + val transport = transport(identity, listOf(authPeer.serverRootCertificate)) + + transport.bindForTest(Timeout.default(), authPeer.proxy, authPeer.proxy).use { bound -> + assertThatThrownBy { bound.exchangeClient.execute(request(API_URL)).close() } + .hasRootCauseInstanceOf(javax.net.ssl.SSLPeerUnverifiedException::class.java) + } + + assertThat(authPeer.takeRequest().requestLine) + .isEqualTo("CONNECT $API_HOST:443 HTTP/1.1") + } + } + + @Test + fun rejectsMissingOrBlankAliasBeforeNetworking() { + val identity = X509TestIdentity.create("builder identity") + val keyManager = keyManager(mapOf(PINNED_ALIAS to identity)) + val trustManager = HandshakeCertificates.Builder().build().trustManager + + assertThatThrownBy { + X509Transport.builder() + .keyManager(keyManager) + .certificateAlias(" ") + .trustManager(trustManager) + .build() + } + .isInstanceOf(IllegalArgumentException::class.java) + .hasMessage("certificateAlias must not be blank") + assertThatThrownBy { + X509Transport.builder() + .keyManager(keyManager) + .certificateAlias("missing") + .trustManager(trustManager) + .build() + } + .isInstanceOf(IllegalArgumentException::class.java) + .hasMessage("certificateAlias does not identify a private key") + } + + private fun adversarialTransport( + pinned: X509TestIdentity, + alternate: X509TestIdentity, + trustedServerRoots: Iterable, + ): X509Transport = + transport( + SelectingKeyManager( + keyManager(mapOf(PINNED_ALIAS to pinned, ALTERNATE_ALIAS to alternate)), + ALTERNATE_ALIAS, + ), + trustedServerRoots, + ) + + private fun transport( + identity: X509TestIdentity, + trustedServerRoots: Iterable, + ): X509Transport = transport(keyManager(mapOf(PINNED_ALIAS to identity)), trustedServerRoots) + + private fun transport( + keyManager: X509ExtendedKeyManager, + trustedServerRoots: Iterable, + ): X509Transport { + val trustManager = + HandshakeCertificates.Builder() + .apply { + trustedServerRoots.forEach { certificate -> addTrustedCertificate(certificate) } + } + .build() + .trustManager + return X509Transport.builder() + .keyManager(keyManager) + .certificateAlias(PINNED_ALIAS) + .trustManager(trustManager) + .build() + } + + private fun keyManager(identities: Map): X509ExtendedKeyManager { + val password = "test password".toCharArray() + val keyStore = + KeyStore.getInstance("PKCS12").apply { + load(null, null) + identities.forEach { (alias, identity) -> + setKeyEntry( + alias, + identity.leaf.keyPair.private, + password, + arrayOf(identity.leaf.certificate, identity.root.certificate), + ) + } + } + val keyManagerFactory = + KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()).apply { + init(keyStore, password) + } + return keyManagerFactory.keyManagers.filterIsInstance().single() + } + + private fun request(url: String): HttpRequest = + HttpRequest.builder().method(HttpMethod.GET).baseUrl(url).build() + + private companion object { + const val AUTH_HOST = "mtls.auth.openai.com" + const val API_HOST = "mtls.api.openai.com" + const val AUTH_URL = "https://$AUTH_HOST/oauth/token" + const val API_URL = "https://$API_HOST" + const val PINNED_ALIAS = "pinned" + const val ALTERNATE_ALIAS = "alternate" + } +} + +/** A delegate whose ordinary selection callbacks always try to select a different alias. */ +private class SelectingKeyManager( + private val delegate: X509ExtendedKeyManager, + private val selectedAlias: String, +) : X509ExtendedKeyManager() { + + override fun getClientAliases(keyType: String, issuers: Array?): Array? = + delegate.getClientAliases(keyType, issuers) + + override fun chooseClientAlias( + keyType: Array?, + issuers: Array?, + socket: Socket?, + ): String = selectedAlias + + override fun chooseEngineClientAlias( + keyType: Array?, + issuers: Array?, + engine: SSLEngine?, + ): String = selectedAlias + + override fun getServerAliases(keyType: String, issuers: Array?): Array? = + delegate.getServerAliases(keyType, issuers) + + override fun chooseServerAlias( + keyType: String, + issuers: Array?, + socket: Socket?, + ): String? = delegate.chooseServerAlias(keyType, issuers, socket) + + override fun chooseEngineServerAlias( + keyType: String, + issuers: Array?, + engine: SSLEngine?, + ): String? = delegate.chooseEngineServerAlias(keyType, issuers, engine) + + override fun getCertificateChain(alias: String?): Array? = + delegate.getCertificateChain(alias) + + override fun getPrivateKey(alias: String?): PrivateKey? = delegate.getPrivateKey(alias) +}