diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 94ac528596..afaa4d24ee 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -260,6 +260,8 @@ dependencies { implementation(libs.androidx.work.runtime.ktx) implementation(libs.bitwarden.sdk) implementation(libs.bumptech.glide) + implementation(libs.bumptech.glide.okhttp) + ksp(libs.bumptech.glide.compiler) implementation(libs.google.hilt.android) ksp(libs.google.hilt.compiler) implementation(libs.kotlinx.collections.immutable) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/glide/BitwardenAppGlideModule.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/glide/BitwardenAppGlideModule.kt new file mode 100644 index 0000000000..6e40b7167f --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/glide/BitwardenAppGlideModule.kt @@ -0,0 +1,59 @@ +package com.x8bit.bitwarden.ui.platform.glide + +import android.content.Context +import com.bitwarden.network.ssl.createMtlsOkHttpClient +import com.bumptech.glide.Glide +import com.bumptech.glide.Registry +import com.bumptech.glide.annotation.GlideModule +import com.bumptech.glide.integration.okhttp3.OkHttpUrlLoader +import com.bumptech.glide.load.model.GlideUrl +import com.bumptech.glide.module.AppGlideModule +import com.x8bit.bitwarden.data.platform.manager.CertificateManager +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent +import java.io.InputStream + +/** + * Custom Glide module for the Bitwarden app that configures Glide to use an OkHttpClient + * with mTLS (mutual TLS) support. + * + * This ensures that all icon/image loading requests through Glide present the client certificate + * for mutual TLS authentication, allowing them to pass through Cloudflare's mTLS checks. + * + * The configuration mirrors the SSL setup used in RetrofitsImpl for API calls. + */ +@GlideModule +class BitwardenAppGlideModule : AppGlideModule() { + + /** + * Entry point to access Hilt-provided dependencies from non-Hilt managed classes. + */ + @EntryPoint + @InstallIn(SingletonComponent::class) + interface BitwardenGlideEntryPoint { + /** + * Provides access to the [CertificateManager] for mTLS certificate management. + */ + fun certificateManager(): CertificateManager + } + + override fun registerComponents(context: Context, glide: Glide, registry: Registry) { + // Get CertificateManager from Hilt + val entryPoint = EntryPointAccessors.fromApplication( + context = context.applicationContext, + entryPoint = BitwardenGlideEntryPoint::class.java, + ) + val certificateManager = entryPoint.certificateManager() + + // Register OkHttpUrlLoader that uses our mTLS OkHttpClient + registry.replace( + GlideUrl::class.java, + InputStream::class.java, + OkHttpUrlLoader.Factory(certificateManager.createMtlsOkHttpClient()), + ) + } + + override fun isManifestParsingEnabled(): Boolean = false +} diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/glide/BitwardenAppGlideModuleTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/glide/BitwardenAppGlideModuleTest.kt new file mode 100644 index 0000000000..9ef1865a48 --- /dev/null +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/glide/BitwardenAppGlideModuleTest.kt @@ -0,0 +1,47 @@ +package com.x8bit.bitwarden.ui.platform.glide + +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +/** + * Test class for [BitwardenAppGlideModule] to verify mTLS configuration is properly applied + * to Glide without requiring a real mTLS server. + * + * These tests verify the module's structure and that it can be instantiated. + * Full integration testing requires running the app and checking logcat for + * "BitwardenGlide" logs when images are loaded. + */ +class BitwardenAppGlideModuleTest { + + @Test + fun `BitwardenAppGlideModule should be instantiable`() { + // Verify the module can be created + val module = BitwardenAppGlideModule() + + assertNotNull(module) + } + + @Test + fun `BitwardenAppGlideModule should have EntryPoint interface for Hilt dependency injection`() { + // Verify the Hilt EntryPoint interface exists for accessing CertificateManager + val entryPointInterface = BitwardenAppGlideModule::class.java + .declaredClasses + .firstOrNull { it.simpleName == "BitwardenGlideEntryPoint" } + + assertNotNull(entryPointInterface) + } + + @Test + fun `BitwardenGlideEntryPoint should declare certificateManager method`() { + // Verify the EntryPoint has the required method to access CertificateManager + val entryPointInterface = BitwardenAppGlideModule::class.java + .declaredClasses + .firstOrNull { it.simpleName == "BitwardenGlideEntryPoint" } + + val methods = requireNotNull(entryPointInterface).declaredMethods + val hasCertificateManagerMethod = methods.any { it.name == "certificateManager" } + + assertTrue(hasCertificateManagerMethod) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f0bb7275b7..3520ba4b29 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -34,7 +34,8 @@ bitwardenSdk = "2.0.0-4818-c1e4bb66" crashlytics = "3.0.6" detekt = "1.23.8" firebaseBom = "34.8.0" -glide = "1.0.0-beta01" +glide = "5.0.5" +glideCompose = "1.0.0-beta01" googleGuava = "33.5.0-jre" googleProtoBufJava = "4.33.4" googleProtoBufPlugin = "0.9.6" @@ -98,7 +99,9 @@ androidx-security-crypto = { module = "androidx.security:security-crypto", versi androidx-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "androidxSplash" } androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "androidxWork" } bitwarden-sdk = { module = "com.bitwarden:sdk-android", version.ref = "bitwardenSdk" } -bumptech-glide = { module = "com.github.bumptech.glide:compose", version.ref = "glide" } +bumptech-glide = { module = "com.github.bumptech.glide:compose", version.ref = "glideCompose" } +bumptech-glide-okhttp = { module = "com.github.bumptech.glide:okhttp3-integration", version.ref = "glide" } +bumptech-glide-compiler = { module = "com.github.bumptech.glide:ksp", version.ref = "glide" } detekt-detekt-formatting = { module = "io.gitlab.arturbosch.detekt:detekt-formatting", version.ref = "detekt" } detekt-detekt-rules = { module = "io.gitlab.arturbosch.detekt:detekt-rules-libraries", version.ref = "detekt" } google-firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebaseBom" } diff --git a/network/src/main/kotlin/com/bitwarden/network/retrofit/RetrofitsImpl.kt b/network/src/main/kotlin/com/bitwarden/network/retrofit/RetrofitsImpl.kt index 8d0a6efe8e..263cb4f4f3 100644 --- a/network/src/main/kotlin/com/bitwarden/network/retrofit/RetrofitsImpl.kt +++ b/network/src/main/kotlin/com/bitwarden/network/retrofit/RetrofitsImpl.kt @@ -5,8 +5,8 @@ import com.bitwarden.network.interceptor.AuthTokenManager import com.bitwarden.network.interceptor.BaseUrlInterceptor import com.bitwarden.network.interceptor.BaseUrlInterceptors import com.bitwarden.network.interceptor.HeadersInterceptor -import com.bitwarden.network.ssl.BitwardenX509ExtendedKeyManager import com.bitwarden.network.ssl.CertificateProvider +import com.bitwarden.network.ssl.configureSsl import com.bitwarden.network.util.HEADER_KEY_AUTHORIZATION import kotlinx.serialization.json.Json import okhttp3.MediaType.Companion.toMediaType @@ -15,11 +15,6 @@ import okhttp3.logging.HttpLoggingInterceptor import retrofit2.Retrofit import retrofit2.converter.kotlinx.serialization.asConverterFactory import timber.log.Timber -import java.security.KeyStore -import javax.net.ssl.SSLContext -import javax.net.ssl.TrustManager -import javax.net.ssl.TrustManagerFactory -import javax.net.ssl.X509TrustManager /** * Primary implementation of [Retrofits]. @@ -97,7 +92,7 @@ internal class RetrofitsImpl( private val baseOkHttpClient: OkHttpClient = OkHttpClient.Builder() .addInterceptor(headersInterceptor) - .configureSsl() + .configureSsl(certificateProvider = certificateProvider) .build() private val authenticatedOkHttpClient: OkHttpClient by lazy { @@ -149,28 +144,5 @@ internal class RetrofitsImpl( ) .build() - private fun createSslTrustManagers(): Array = - TrustManagerFactory - .getInstance(TrustManagerFactory.getDefaultAlgorithm()) - .apply { init(null as KeyStore?) } - .trustManagers - - private fun createSslContext(certificateProvider: CertificateProvider): SSLContext = SSLContext - .getInstance("TLS").apply { - init( - arrayOf( - BitwardenX509ExtendedKeyManager(certificateProvider = certificateProvider), - ), - createSslTrustManagers(), - null, - ) - } - - private fun OkHttpClient.Builder.configureSsl(): OkHttpClient.Builder = - sslSocketFactory( - createSslContext(certificateProvider = certificateProvider).socketFactory, - createSslTrustManagers().first() as X509TrustManager, - ) - //endregion Helper properties and functions } diff --git a/network/src/main/kotlin/com/bitwarden/network/ssl/SslCertificateUtils.kt b/network/src/main/kotlin/com/bitwarden/network/ssl/SslCertificateUtils.kt new file mode 100644 index 0000000000..795734434d --- /dev/null +++ b/network/src/main/kotlin/com/bitwarden/network/ssl/SslCertificateUtils.kt @@ -0,0 +1,63 @@ +package com.bitwarden.network.ssl + +import okhttp3.OkHttpClient +import java.security.KeyStore +import javax.net.ssl.SSLContext +import javax.net.ssl.TrustManager +import javax.net.ssl.TrustManagerFactory +import javax.net.ssl.X509TrustManager + +/** + * Creates an [OkHttpClient] configured with mTLS support using this [CertificateProvider]. + * + * The returned client will present the client certificate from this provider during TLS + * handshakes, allowing requests to pass through mTLS checks. + */ +fun CertificateProvider.createMtlsOkHttpClient(): OkHttpClient = + OkHttpClient.Builder() + .configureSsl(certificateProvider = this) + .build() + +/** + * Configures the [OkHttpClient.Builder] to use the a `SSLSocketFactory` as provided by the + * [CertificateProvider]. + */ +fun OkHttpClient.Builder.configureSsl( + certificateProvider: CertificateProvider, +): OkHttpClient.Builder { + val trustManagers = sslTrustManagers + val sslContext = certificateProvider.createSslContext(trustManagers = trustManagers) + return sslSocketFactory( + sslContext.socketFactory, + trustManagers.first() as X509TrustManager, + ) +} + +/** + * Creates an [SSLContext] configured with mTLS support using this [CertificateProvider]. + * + * The returned SSLContext will present the client certificate from this provider during + * TLS handshakes, enabling mutual TLS authentication. + */ +private fun CertificateProvider.createSslContext( + trustManagers: Array, +): SSLContext = SSLContext.getInstance("TLS").apply { + init( + arrayOf( + BitwardenX509ExtendedKeyManager(certificateProvider = this@createSslContext), + ), + trustManagers, + null, + ) +} + +/** + * Creates default [TrustManager]s for verifying server certificates. + * + * Uses the system's default trust anchors (trusted CA certificates). + */ +private val sslTrustManagers: Array + get() = TrustManagerFactory + .getInstance(TrustManagerFactory.getDefaultAlgorithm()) + .apply { init(null as KeyStore?) } + .trustManagers