Skip to content

Add named groups (supported_groups) to ConnectionSpec for post-quantum key exchange#9517

Open
yschimke wants to merge 14 commits into
square:masterfrom
yschimke:claude/zealous-faraday-39c01q
Open

Add named groups (supported_groups) to ConnectionSpec for post-quantum key exchange#9517
yschimke wants to merge 14 commits into
square:masterfrom
yschimke:claude/zealous-faraday-39c01q

Conversation

@yschimke

Copy link
Copy Markdown
Collaborator

Resolves #9497.

Summary

Adds the ability to configure TLS 1.3 named groups (the supported_groups extension) on a ConnectionSpec, so callers can require or prefer post-quantum hybrid key exchange such as X25519MLKEM768. Previously ConnectionSpec only exposed cipher suites and TLS versions, and the key-exchange algorithm is negotiated separately via supported_groups — so there was no way to require, restrict, or prefer PQC key exchange.

val pqcSpec = ConnectionSpec.Builder(ConnectionSpec.MODERN_TLS)
    .namedGroups(NamedGroup.X25519MLKEM768, NamedGroup.X25519, NamedGroup.SECP256R1)
    .build()

yschimke added 7 commits June 27, 2026 20:34
Allow configuring the TLS 1.3 supported_groups extension on a
ConnectionSpec, so callers can require or prefer post-quantum hybrid
key exchange such as X25519MLKEM768.

- New NamedGroup enum modelling the PQC hybrids, classical curves and
  ffdhe groups, with raw-string escape hatches on the builder.
- ConnectionSpec.Builder.namedGroups(...) / allEnabledNamedGroups().
- Applied via SSLParameters.setNamedGroups reflectively, available on
  Java 20+ and recent Conscrypt; silently ignored on older platforms.
- Defaults unchanged (null = defer to the platform).

Refs square#9497
…WebServer

Replace the reflective SSLParameters.setNamedGroups call with a
multi-release JAR variant:

- okhttp3.internal.platform.NamedGroups (compiled from NamedGroups.kt)
  is a no-op base used on Android and Java < 20.
- src/jvmMain/java20 provides a Java 20 override packaged into
  META-INF/versions/20, calling SSLParameters.setNamedGroups directly.
  No reflection, no runtime version checks.

Also add MockWebServer.namedGroups so the server side can require a
named group (e.g. a PQC hybrid) during the handshake, enabling
end-to-end tests.
Two gated tests in container-tests, where OkHttp is consumed as the
multi-release jar so the Java 20 setNamedGroups variant is active:

- PostQuantumMockWebServerTest: loopback MockWebServer restricted to
  X25519MLKEM768. A PQC client connects (positive) and a classical-only
  client is rejected (negative), proving the server restriction.
- PostQuantumContainerTest: openssl s_server (-groups X25519MLKEM768)
  in a container; a successful HTTPS response proves PQC negotiation.

Both skip unless the client provider supports PQC key exchange
(JDK 27+ / Conscrypt 2.6+).
Conscrypt 2.6 adds the X25519MLKEM768 hybrid named group and
SSLParameters.setNamedGroups support, so the post-quantum tests can run
on the conscrypt platform lane (JDK 20+) rather than only on JDK 27.

Register PlatformRule in the PQC container tests so Conscrypt is
installed as the security provider on that lane, and widen the
capability gate to JDK 27+ OR Conscrypt.
A PQC-only server runs as a Docker container on the runner host; the
emulator reaches it at 10.0.2.2. PostQuantumAndroidTest installs the
bundled Conscrypt 2.6 provider and requires X25519MLKEM768, skipping
unless the pqcServerUrl instrumentation arg is supplied.

The android-containers workflow starts the openssl s_server container,
then runs the test on the emulator. Emulator config is copied from the
android job in build.yml as a starting point.

