diff --git a/README.md b/README.md index f77c9ba0..9f623904 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,9 @@ Some manual setup is required, outside of `cargo`, to use this crate on Android. use Android's certificate verifier, the crate needs to call into the JVM. A small Kotlin component must be included in your app's build to support `rustls-platform-verifier`. +Certificate verification runs on the calling thread unless it is Android's main thread. In that case, verification runs on a Rust worker thread because Android's revocation checks may perform network I/O. Verification is still synchronous, because the calling thread waits for the result. +Run TLS handshakes on a background thread or dispatcher to keep the Android UI responsive. + #### Gradle Setup `rustls-platform-verifier` distributes the required native components in a Maven-compatible format via GitHub, but the project must be setup to locate them diff --git a/android/rustls-platform-verifier/src/androidTest/java/org/rustls/platformverifier/CertificateVerifierTests.kt b/android/rustls-platform-verifier/src/androidTest/java/org/rustls/platformverifier/CertificateVerifierTests.kt index 238b636d..911b7056 100644 --- a/android/rustls-platform-verifier/src/androidTest/java/org/rustls/platformverifier/CertificateVerifierTests.kt +++ b/android/rustls-platform-verifier/src/androidTest/java/org/rustls/platformverifier/CertificateVerifierTests.kt @@ -1,6 +1,7 @@ package org.rustls.platformverifier import android.content.Context +import android.os.Looper import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import org.junit.Assert.assertEquals @@ -8,6 +9,8 @@ import org.junit.Assert.assertTrue import org.junit.BeforeClass import org.junit.Test import org.junit.runner.RunWith +import java.util.concurrent.FutureTask +import kotlin.concurrent.thread private const val SUCCESS_MARKER: String = "success" private const val FAILURE_MSG: String = "A test failed. Check the logs above for Rust panics." @@ -33,9 +36,47 @@ class CertificateVerifierTests { @Test fun runMockTestSuite() { + assertEquals(null, Looper.myLooper()) + runMockTestSuiteOnCallingThread() + } + + private fun runMockTestSuiteOnCallingThread() { + assertTrue(Looper.myLooper() != Looper.getMainLooper()) val context = InstrumentationRegistry.getInstrumentation().targetContext - val result = mockTests(context) - assertEquals(FAILURE_MSG, SUCCESS_MARKER, result) + CertificateVerifier.expectedVerificationThread = Thread.currentThread() + try { + val result = mockTests(context) + assertEquals(FAILURE_MSG, SUCCESS_MARKER, result) + } finally { + CertificateVerifier.expectedVerificationThread = null + } + } + + @Test + fun runMockTestSuiteOnBackgroundThreadWithLooper() { + val task = FutureTask { + Looper.prepare() + assertTrue(Looper.myLooper() != null) + runMockTestSuiteOnCallingThread() + } + val worker = thread { task.run() } + try { + task.get() + } finally { + worker.join() + } + } + + @Test + fun runMockTestSuiteOnMainThread() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + instrumentation.runOnMainSync { + assertEquals(Looper.getMainLooper(), Looper.myLooper()) + // The test build of CertificateVerifier checks that JNI verification runs off the main + // thread, even when the Rust caller is on it. + val result = mockTests(instrumentation.targetContext) + assertEquals(FAILURE_MSG, SUCCESS_MARKER, result) + } } @Test diff --git a/android/rustls-platform-verifier/src/main/java/org/rustls/platformverifier/CertificateVerifier.kt b/android/rustls-platform-verifier/src/main/java/org/rustls/platformverifier/CertificateVerifier.kt index 618f5eef..3f696395 100644 --- a/android/rustls-platform-verifier/src/main/java/org/rustls/platformverifier/CertificateVerifier.kt +++ b/android/rustls-platform-verifier/src/main/java/org/rustls/platformverifier/CertificateVerifier.kt @@ -4,6 +4,7 @@ import android.annotation.SuppressLint import android.content.Context import android.net.http.X509TrustManagerExtensions import android.os.Build +import android.os.Looper import android.util.Log import java.io.ByteArrayInputStream import java.io.File @@ -96,6 +97,9 @@ internal object CertificateVerifier { // Ideally, all of this will be optimized out at compile time due to not being accessed // in release builds. + @Volatile + var expectedVerificationThread: Thread? = null + @get:Synchronized private val mockKeystore: KeyStore = KeyStore.getInstance(KeyStore.getDefaultType()) @@ -188,6 +192,17 @@ internal object CertificateVerifier { time: Long, certChain: Array, ): VerificationResult { + if (BuildConfig.TEST) { + check(Looper.myLooper() != Looper.getMainLooper()) { + "Certificate verification must run off the main thread" + } + expectedVerificationThread?.let { expected -> + check(Thread.currentThread() === expected) { + "Background verification must stay on the calling thread" + } + } + } + // Convert the array of (supposedly) DER bytes into certificates. val certificateChain = mutableListOf() certChain.forEach { certBytes -> diff --git a/rustls-platform-verifier/src/android.rs b/rustls-platform-verifier/src/android.rs index 9af5c794..f43aef45 100644 --- a/rustls-platform-verifier/src/android.rs +++ b/rustls-platform-verifier/src/android.rs @@ -204,6 +204,34 @@ impl<'env> LocalContext<'_, 'env> { } } +/// Checks whether the current thread is Android's main thread. +pub(super) fn is_main_thread() -> Result { + global().vm().attach_current_thread_for_scope(|env| { + let looper = env.find_class(jni_str!("android/os/Looper"))?; + let current = env + .call_static_method( + &looper, + jni_str!("myLooper"), + jni_sig!(() -> android.os.Looper), + &[], + )? + .l()?; + if current.is_null() { + return Ok(false); + } + + let main = env + .call_static_method( + &looper, + jni_str!("getMainLooper"), + jni_sig!(() -> android.os.Looper), + &[], + )? + .l()?; + Ok(env.is_same_object(current, main)?) + }) +} + /// Borrow the Android application context and execute the closure /// `with_context, ensuring locals are properly freed and exceptions /// are cleared. diff --git a/rustls-platform-verifier/src/verification/android.rs b/rustls-platform-verifier/src/verification/android.rs index 3916a5fa..34960885 100644 --- a/rustls-platform-verifier/src/verification/android.rs +++ b/rustls-platform-verifier/src/verification/android.rs @@ -12,9 +12,10 @@ use rustls::{ CertificateError, DigitallySignedStruct, Error as TlsError, OtherError, SignatureScheme, }; use std::sync::Arc; +use std::thread; use super::{log_server_cert, ALLOWED_EKUS}; -use crate::android::{with_context, CachedClass}; +use crate::android::{is_main_thread, with_context, CachedClass}; static CERT_VERIFIER_CLASS: CachedClass = CachedClass::new(jni_str!("org.rustls.platformverifier.CertificateVerifier")); @@ -293,7 +294,35 @@ impl ServerCertVerifier for Verifier { None }; - match self.verify_certificate(end_entity, intermediates, server_name, ocsp_data, now) { + let verify = + || self.verify_certificate(end_entity, intermediates, server_name, ocsp_data, now); + + // JNI calls run on the calling thread. Android's revocation checks can perform network + // I/O, so move them to a worker thread when called on Android's main thread to avoid + // NetworkOnMainThreadException. + let result = match is_main_thread() { + Ok(false) => verify(), + Ok(true) => thread::scope(|scope| { + let worker = thread::Builder::new() + .name("rustls-platform-verifier".into()) + .spawn_scoped(scope, verify) + .map_err(|e| { + TlsError::General(format!( + "failed to spawn certificate verification thread: {e}" + )) + })?; + + match worker.join() { + Ok(result) => result, + Err(panic) => std::panic::resume_unwind(panic), + } + }), + Err(e) => Err(TlsError::General(format!( + "failed to determine Android verification thread: {e:?}" + ))), + }; + + match result { Ok(()) => Ok(rustls::client::danger::ServerCertVerified::assertion()), Err(e) => { // This error only tells us what the system errored with, so it doesn't leak anything