diff --git a/.github/workflows/x509-live-smoke.yml b/.github/workflows/x509-live-smoke.yml new file mode 100644 index 000000000..145aeaa5c --- /dev/null +++ b/.github/workflows/x509-live-smoke.yml @@ -0,0 +1,141 @@ +name: X.509 live verification + +on: + workflow_dispatch: + inputs: + run_x509: + description: Confirm the protected issuer-to-mTLS API verification + required: true + default: false + type: boolean + +permissions: {} + +concurrency: + group: x509-live-verification + cancel-in-progress: false + +jobs: + preflight: + name: X.509 live gate status + runs-on: ubuntu-24.04 + timeout-minutes: 2 + permissions: {} + steps: + - name: Record that live verification was not requested + if: ${{ !inputs.run_x509 }} + shell: bash + run: | + echo '## X.509 live verification: NOT RUN' >> "$GITHUB_STEP_SUMMARY" + echo 'The manual X.509 verification input was not enabled.' >> "$GITHUB_STEP_SUMMARY" + + - name: Reject noncanonical live verification requests + if: >- + inputs.run_x509 && + (github.repository != 'openai/openai-java' || github.ref != 'refs/heads/main') + shell: bash + run: | + echo 'X.509 live verification is restricted to openai/openai-java main.' >&2 + exit 1 + + - name: Record the pending protected verification + if: >- + inputs.run_x509 && + github.repository == 'openai/openai-java' && + github.ref == 'refs/heads/main' + shell: bash + run: | + echo '## X.509 live verification: REQUESTED' >> "$GITHUB_STEP_SUMMARY" + echo 'Production verification is pending protected-environment approval and execution.' >> "$GITHUB_STEP_SUMMARY" + + live: + name: Issuer exchange and mTLS API + needs: preflight + if: >- + inputs.run_x509 && + github.repository == 'openai/openai-java' && + github.ref == 'refs/heads/main' + runs-on: ubuntu-24.04 + timeout-minutes: 10 + # This environment must require independent SDK-team approval with self-review and + # administrator bypass disabled. See docs/x509-live-verification.md. + environment: x509-live-smoke + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 + with: + persist-credentials: false + ref: ${{ github.sha }} + + - name: Set up Java + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: 21 + + - name: Create isolated X.509 Gradle User Home + env: + TRUSTED_GRADLE_USER_HOME: ${{ runner.temp }}/trusted-x509-gradle-${{ github.run_id }}-${{ github.run_attempt }} + run: | + set -euo pipefail + + if [[ -e "$TRUSTED_GRADLE_USER_HOME" || -L "$TRUSTED_GRADLE_USER_HOME" ]]; then + echo "::error::X.509 Gradle User Home already exists" + exit 1 + fi + + mkdir -m 700 "$TRUSTED_GRADLE_USER_HOME" + printf 'GRADLE_USER_HOME=%s\n' "$TRUSTED_GRADLE_USER_HOME" >> "$GITHUB_ENV" + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2 + with: + cache-disabled: true + + - name: Verify enrolled X.509 identity + shell: bash + env: + OPENAI_X509_LIVE_TEST: "1" + OPENAI_X509_KEYSTORE_P12_BASE64: ${{ secrets.OPENAI_X509_KEYSTORE_P12_BASE64 }} + OPENAI_X509_KEYSTORE_PASSWORD: ${{ secrets.OPENAI_X509_KEYSTORE_PASSWORD }} + OPENAI_X509_CERTIFICATE_ALIAS: ${{ secrets.OPENAI_X509_CERTIFICATE_ALIAS }} + OPENAI_X509_IDENTITY_PROVIDER_ID: ${{ secrets.OPENAI_X509_IDENTITY_PROVIDER_ID }} + OPENAI_X509_SERVICE_ACCOUNT_ID: ${{ secrets.OPENAI_X509_SERVICE_ACCOUNT_ID }} + run: | + set -euo pipefail + missing_configuration=false + for name in \ + OPENAI_X509_KEYSTORE_P12_BASE64 \ + OPENAI_X509_KEYSTORE_PASSWORD \ + OPENAI_X509_CERTIFICATE_ALIAS \ + OPENAI_X509_IDENTITY_PROVIDER_ID \ + OPENAI_X509_SERVICE_ACCOUNT_ID; do + if [[ -z "${!name:-}" ]]; then + missing_configuration=true + fi + done + + if [[ "$missing_configuration" == true ]]; then + echo '## X.509 live verification: NOT RUN' >> "$GITHUB_STEP_SUMMARY" + echo 'Required protected-environment configuration was unavailable.' >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + + ./gradlew :openai-java-client-okhttp:test \ + --tests '*X509LiveVerificationTest' \ + --no-build-cache \ + --no-daemon \ + --rerun-tasks + + report='openai-java-client-okhttp/build/test-results/test/TEST-com.openai.client.okhttp.X509LiveVerificationTest.xml' + test -f "$report" + grep -q 'tests="1"' "$report" + grep -q 'skipped="0"' "$report" + timestamp=$(date -u +'%Y-%m-%dT%H:%M:%SZ') + runtime=$(java -version 2>&1) + runtime=${runtime%%$'\n'*} + echo '## X.509 live verification: PASSED' >> "$GITHUB_STEP_SUMMARY" + echo 'The enrolled issuer exchange and approved mTLS API request both completed.' >> "$GITHUB_STEP_SUMMARY" + printf -- '- Timestamp: `%s`\n- Revision: `%s`\n- Runtime: `%s`\n' \ + "$timestamp" "$GITHUB_SHA" "$runtime" >> "$GITHUB_STEP_SUMMARY" diff --git a/docs/x509-live-verification.md b/docs/x509-live-verification.md new file mode 100644 index 000000000..a9892f526 --- /dev/null +++ b/docs/x509-live-verification.md @@ -0,0 +1,143 @@ +# X.509 issuer-to-API verification + +The Java SDK has two separate X.509 verification layers: + +- `X509TransportTest` runs in ordinary CI with an ephemeral PKI and real TLS. It verifies one fixed + certificate alias on the issuer and API legs, exact SNI and authorities, the token-exchange wire + shape, bearer placement, redirect refusal, hostname verification, server trust, and response + cleanup. +- `X509LiveVerificationTest` is an explicitly enabled production probe. It performs the exact + issuer exchange and then calls `GET https://mtls.api.openai.com/v1/models` with both the returned + bearer and the enrolled client certificate. It uses the existing raw transport capability, so it + remains useful before higher-level X.509 client integration lands. + +Ordinary tests never use live credentials. A skipped live test is **not** production evidence. + +## Hosted setup (GitHub Actions) + +Create a dedicated GitHub Actions environment named `x509-live-smoke`. It must: + +- allow deployments only from protected `main`; +- require independent approval from the SDK team; +- prevent self-review; +- disable administrator bypass; and +- contain only the X.509 secrets listed below, not API keys or other shared CI credentials. + +The organization and project must already be enrolled for X.509 workload identity. Use a dedicated +non-production project with no customer data, a short-lived test certificate, an exact +certificate-subject mapping, and a dedicated active service account with only the permission needed +to list models. Activate its public trust root only for the intended test project. Keep the root +private key and all client private material outside the repository and GitHub artifacts. + +Configure these environment secrets: + +| Secret | Purpose | +| --- | --- | +| `OPENAI_X509_KEYSTORE_P12_BASE64` | Base64-encoded PKCS#12 containing the client private key and complete certificate chain | +| `OPENAI_X509_KEYSTORE_PASSWORD` | PKCS#12 and private-key password | +| `OPENAI_X509_CERTIFICATE_ALIAS` | Exact static key entry selected for both TLS legs | +| `OPENAI_X509_IDENTITY_PROVIDER_ID` | Enrolled X.509 identity-provider identifier | +| `OPENAI_X509_SERVICE_ACCOUNT_ID` | Least-privilege mapped service-account identifier | + +Never put secret values in workflow inputs, command-line arguments, Gradle properties, repository +files, issue or pull-request text, Slack, screenshots, or logs. Produce and store the single-line +PKCS#12 base64 value using approved secret tooling with shell tracing disabled. + +## Manual workflow + +1. Confirm the certificate is current, the exact provider mapping is active, the dedicated project + contains no customer data, and the mapped account can list models. +2. Confirm the `x509-live-smoke` protection rules and secret names without reading secret values. +3. From `.github/workflows/x509-live-smoke.yml`, choose **Run workflow**, select `main`, and enable + **Confirm the protected issuer-to-mTLS API verification**. +4. An independent reviewer approves the protected environment deployment. +5. Retain only the workflow conclusion and its emitted timestamp, exact SDK revision, Java runtime, + and fixed two-stage completion statement. If a failure safely emits a sanitized request ID, keep + it only when needed for diagnosis. Never retain response bodies or credential material. + +The probe makes no issuer or API request until every required environment value is present and the +PKCS#12 identity, alias, chain, and JVM trust manager have loaded successfully. Both destinations are +hard-coded HTTPS origins, both clients are direct and non-redirecting, and every response is closed. + +## Result meanings + +- **NOT RUN**: the dispatch input was false, or the protected job started but required enrolled + credentials were unavailable. This is not a pass. +- **REQUESTED**: the canonical `main` run passed the non-secret guard and awaits or entered the + protected job. A deployment blocked or cancelled before environment approval remains requested + and is not a pass; GitHub cannot run a later summary step while approval is pending. +- **PASSED**: exactly one enabled live test obtained a valid short-lived bearer from the issuer and + completed the approved mTLS model-list request. Only the protected job writes this result. +- Any other conclusion is a failure or infrastructure interruption and must not be reported as + production verification. + +The live probe intentionally reports only fixed stage names, HTTP status codes, and sanitized +`x-request-id` values. It does not read API error bodies and discards issuer response bytes after +closing the validated response. + +## Local setup and commands + +Use a trusted machine with JDK 21 and a clean checkout. With approved certificate tooling, create a +PKCS#12 outside every Git checkout that contains one client private key followed by its complete +leaf-to-root certificate chain. Give that key entry one exact, stable alias. Use an independent +password and inject the single-line base64 PKCS#12, password, alias, provider ID, and service-account +ID through an approved local secret boundary under the five environment names in the table above. +Do not place them in a repository `.env`, shell history, Gradle properties, or command-line +arguments, and keep shell tracing disabled. + +Run deterministic real-TLS verification with no credentials: + +```shell +./gradlew :openai-java-client-okhttp:test \ + --tests '*X509TransportTest' \ + --tests '*X509LiveVerificationDiagnosticsTest' +``` + +An authorized maintainer may run the live probe from a trusted machine after setting the five +environment values above through an approved local secret boundary: + +```shell +date -u +'%Y-%m-%dT%H:%M:%SZ' +git rev-parse --verify HEAD +java -version +OPENAI_X509_LIVE_TEST=1 ./gradlew :openai-java-client-okhttp:test \ + --tests '*X509LiveVerificationTest' --no-build-cache --no-daemon --rerun-tasks +``` + +Without `OPENAI_X509_LIVE_TEST=1`, JUnit marks the probe skipped. Do not use a skipped or cached test +as launch evidence. + +Expected hosted evidence is the PASSED summary with its timestamp, exact Git revision, Java runtime, +and fixed statement that both the issuer exchange and mTLS API request completed. For a local run, +retain the three non-secret command outputs above plus the non-skipped JUnit result and fixed success +message in +`openai-java-client-okhttp/build/test-results/test/TEST-com.openai.client.okhttp.X509LiveVerificationTest.xml`. +A skipped test, a successful issuer exchange without the API leg, or an API request that did not use +the enrolled client certificate is not end-to-end evidence. + +## Safe teardown + +For a one-off fixture, first disable the exact provider subject mapping or its dedicated service +account so that it cannot mint new bearers. Allow already issued credentials to expire within their +one-hour maximum, or follow the environment owner's approved revocation procedure. Then remove the +five secrets from `x509-live-smoke` and delete the environment if it has no continuing owner. + +Deactivate or remove the public trust root only after the environment owner confirms that no other +mapping, SDK, or test fixture depends on it; never tear down a shared root as part of this runbook. +Finally, destroy the local PKCS#12, leaf private key, and any disposable CA private key using the +approved secret-store or workstation procedure. Confirm that no workflow artifact, test report, +shell history, screenshot, or retained log contains credential material. Keep only the sanitized +evidence described above. + +## Rotation and diagnosis + +Rotate the short-lived leaf before expiry by replacing the protected PKCS#12 secret, then obtain a +fresh independently approved **PASSED** run before retiring the old identity. Root rotation requires +activating the replacement public root for the test project before replacing callers; remove the old +root only with explicit environment-owner approval. + +For TLS failures, inspect certificate validity, chain order, key/alias matching, client-auth EKU, +SAN, project-scoped trust activation, and JVM server trust. For issuer authorization failures, +inspect the exact provider subject mapping and mapped account state. For API authorization failures, +inspect the mapped account's model-list permission. Do not enable wire logging or print certificates, +private keys, passwords, bearer values, request headers, or response bodies while diagnosing. diff --git a/openai-java-client-okhttp/src/test/kotlin/com/openai/client/okhttp/X509LiveVerificationTest.kt b/openai-java-client-okhttp/src/test/kotlin/com/openai/client/okhttp/X509LiveVerificationTest.kt new file mode 100644 index 000000000..2f3b9e889 --- /dev/null +++ b/openai-java-client-okhttp/src/test/kotlin/com/openai/client/okhttp/X509LiveVerificationTest.kt @@ -0,0 +1,381 @@ +package com.openai.client.okhttp + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.json.JsonMapper +import com.openai.core.Timeout +import com.openai.core.http.Headers +import com.openai.core.http.HttpMethod +import com.openai.core.http.HttpRequest +import com.openai.core.http.HttpRequestBody +import com.openai.core.http.HttpResponse +import java.io.ByteArrayInputStream +import java.io.OutputStream +import java.net.Socket +import java.security.KeyStore +import java.security.Principal +import java.security.PrivateKey +import java.security.cert.X509Certificate +import java.time.Duration +import java.util.Arrays +import java.util.Base64 +import java.util.concurrent.atomic.AtomicBoolean +import javax.net.ssl.KeyManagerFactory +import javax.net.ssl.SSLEngine +import javax.net.ssl.TrustManagerFactory +import javax.net.ssl.X509ExtendedKeyManager +import javax.net.ssl.X509TrustManager +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable + +/** + * Explicitly opt-in verification against the enrolled production issuer and mTLS API. + * + * This deliberately uses the raw, fixed-origin transport capability so the live gate remains useful + * before higher-level X.509 client integration exists. It never logs response bodies, tokens, + * certificate material, aliases, or enrollment identifiers. + */ +@EnabledIfEnvironmentVariable(named = "OPENAI_X509_LIVE_TEST", matches = "1") +internal class X509LiveVerificationTest { + private val jsonMapper = JsonMapper() + + @Test + fun enrolledCertificateCompletesIssuerAndApiLegs() { + LiveConfiguration.fromEnvironment().use { configuration -> + val keyStore = + KeyStore.getInstance("PKCS12").apply { + ByteArrayInputStream(configuration.pkcs12).use { input -> + load(input, configuration.password) + } + } + val keyManager = + KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()) + .apply { init(keyStore, configuration.password) } + .keyManagers + .filterIsInstance() + .firstOrNull() + ?: error( + "The configured PKCS#12 identity did not provide an X.509 key manager." + ) + val recordingKeyManager = + HandshakeRecordingKeyManager(keyManager, configuration.certificateAlias) + val trustManager = defaultTrustManager() + val transport = + X509Transport.builder() + .keyManager(recordingKeyManager) + .certificateAlias(configuration.certificateAlias) + .trustManager(trustManager) + .build() + + transport.bind(LIVE_TIMEOUT).use { bound -> + val accessToken = exchangeToken(bound.exchangeClient, configuration) + recordingKeyManager.requireClientAliasSelection("issuer exchange") + verifyApi(bound.apiClient, accessToken) + recordingKeyManager.requireClientAliasSelection("mTLS API") + } + } + + println("X.509 live verification passed: issuer exchange and mTLS API request completed.") + } + + private fun exchangeToken(client: OkHttpClient, configuration: LiveConfiguration): String { + val exchange = + X509LiveRequests.exchange( + jsonMapper, + configuration.identityProviderId, + configuration.serviceAccountId, + ) + + return execute(client, exchange.request, "issuer exchange").use { response -> + requireSuccessful(response, "issuer exchange") + val body = readJson(response, "issuer exchange") + validateTokenResponse(body) + } + } + + private fun verifyApi(client: OkHttpClient, accessToken: String) { + execute(client, X509LiveRequests.api(accessToken), "mTLS API").use { response -> + requireSuccessful(response, "mTLS API") + } + } + + private fun validateTokenResponse(body: JsonNode): String { + check(body.isObject) { "The issuer exchange returned an invalid response shape." } + check(body.path("token_type").asText() == "Bearer") { + "The issuer exchange returned an unexpected token type." + } + check(body.path("issued_token_type").asText() == ACCESS_TOKEN_TYPE) { + "The issuer exchange returned an unexpected issued token type." + } + val expiresIn = body.path("expires_in") + check(expiresIn.isIntegralNumber && expiresIn.asLong() in 1..MAX_TOKEN_TTL_SECONDS) { + "The issuer exchange returned an invalid token lifetime." + } + val accessToken = body.path("access_token") + check(accessToken.isTextual && BEARER_TOKEN.matches(accessToken.asText())) { + "The issuer exchange returned an invalid bearer token." + } + return accessToken.asText() + } + + private fun requireSuccessful(response: HttpResponse, stage: String) = + X509LiveDiagnostics.requireSuccessful(response, stage) + + private fun execute(client: OkHttpClient, request: HttpRequest, stage: String): HttpResponse = + try { + client.execute(request) + } catch (_: Exception) { + throw IllegalStateException("$stage failed before receiving an HTTP response.") + } + + private fun readJson(response: HttpResponse, stage: String): JsonNode = + X509LiveDiagnostics.readJson(jsonMapper, response, stage) + + private fun defaultTrustManager(): X509TrustManager = + TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) + .apply { init(null as KeyStore?) } + .trustManagers + .filterIsInstance() + .firstOrNull() + ?: error("The JVM default trust store did not provide an X.509 trust manager.") + + private companion object { + const val ACCESS_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token" + const val MAX_TOKEN_TTL_SECONDS = 3600L + val BEARER_TOKEN = Regex("^[A-Za-z0-9\\-._~+/]+=*$") + val LIVE_TIMEOUT = + Timeout.builder() + .connect(Duration.ofSeconds(20)) + .read(Duration.ofSeconds(30)) + .write(Duration.ofSeconds(30)) + .request(Duration.ofSeconds(45)) + .build() + } +} + +internal object X509LiveRequests { + private const val EXCHANGE_URL = "https://mtls.auth.openai.com/oauth/token" + private const val API_ORIGIN = "https://mtls.api.openai.com" + private const val TOKEN_EXCHANGE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange" + private const val X509_SUBJECT_TOKEN_TYPE = "urn:openai:params:oauth:token-type:x509" + + fun exchange( + jsonMapper: JsonMapper, + identityProviderId: String, + serviceAccountId: String, + ): X509LiveExchangeRequest { + val body = + ZeroizingJsonBody( + jsonMapper.writeValueAsBytes( + linkedMapOf( + "grant_type" to TOKEN_EXCHANGE_GRANT_TYPE, + "subject_token_type" to X509_SUBJECT_TOKEN_TYPE, + "identity_provider_id" to identityProviderId, + "service_account_id" to serviceAccountId, + ) + ) + ) + return X509LiveExchangeRequest( + HttpRequest.builder().method(HttpMethod.POST).baseUrl(EXCHANGE_URL).body(body).build(), + body, + ) + } + + fun api(accessToken: String): HttpRequest = + HttpRequest.builder() + .method(HttpMethod.GET) + .baseUrl(API_ORIGIN) + .addPathSegments("v1", "models") + .putHeader("Authorization", "Bearer $accessToken") + .build() +} + +internal data class X509LiveExchangeRequest(val request: HttpRequest, val body: ZeroizingJsonBody) + +internal class HandshakeRecordingKeyManager( + private val delegate: X509ExtendedKeyManager, + private val expectedAlias: String, +) : X509ExtendedKeyManager() { + private val expectedAliasWasEligible = AtomicBoolean() + + fun requireClientAliasSelection(stage: String) { + check(expectedAliasWasEligible.getAndSet(false)) { + "$stage completed without selecting the configured X.509 client certificate." + } + } + + override fun getClientAliases(keyType: String, issuers: Array?): Array? = + delegate.getClientAliases(keyType, issuers).also { aliases -> + if (aliases?.any { it == expectedAlias } == true) { + expectedAliasWasEligible.set(true) + } + } + + override fun chooseClientAlias( + keyType: Array?, + issuers: Array?, + socket: Socket?, + ): String? = delegate.chooseClientAlias(keyType, issuers, socket) + + override fun chooseEngineClientAlias( + keyType: Array?, + issuers: Array?, + engine: SSLEngine?, + ): String? = delegate.chooseEngineClientAlias(keyType, issuers, engine) + + 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) +} + +internal object X509LiveDiagnostics { + private val safeRequestId = Regex("^[A-Za-z0-9._:-]{1,128}$") + + fun requireSuccessful(response: HttpResponse, stage: String) { + check(response.statusCode() in 200..299) { + "$stage failed with HTTP ${response.statusCode()}${requestIdSuffix(response)}." + } + } + + fun readJson(jsonMapper: JsonMapper, response: HttpResponse, stage: String): JsonNode = + try { + jsonMapper.readTree(response.body()) + ?: throw IllegalStateException("$stage returned an empty JSON response.") + } catch (_: Exception) { + throw IllegalStateException("$stage returned invalid JSON${requestIdSuffix(response)}.") + } + + private fun requestIdSuffix(response: HttpResponse): String = + response + .requestId() + .orElse(null) + ?.takeIf(safeRequestId::matches) + ?.let { " (request_id=$it)" } + .orEmpty() +} + +internal class X509LiveVerificationDiagnosticsTest { + @Test + fun diagnosticsIncludeOnlySanitizedRequestIds() { + val safeResponse = StubLiveResponse(403, "req_123-abc:456", "unused") + val unsafeResponse = + StubLiveResponse(403, "request id containing sensitive text", "customer-data") + + assertThatThrownBy { X509LiveDiagnostics.requireSuccessful(safeResponse, "mTLS API") } + .isInstanceOf(IllegalStateException::class.java) + .hasMessage("mTLS API failed with HTTP 403 (request_id=req_123-abc:456).") + assertThatThrownBy { X509LiveDiagnostics.requireSuccessful(unsafeResponse, "mTLS API") } + .isInstanceOf(IllegalStateException::class.java) + .hasMessage("mTLS API failed with HTTP 403.") + .hasMessageNotContaining("sensitive") + .hasMessageNotContaining("customer-data") + } + + @Test + fun invalidIssuerBodiesAreNeverIncludedInDiagnostics() { + val response = + StubLiveResponse(200, "request id containing sensitive text", "customer-data") + + response.use { + assertThatThrownBy { + X509LiveDiagnostics.readJson(JsonMapper(), response, "issuer exchange") + } + .isInstanceOf(IllegalStateException::class.java) + .hasMessage("issuer exchange returned invalid JSON.") + .hasMessageNotContaining("sensitive") + .hasMessageNotContaining("customer-data") + } + } +} + +private class StubLiveResponse(statusCode: Int, requestId: String, body: String) : HttpResponse { + private val statusCode = statusCode + private val headers = Headers.builder().put("x-request-id", requestId).build() + private val body = ByteArrayInputStream(body.toByteArray(Charsets.UTF_8)) + + override fun statusCode(): Int = statusCode + + override fun headers(): Headers = headers + + override fun body(): ByteArrayInputStream = body + + override fun close() = body.close() +} + +private class LiveConfiguration +private constructor( + val pkcs12: ByteArray, + val password: CharArray, + val certificateAlias: String, + val identityProviderId: String, + val serviceAccountId: String, +) : AutoCloseable { + + override fun close() { + Arrays.fill(pkcs12, 0) + Arrays.fill(password, '\u0000') + } + + companion object { + fun fromEnvironment(): LiveConfiguration { + val encodedPkcs12 = requiredEnvironment("OPENAI_X509_KEYSTORE_P12_BASE64") + val password = requiredEnvironment("OPENAI_X509_KEYSTORE_PASSWORD") + val certificateAlias = requiredNonBlankEnvironment("OPENAI_X509_CERTIFICATE_ALIAS") + val identityProviderId = requiredNonBlankEnvironment("OPENAI_X509_IDENTITY_PROVIDER_ID") + val serviceAccountId = requiredNonBlankEnvironment("OPENAI_X509_SERVICE_ACCOUNT_ID") + + return LiveConfiguration( + Base64.getDecoder().decode(encodedPkcs12), + password.toCharArray(), + certificateAlias, + identityProviderId, + serviceAccountId, + ) + } + + private fun requiredEnvironment(name: String): String = + System.getenv(name)?.takeIf(String::isNotEmpty) + ?: error("$name must be configured for X.509 live verification.") + + private fun requiredNonBlankEnvironment(name: String): String = + requiredEnvironment(name).also { + check(it.isNotBlank()) { "$name must not be blank for X.509 live verification." } + } + } +} + +internal class ZeroizingJsonBody(private val bytes: ByteArray) : HttpRequestBody { + private val contentLength = bytes.size.toLong() + + var closed = false + private set + + override fun writeTo(outputStream: OutputStream) = outputStream.write(bytes) + + override fun contentType(): String = "application/json" + + override fun contentLength(): Long = contentLength + + override fun repeatable(): Boolean = false + + override fun close() { + Arrays.fill(bytes, 0) + closed = true + } +} 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 index 062875ae3..4266bf054 100644 --- 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 @@ -1,8 +1,10 @@ package com.openai.client.okhttp +import com.fasterxml.jackson.databind.json.JsonMapper import com.openai.core.Timeout import com.openai.core.http.HttpMethod import com.openai.core.http.HttpRequest +import com.openai.errors.OpenAIIoException import java.net.Socket import java.security.KeyStore import java.security.Principal @@ -10,6 +12,7 @@ import java.security.PrivateKey import java.security.cert.X509Certificate import javax.net.ssl.KeyManagerFactory import javax.net.ssl.SSLEngine +import javax.net.ssl.SSLHandshakeException import javax.net.ssl.X509ExtendedKeyManager import okhttp3.mockwebserver.MockResponse import okhttp3.tls.HandshakeCertificates @@ -18,16 +21,17 @@ import org.assertj.core.api.Assertions.assertThatThrownBy import org.junit.jupiter.api.Test internal class X509TransportTest { + private val jsonMapper = JsonMapper() @Test fun productionBindingIsDirectNonRetryingAndIsolated() { val pinned = X509TestIdentity.create("production binding identity") val transport = transport(pinned, emptyList()) + val bound = transport.bind(Timeout.default()) + val exchange = bound.exchangeClient.okHttpClient + val api = bound.apiClient.okHttpClient - transport.bind(Timeout.default()).use { bound -> - val exchange = bound.exchangeClient.okHttpClient - val api = bound.apiClient.okHttpClient - + bound.use { assertThat(exchange.proxy).isEqualTo(java.net.Proxy.NO_PROXY) assertThat(api.proxy).isEqualTo(java.net.Proxy.NO_PROXY) assertThat(exchange.followRedirects).isFalse() @@ -41,6 +45,11 @@ internal class X509TransportTest { assertThat(exchange.dispatcher.executorService) .isNotSameAs(api.dispatcher.executorService) } + + assertThat(exchange.dispatcher.executorService.isShutdown).isTrue() + assertThat(api.dispatcher.executorService.isShutdown).isTrue() + assertThat(exchange.connectionPool.connectionCount()).isZero() + assertThat(api.connectionPool.connectionCount()).isZero() } @Test @@ -49,25 +58,45 @@ internal class X509TransportTest { 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")) + authPeer.enqueue( + MockResponse() + .setHeader("Content-Type", "application/json") + .setBody(TOKEN_RESPONSE) + ) + apiPeer.enqueue( + MockResponse() + .setHeader("Content-Type", "application/json") + .setBody("""{"object":"list","data":[]}""") + ) + val recordingKeyManager = + HandshakeRecordingKeyManager( + SelectingKeyManager( + keyManager(mapOf(PINNED_ALIAS to pinned, ALTERNATE_ALIAS to alternate)), + ALTERNATE_ALIAS, + ), + PINNED_ALIAS, + ) val transport = - adversarialTransport( - pinned, - alternate, + transport( + recordingKeyManager, listOf(authPeer.serverRootCertificate, apiPeer.serverRootCertificate), ) + val exchange = X509LiveRequests.exchange(jsonMapper, "idp_test", "svc_acct_test") transport.bindForTest(Timeout.default(), authPeer.proxy, apiPeer.proxy).use { bound -> - bound.exchangeClient.execute(request(AUTH_URL)).use { response -> - assertThat(response.statusCode()).isEqualTo(200) - } + val accessToken = + bound.exchangeClient.execute(exchange.request).use { response -> + assertThat(response.statusCode()).isEqualTo(200) + jsonMapper.readTree(response.body()).path("access_token").asText() + } + recordingKeyManager.requireClientAliasSelection("issuer exchange") // 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 -> + bound.apiClient.execute(X509LiveRequests.api(accessToken)).use { response -> assertThat(response.statusCode()).isEqualTo(200) } + recordingKeyManager.requireClientAliasSelection("mTLS API") } val authConnect = authPeer.takeRequest() @@ -76,8 +105,16 @@ internal class X509TransportTest { 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.method).isEqualTo("POST") assertThat(authRequest.path).isEqualTo("/oauth/token") - assertThat(apiRequest.path).isEqualTo("/v1/files") + assertThat(authRequest.getHeader("Authorization")).isNull() + assertThat(authRequest.getHeader("Content-Type")).isEqualTo("application/json") + assertThat(authRequest.body.readUtf8()).isEqualTo(TOKEN_REQUEST) + assertThat(apiRequest.method).isEqualTo("GET") + assertThat(apiRequest.path).isEqualTo("/v1/models") + assertThat(apiRequest.getHeader("Authorization")).isEqualTo("Bearer $ACCESS_TOKEN") + assertThat(apiRequest.getHeader("api-key")).isNull() + assertThat(apiRequest.getHeader("x-api-key")).isNull() assertThat(requireNotNull(authRequest.handshake).peerCertificates.first()) .isEqualTo(pinned.leaf.certificate) assertThat(requireNotNull(apiRequest.handshake).peerCertificates.first()) @@ -88,6 +125,7 @@ internal class X509TransportTest { .doesNotContain(alternate.leaf.certificate) assertThat(authPeer.requestedServerNames).containsExactly(AUTH_HOST) assertThat(apiPeer.requestedServerNames).containsExactly(API_HOST) + assertThat(exchange.body.closed).isTrue() } } } @@ -133,6 +171,35 @@ internal class X509TransportTest { assertThat(authPeer.takeRequest().requestLine) .isEqualTo("CONNECT $API_HOST:443 HTTP/1.1") + assertThat(authPeer.requestedServerNames).containsExactly(API_HOST) + } + } + + @Test + fun productionTransportRejectsUntrustedServersOnBothLegs() { + val identity = X509TestIdentity.create("untrusted server identity") + val unrelatedRoot = X509TestIdentity.create("unrelated server identity").root.certificate + X509TestPeer(AUTH_HOST, identity.root.certificate).use { authPeer -> + X509TestPeer(API_HOST, identity.root.certificate).use { apiPeer -> + authPeer.enqueue(MockResponse()) + apiPeer.enqueue(MockResponse()) + val transport = transport(identity, listOf(unrelatedRoot)) + + transport.bindForTest(Timeout.default(), authPeer.proxy, apiPeer.proxy).use { bound + -> + assertThatThrownBy { bound.exchangeClient.execute(request(AUTH_URL)).close() } + .isInstanceOf(OpenAIIoException::class.java) + .hasCauseInstanceOf(SSLHandshakeException::class.java) + assertThatThrownBy { + bound.apiClient.execute(request("$API_URL/v1/files")).close() + } + .isInstanceOf(OpenAIIoException::class.java) + .hasCauseInstanceOf(SSLHandshakeException::class.java) + } + + assertThat(authPeer.server.requestCount).isEqualTo(1) + assertThat(apiPeer.server.requestCount).isEqualTo(1) + } } } @@ -162,19 +229,6 @@ internal class X509TransportTest { .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, @@ -229,6 +283,19 @@ internal class X509TransportTest { const val API_URL = "https://$API_HOST" const val PINNED_ALIAS = "pinned" const val ALTERNATE_ALIAS = "alternate" + const val ACCESS_TOKEN = "test-x509-access-token" + val TOKEN_REQUEST = + """{"grant_type":"urn:ietf:params:oauth:grant-type:token-exchange","subject_token_type":"urn:openai:params:oauth:token-type:x509","identity_provider_id":"idp_test","service_account_id":"svc_acct_test"}""" + val TOKEN_RESPONSE = + """ + { + "access_token": "$ACCESS_TOKEN", + "issued_token_type": "urn:ietf:params:oauth:token-type:access_token", + "token_type": "Bearer", + "expires_in": 3600 + } + """ + .trimIndent() } }