Applying ConnectionSpec.namedGroups on Android needs the Android
implementation of applyNamedGroups, which lands separately.
Conscrypt only gained X25519MLKEM768 and SSLParameters.setNamedGroups in
2.6, so gate the post-quantum tests on Conscrypt.version() >= 2.6 rather
than assuming any Conscrypt provider supports it.
@yschimke yschimke requested a review from swankjesse June 27, 2026 20:50
Comment thread .github/workflows/android-containers.yml Fixed
Comment thread .github/workflows/android-containers.yml Fixed
Comment thread .github/workflows/android-containers.yml Fixed
Comment thread .github/workflows/android-containers.yml Fixed
Comment thread .github/workflows/android-containers.yml Fixed
Comment thread .github/workflows/android-containers.yml Fixed
Split the Android PQC test into two: one that installs the bundled
conscrypt-android (2.6+), and a systemProviderNegotiatesPostQuantumGroup
probe that uses the device's system TLS stack with no bundled provider.
The probe runs only on API 37+ (in the experimental, non-blocking
emulator lane) to empirically answer whether the platform itself
negotiates X25519MLKEM768.
@yschimke yschimke added the containers Container tests (Docker) label Jun 27, 2026
yschimke added 3 commits June 27, 2026 21:40
systemProviderDoesNotYetNegotiatePostQuantumGroup now asserts the
handshake FAILS: we expect the API 37 system TLS stack not to negotiate
X25519MLKEM768. If it unexpectedly succeeds the test fails loudly,
signalling that platform PQC has landed and OkHttp's Android handling
should be updated.
Conscrypt 2.6-alpha4 registers an XDH KeyPairGenerator whose
initialize(AlgorithmParameterSpec) always throws, but SunJSSE's
XDHKeyExchange passes a NamedParameterSpec. So when the alpha is a
registered JSSE provider it breaks classical X25519 (e.g. Robolectric's
Maven fetch in :android-test:testDebugUnitTest, and the conscrypt
jvmTest lane).

Revert the global org-conscrypt to the stable 2.5.2 and add a separate
conscrypt-pqc (2.6-alpha4) used only where Conscrypt drives the whole
handshake via its own SSLContext (the instrumented PQC test and the
conscrypt container-tests lane), which bypasses the broken JCA path.
The default container-tests run is JDK 21 + SunJSSE, where the PQC tests
skip. Add a Conscrypt-platform pass that re-runs just PostQuantum* so
X25519MLKEM768 is actually negotiated. Non-blocking, since it depends on
a pre-release Conscrypt.
@yschimke

Copy link
Copy Markdown
Collaborator Author

Prior art: how other HTTP/TLS stacks configure named groups

Named-group / post-quantum key-exchange configuration varies a lot across ecosystems — from per-client (Go), to per-provider (rustls), to a global JVM property (Netty/JDK), to no app-level API at all (Python). OkHttp's ConnectionSpec.namedGroups follows the per-configuration model.

Rust — reqwest / rustls (per-CryptoProvider; PQC on by default)
rustls with the aws-lc-rs backend enables X25519MLKEM768 first by default via the prefer-post-quantum feature. To set groups explicitly you build a CryptoProvider:

use rustls::crypto::{aws_lc_rs, CryptoProvider};

let provider = CryptoProvider {
    kx_groups: vec![
        rustls_post_quantum::X25519MLKEM768,
        aws_lc_rs::kx_group::X25519,
        aws_lc_rs::kx_group::SECP256R1,
    ],
    ..aws_lc_rs::default_provider()
};
provider.install_default().unwrap(); // process-wide; or .with_crypto_provider() per ClientConfig

Docs: CryptoProvider · rustls-post-quantum · rustls PQ key-exchange notes

Go — net/http / crypto/tls (per-tls.Config; closest analog to OkHttp)

client := &http.Client{Transport: &http.Transport{
    TLSClientConfig: &tls.Config{
        CurvePreferences: []tls.CurveID{tls.X25519MLKEM768, tls.X25519},
    },
}}

X25519MLKEM768 is the default since Go 1.24. Docs: tls.Config.CurvePreferences · Go 1.24 release notes

Netty (JVM) — no first-class API
SslContextBuilder exposes no named-groups setter. With the JDK provider, groups come from the global JVM system property (process-wide, not per-SslContext):

-Djdk.tls.namedGroups=X25519MLKEM768,x25519,secp256r1

For the OpenSSL/tcnative (BoringSSL) provider, configuring supported_groups is still an open request. Docs: SslContextBuilder · netty-tcnative#567 · jdk.tls.namedGroups (JEP 527)

Python — requests / urllib3 — no app-level API
ssl.SSLContext can only set a single curve, which can't express a hybrid PQC group or a list:

import ssl
ctx = ssl.create_default_context()
ctx.set_ecdh_curve("x25519")  # one curve only

A multi-group set_groups() / get_groups() API is only proposed. In practice PQC is transparent when Python links OpenSSL 3.5 (which sends X25519MLKEM768 by default) — there's no requests-level switch. Docs: set_ecdh_curve · cpython#136306 (proposed set_groups)

@yschimke

yschimke commented Jun 28, 2026

Copy link
Copy Markdown
Collaborator Author

Should OkHttp set named groups by default (and how does jdk.tls.namedGroups fit)?

jdk.tls.namedGroups is the JVM-wide system property that overrides the default named-group list for all SunJSSE TLS in the process. The question: should OkHttp ship a default named-group list (e.g. bake X25519MLKEM768 into MODERN_TLS), or leave namedGroups unset (null) and defer to the platform default / this property?

