Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 139 additions & 0 deletions .github/workflows/android-containers.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
# Post-quantum TLS test for Android: a PQC-only server runs as a Docker container on the runner host
# and the emulator reaches it at 10.0.2.2. Opt-in (heavy: emulator + Docker) via label or manual run.
#
# The emulator setup mirrors the `android` job in build.yml (extracted from testing_ech), including the
# experimental API 37 (16 KB page-size) entry.
name: android-containers

on:
workflow_dispatch:
pull_request:
types: [labeled, synchronize]

permissions:
contents: read

jobs:
android-pqc:
# Only run on manual dispatch or when the PR carries the "android-containers" label.
if: >
github.event_name == 'workflow_dispatch'
|| contains(github.event.pull_request.labels.*.name, 'android-containers')
runs-on: ubuntu-latest
timeout-minutes: 30
continue-on-error: ${{ matrix.experimental || false }}

strategy:
fail-fast: false
matrix:
include:
- api-level: 34
arch: x86_64
target: default
# API 37 only ships 16 KB page-size images; playstore_ps16k resolves to
# google_apis_playstore_ps16k. Quoted so YAML keeps the ".0".
- api-level: "37.0"
arch: x86_64
target: playstore_ps16k
memory: 4096
gpu: -gpu auto
experimental: true

steps:
- name: Checkout
uses: actions/checkout@v7

- name: Configure JDK
uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: 21

- name: Enable KVM group perms
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm

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

Check failure

Code scanning / zizmor

unpinned action reference Error

unpinned action reference

Check failure

Code scanning / Semgrep OSS

Insecure GitHub Actions: Third-Party Action Not Pinned to Commit SHA Error

Insecure GitHub Actions: Third-Party Action Not Pinned to Commit SHA
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed

- name: Warm the Gradle cache
run: ./gradlew :android-test:assembleDebugAndroidTest

# OpenSSL >= 3.5 ships native ML-KEM; restrict the server to the PQC hybrid group so a
# successful handshake proves the group was negotiated. Maps container 4433 -> host 8443.
- name: Start post-quantum server
run: |
docker run -d --name pqc-server -p 8443:4433 alpine:3.22 sh -c '
apk add --no-cache openssl &&
openssl req -x509 -newkey rsa:2048 -nodes -days 1 -subj /CN=localhost \
-keyout /key.pem -out /cert.pem &&
openssl s_server -accept 4433 -cert /cert.pem -key /key.pem \
-groups X25519MLKEM768 -tls1_3 -www
'
# Wait for the TLS port to accept connections.
for i in $(seq 1 30); do
if openssl s_client -connect 127.0.0.1:8443 -groups X25519MLKEM768 </dev/null 2>/dev/null | grep -q "BEGIN CERTIFICATE"; then
echo "PQC server ready" && break
fi
sleep 2
done

- name: AVD System Image Cache
uses: actions/cache@v6
id: avd-cache
with:
key: avd-${{ runner.os }}-${{ matrix.api-level }}-${{ matrix.target }}-${{ matrix.arch }}
path: |
~/.android/avd/*
~/.android/adb*
${{ env.ANDROID_HOME }}/system-images/android-${{ matrix.api-level }}

- name: Create AVD and generate snapshot for caching
if: steps.avd-cache.outputs.cache-hit != 'true'
uses: reactivecircus/android-emulator-runner@v2

Check failure

Code scanning / zizmor

unpinned action reference Error

unpinned action reference

Check failure

Code scanning / Semgrep OSS

Insecure GitHub Actions: Third-Party Action Not Pinned to Commit SHA Error

Insecure GitHub Actions: Third-Party Action Not Pinned to Commit SHA
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
with:
api-level: ${{ matrix.api-level }}
force-avd-creation: false
target: ${{ matrix.target }}
arch: ${{ matrix.arch }}
emulator-boot-timeout: 120
emulator-options: >
-no-window
${{ matrix.gpu || '-gpu swiftshader_indirect' }}
-noaudio
-no-boot-anim
-camera-back none
-memory ${{ matrix.memory || '2048' }}
-feature GLDirectMem,HasSharedSlotsHostMemoryAllocator
disable-animations: true
script: echo "Generated AVD snapshot for caching."

# 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

Check failure

Code scanning / zizmor

unpinned action reference Error

unpinned action reference

Check failure

Code scanning / Semgrep OSS

Insecure GitHub Actions: Third-Party Action Not Pinned to Commit SHA Error

Insecure GitHub Actions: Third-Party Action Not Pinned to Commit SHA
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
with:
api-level: ${{ matrix.api-level }}
target: ${{ matrix.target }}
arch: ${{ matrix.arch }}
emulator-boot-timeout: 120
emulator-options: >
-no-window
${{ matrix.gpu || '-gpu swiftshader_indirect' }}
-noaudio
-no-boot-anim
-camera-back none
-memory ${{ matrix.memory || '2048' }}
-feature GLDirectMem,HasSharedSlotsHostMemoryAllocator
script: >
./gradlew -PandroidBuild=true :android-test:connectedCheck
-Pandroid.testInstrumentationRunnerArguments.class=okhttp.android.test.PostQuantumAndroidTest
-Pandroid.testInstrumentationRunnerArguments.pqcServerUrl=https://10.0.2.2:8443/
env:
API_LEVEL: ${{ matrix.api-level }}

- name: Stop post-quantum server
if: always()
run: docker rm -f pqc-server || true
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
/*
* Copyright (C) 2026 Square, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package okhttp.android.test

import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.test.platform.app.InstrumentationRegistry
import assertk.assertThat
import assertk.assertions.contains
import assertk.assertions.isEqualTo
import java.security.SecureRandom
import java.security.Security
import java.security.cert.X509Certificate
import javax.net.ssl.SSLContext
import javax.net.ssl.X509TrustManager
import okhttp3.ConnectionSpec
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.NamedGroup
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.internal.platform.PlatformRegistry
import org.conscrypt.Conscrypt
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assumptions.assumeTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test

/**
* Exercises post-quantum named-group key exchange on a real Android device/emulator.
*
* Unlike the JVM container tests, the server runs as a Docker container on the *host*: the emulator
* reaches it at `10.0.2.2`. The PQC-only server URL is passed in as the `pqcServerUrl` instrumentation
* argument (see the `android-containers` workflow); the test skips when it is absent, so it is inert
* in the regular `connectedCheck` matrix.
*
* Android's system Conscrypt has no PQC, so this installs the bundled `conscrypt-android` (2.6+),
* which does. A successful HTTPS response against a server restricted to [NamedGroup.X25519MLKEM768]
* proves the group was negotiated.
*
* Note: applying [ConnectionSpec.Builder.namedGroups] on Android needs the Android implementation of
* `applyNamedGroups` (tracked separately). Until that lands the spec setting is a no-op on device, so
* this test relies on the provider's default group preference; once it lands the configuration is
* honored explicitly.
*/
class PostQuantumAndroidTest {
private val serverUrl: String? =
InstrumentationRegistry.getArguments().getString("pqcServerUrl")

private lateinit var client: OkHttpClient

@BeforeEach
fun setup() {
PlatformRegistry.applicationContext = ApplicationProvider.getApplicationContext<Context>()
Security.insertProviderAt(Conscrypt.newProviderBuilder().build(), 1)
}

@AfterEach
fun tearDown() {
Security.removeProvider("Conscrypt")
if (::client.isInitialized) {
client.dispatcher.executorService.shutdown()
client.connectionPool.evictAll()
}
}

@Test
fun negotiatesPostQuantumGroup() {
assumeTrue(serverUrl != null, "pqcServerUrl not set; skipping (needs the PQC server container)")
assumeTrue(conscryptSupportsPostQuantum(), "bundled Conscrypt < 2.6 has no post-quantum key exchange")

// Conscrypt is installed above; cert validation is bypassed because this is about key exchange,
// not authentication (the container uses a throwaway self-signed certificate).
val sslContext =
SSLContext.getInstance("TLS").apply {
init(null, arrayOf(trustAllManager), SecureRandom())
}

client =
OkHttpClient
.Builder()
.sslSocketFactory(sslContext.socketFactory, trustAllManager)
.hostnameVerifier { _, _ -> true }
.connectionSpecs(
listOf(
ConnectionSpec
.Builder(ConnectionSpec.RESTRICTED_TLS)
.namedGroups(NamedGroup.X25519MLKEM768)
.build(),
),
).build()

val response = client.newCall(Request(serverUrl!!.toHttpUrl())).execute()
response.use {
assertThat(response.code).isEqualTo(200)
// `openssl s_server -www` echoes the negotiated parameters in its HTML status page.
assertThat(response.body.string()).contains("Protocol")
}
}

companion object {
/** Conscrypt added X25519MLKEM768 and SSLParameters.setNamedGroups in 2.6. */
private fun conscryptSupportsPostQuantum(): Boolean {
val version = Conscrypt.version()
return version.major() > 2 || (version.major() == 2 && version.minor() >= 6)
}

private val trustAllManager =
object : X509TrustManager {
override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) {}

override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) {}

override fun getAcceptedIssuers(): Array<X509Certificate> = arrayOf()
}
}
}
Loading
Loading