OkHttp's current behavior: namedGroups defaults to null, and the implementation only calls SSLParameters.setNamedGroups(...) when it is explicitly set. So an unset ConnectionSpec leaves jdk.tls.namedGroups and the platform default fully in effect — OkHttp does not clobber them.

Option A — keep the default null (defer to platform / jdk.tls.namedGroups) ✅ AI recommended

Pros

  • Zero maintenance. OkHttp doesn't track an evolving named-group list (unlike the cipher-suite treadmill).
  • Free PQC rollout. Platforms already move the default for us: JDK 27 (JEP 527) puts X25519MLKEM768 first by default; Conscrypt 2.6 likewise. Deferring means users get PQC automatically as their platform adopts it.
  • Respects operator control. jdk.tls.namedGroups is the standard, documented knob; OkHttp not overriding it means ops can tune groups JVM-wide without fighting the library.
  • No connectivity risk. A pinned-but-stale list could exclude a group a server needs, or include one a provider rejects. Deferring avoids that.
  • Provider-agnostic. SunJSSE, Conscrypt, and BouncyCastle each have their own sensible defaults; we don't second-guess them.

Cons

  • Non-deterministic across environments. Behavior depends on JDK/provider version, so two deployments can negotiate different groups.
  • No guaranteed PQC. On platforms whose default lacks PQC, users get none unless they opt in.

Option B — bake a default list into MODERN_TLS/RESTRICTED_TLS

Pros

  • Deterministic, version-pinned behavior tied to the OkHttp release, consistent with how cipher suites and TLS versions are already curated.
  • Could guarantee PQC even where the platform default doesn't include it yet.

Cons

  • Overrides jdk.tls.namedGroups. Setting groups on the socket wins over the JVM property, taking control away from operators — a surprising, hard-to-debug regression for anyone relying on the property.
  • Maintenance + breakage risk. OkHttp would own a list that must track new groups every release; a stale list silently blocks newer/better key exchange or breaks servers that require a group we dropped.
  • Provider/version skew. A baked name may be unsupported on some providers/versions; setNamedGroups is best-effort, so failures are silent and inconsistent.
  • Redundant with the platform. On JDK 27 / Conscrypt 2.6 the default already leads with X25519MLKEM768, so pinning mostly duplicates the platform at the cost of the above.

AI Recommendation

Keep namedGroups defaulting to null (Option A). Ship the per-ConnectionSpec API so callers who need to require or restrict PQC can, and document jdk.tls.namedGroups as the JVM-wide alternative. Don't bake a default list into MODERN_TLS now — revisit only if a single PQC group becomes universally supported and a hard default is clearly worth the maintenance and override cost.

Move the android-pqc emulator job into the containers workflow and delete
the standalone android-containers.yml. It's gated like test_containers
(master or the 'containers' label) and adds workflow_dispatch for manual
runs.
sudo udevadm trigger --name-match=kvm

- name: Setup Gradle
uses: gradle/actions/setup-gradle@v5

- name: Create AVD and generate snapshot for caching
if: steps.avd-cache.outputs.cache-hit != 'true'
uses: reactivecircus/android-emulator-runner@v2
# The emulator reaches the host container at 10.0.2.2; pass the endpoint to the test and run
# only the PQC test class. Boot options must match the snapshot step above.
- name: Run post-quantum Android test
uses: reactivecircus/android-emulator-runner@v2
sudo udevadm trigger --name-match=kvm

- name: Setup Gradle
uses: gradle/actions/setup-gradle@v5

- name: Create AVD and generate snapshot for caching
if: steps.avd-cache.outputs.cache-hit != 'true'
uses: reactivecircus/android-emulator-runner@v2
# The emulator reaches the host container at 10.0.2.2; pass the endpoint to the test and run
# only the PQC test class. Boot options must match the snapshot step above.
- name: Run post-quantum Android test
uses: reactivecircus/android-emulator-runner@v2
yschimke added 2 commits June 28, 2026 12:31
Replace the NamedGroup enum with a class that wraps javaName, exposes the
known groups as constants, and interns via forJavaName(). forJavaName now
accepts arbitrary names, so unknown groups round-trip through the typed
ConnectionSpec.namedGroups view instead of being dropped.

Drop the redundant Builder.namedGroups(vararg String) overload: callers
pass NamedGroup.forJavaName("...") for groups not enumerated here.
Drop the interning map and synchronized forJavaName; equality/hashCode
are based on javaName, so two NamedGroups with the same name are equal
without a shared cache.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

containers Container tests (Docker)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add namedGroups/supportedGroups to ConnectionSpec for post-quantum TLS key exchange

2 participants