From c8ecf824bb19dff396da3f8e374abf35ec0aca73 Mon Sep 17 00:00:00 2001 From: Sunik Kupfer Date: Mon, 17 Nov 2025 17:48:10 +0100 Subject: [PATCH 1/2] Decouple CustomCertManager from Android --- .../cert4android/CustomCertManagerTest.kt | 89 -------- .../at/bitfire/cert4android/OkhttpTest.kt | 2 +- .../bitfire/cert4android/TestCertificates.kt | 40 ---- .../java/at/bitfire/cert4android/CertStore.kt | 34 +++ .../bitfire/cert4android/CustomCertManager.kt | 6 +- .../bitfire/cert4android/CustomCertStore.kt | 54 ++--- .../cert4android/CustomCertManagerTest.kt | 211 ++++++++++++++++++ .../bitfire/cert4android/demo/MainActivity.kt | 2 +- 8 files changed, 275 insertions(+), 163 deletions(-) delete mode 100644 lib/src/androidTest/java/at/bitfire/cert4android/CustomCertManagerTest.kt create mode 100644 lib/src/main/java/at/bitfire/cert4android/CertStore.kt create mode 100644 lib/src/test/java/at/bitfire/cert4android/CustomCertManagerTest.kt diff --git a/lib/src/androidTest/java/at/bitfire/cert4android/CustomCertManagerTest.kt b/lib/src/androidTest/java/at/bitfire/cert4android/CustomCertManagerTest.kt deleted file mode 100644 index 3092844..0000000 --- a/lib/src/androidTest/java/at/bitfire/cert4android/CustomCertManagerTest.kt +++ /dev/null @@ -1,89 +0,0 @@ -/* - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - * - * SPDX-License-Identifier: MPL-2.0 - */ - -package at.bitfire.cert4android - -import androidx.test.platform.app.InstrumentationRegistry -import org.junit.Assume.assumeNotNull -import org.junit.Before -import org.junit.Test -import java.io.IOException -import java.net.URL -import java.security.cert.CertificateException -import java.security.cert.X509Certificate - -class CustomCertManagerTest { - - private val context by lazy { InstrumentationRegistry.getInstrumentation().targetContext } - - private lateinit var certManager: CustomCertManager - private lateinit var paranoidCertManager: CustomCertManager - - private var siteCerts: List? = - try { - TestCertificates.getSiteCertificates(URL("https://www.davx5.com")) - } catch(_: IOException) { - null - } - init { - assumeNotNull("Couldn't load certificate from Web", siteCerts) - } - - @Before - fun createCertManager() { - certManager = CustomCertManager(context, true, null) - paranoidCertManager = CustomCertManager(context, false, null) - } - - - @Test(expected = CertificateException::class) - fun testCheckClientCertificate() { - certManager.checkClientTrusted(null, null) - } - - @Test - fun testTrustedCertificate() { - certManager.checkServerTrusted(siteCerts!!.toTypedArray(), "RSA") - } - - @Test(expected = CertificateException::class) - fun testParanoidCertificate() { - paranoidCertManager.checkServerTrusted(siteCerts!!.toTypedArray(), "RSA") - } - - @Test - fun testAddCustomCertificate() { - addTrustedCertificate() - paranoidCertManager.checkServerTrusted(siteCerts!!.toTypedArray(), "RSA") - } - - @Test(expected = CertificateException::class) - fun testRemoveCustomCertificate() { - addTrustedCertificate() - - // remove certificate again - // should now be rejected for the whole session - addUntrustedCertificate() - - paranoidCertManager.checkServerTrusted(siteCerts!!.toTypedArray(), "RSA") - } - - - // helpers - - private fun addTrustedCertificate() { - CustomCertStore.getInstance(context).setTrustedByUser(siteCerts!!.first()) - } - - private fun addUntrustedCertificate() { - CustomCertStore.getInstance(context).setUntrustedByUser(siteCerts!!.first()) - } - -} \ No newline at end of file diff --git a/lib/src/androidTest/java/at/bitfire/cert4android/OkhttpTest.kt b/lib/src/androidTest/java/at/bitfire/cert4android/OkhttpTest.kt index e6481ae..f60097e 100644 --- a/lib/src/androidTest/java/at/bitfire/cert4android/OkhttpTest.kt +++ b/lib/src/androidTest/java/at/bitfire/cert4android/OkhttpTest.kt @@ -58,7 +58,7 @@ class OkhttpTest { // set cert4android TrustManager and HostnameVerifier val certManager = CustomCertManager( - context, + CustomCertStore.getInstance(context), trustSystemCerts = true, appInForeground = null ) diff --git a/lib/src/androidTest/java/at/bitfire/cert4android/TestCertificates.kt b/lib/src/androidTest/java/at/bitfire/cert4android/TestCertificates.kt index dce29e6..51bc295 100644 --- a/lib/src/androidTest/java/at/bitfire/cert4android/TestCertificates.kt +++ b/lib/src/androidTest/java/at/bitfire/cert4android/TestCertificates.kt @@ -10,13 +10,8 @@ package at.bitfire.cert4android -import android.net.SSLCertificateSocketFactory -import org.apache.http.conn.ssl.AllowAllHostnameVerifier -import java.net.URL import java.security.cert.CertificateFactory import java.security.cert.X509Certificate -import javax.net.ssl.HttpsURLConnection -import javax.net.ssl.X509TrustManager /** * Provides certificates for testing. @@ -71,39 +66,4 @@ object TestCertificates { fun testCert() = certFactory.generateCertificate(RAW_TEST_CERT.byteInputStream()) as X509Certificate - - /** - * Get the certificates of a site (bypassing all trusted checks). - * - * @param url the URL to get the certificates from - * @return the certificates of the site - */ - fun getSiteCertificates(url: URL): List { - val conn = url.openConnection() as HttpsURLConnection - try { - conn.hostnameVerifier = AllowAllHostnameVerifier() - conn.sslSocketFactory = object : SSLCertificateSocketFactory(1000) { - init { - setTrustManagers(arrayOf(object : X509TrustManager { - override fun checkClientTrusted( - chain: Array?, - authType: String? - ) { /* OK */ } - override fun checkServerTrusted( - chain: Array?, - authType: String? - ) { /* OK */ } - override fun getAcceptedIssuers(): Array? = emptyArray() - })) - } - } - conn.inputStream.read() - val certs = mutableListOf() - conn.serverCertificates.forEach { certs += it as X509Certificate } - return certs - } finally { - conn.disconnect() - } - } - } \ No newline at end of file diff --git a/lib/src/main/java/at/bitfire/cert4android/CertStore.kt b/lib/src/main/java/at/bitfire/cert4android/CertStore.kt new file mode 100644 index 0000000..c6fd846 --- /dev/null +++ b/lib/src/main/java/at/bitfire/cert4android/CertStore.kt @@ -0,0 +1,34 @@ +package at.bitfire.cert4android + +import kotlinx.coroutines.flow.StateFlow +import java.security.cert.X509Certificate + +interface CertStore { + + /** + * Removes user (dis-)trust decisions for all certificates. + */ + fun clearUserDecisions() + + /** + * Determines whether a certificate chain is trusted. + */ + fun isTrusted(chain: Array, authType: String, trustSystemCerts: Boolean, appInForeground: StateFlow?): Boolean + + /** + * Determines whether a certificate has been explicitly accepted by the user. In this case, + * we can ignore an invalid host name for that certificate. + */ + fun isTrustedByUser(cert: X509Certificate): Boolean + + /** + * Sets this certificate as trusted. + */ + fun setTrustedByUser(cert: X509Certificate) + + /** + * Sets this certificate as untrusted. + */ + fun setUntrustedByUser(cert: X509Certificate) + +} \ No newline at end of file diff --git a/lib/src/main/java/at/bitfire/cert4android/CustomCertManager.kt b/lib/src/main/java/at/bitfire/cert4android/CustomCertManager.kt index 7a6dcd0..218334b 100644 --- a/lib/src/main/java/at/bitfire/cert4android/CustomCertManager.kt +++ b/lib/src/main/java/at/bitfire/cert4android/CustomCertManager.kt @@ -11,7 +11,6 @@ package at.bitfire.cert4android import android.annotation.SuppressLint -import android.content.Context import kotlinx.coroutines.flow.StateFlow import java.security.cert.CertificateException import java.security.cert.X509Certificate @@ -29,7 +28,7 @@ import javax.net.ssl.X509TrustManager */ @SuppressLint("CustomX509TrustManager") class CustomCertManager @JvmOverloads constructor( - context: Context, + private val certStore: CertStore, val trustSystemCerts: Boolean = true, var appInForeground: StateFlow? ): X509TrustManager { @@ -37,9 +36,6 @@ class CustomCertManager @JvmOverloads constructor( private val logger get() = Logger.getLogger(javaClass.name) - val certStore = CustomCertStore.getInstance(context) - - @Throws(CertificateException::class) override fun checkClientTrusted(chain: Array?, authType: String?) { throw CertificateException("cert4android doesn't validate client certificates") diff --git a/lib/src/main/java/at/bitfire/cert4android/CustomCertStore.kt b/lib/src/main/java/at/bitfire/cert4android/CustomCertStore.kt index 21c963b..0f4508e 100644 --- a/lib/src/main/java/at/bitfire/cert4android/CustomCertStore.kt +++ b/lib/src/main/java/at/bitfire/cert4android/CustomCertStore.kt @@ -31,28 +31,7 @@ import javax.net.ssl.X509TrustManager class CustomCertStore internal constructor( private val context: Context, private val userTimeout: Long = 60000L -) { - - companion object { - - private const val KEYSTORE_DIR = "KeyStore" - private const val KEYSTORE_NAME = "KeyStore.bks" - - @SuppressLint("StaticFieldLeak") // we only store the applicationContext, so this is safe - private var instance: CustomCertStore? = null - - @Synchronized - fun getInstance(context: Context): CustomCertStore { - instance?.let { - return it - } - - val newInstance = CustomCertStore(context.applicationContext) - instance = newInstance - return newInstance - } - - } +): CertStore { private val logger get() = Logger.getLogger(javaClass.name) @@ -82,7 +61,7 @@ class CustomCertStore internal constructor( } @Synchronized - fun clearUserDecisions() { + override fun clearUserDecisions() { logger.info("Clearing user-(dis)trusted certificates") for (alias in userKeyStore.aliases()) @@ -96,7 +75,7 @@ class CustomCertStore internal constructor( /** * Determines whether a certificate chain is trusted. */ - fun isTrusted(chain: Array, authType: String, trustSystemCerts: Boolean, appInForeground: StateFlow?): Boolean { + override fun isTrusted(chain: Array, authType: String, trustSystemCerts: Boolean, appInForeground: StateFlow?): Boolean { if (chain.isEmpty()) throw IllegalArgumentException("Certificate chain must not be empty") val cert = chain[0] @@ -146,11 +125,11 @@ class CustomCertStore internal constructor( * we can ignore an invalid host name for that certificate. */ @Synchronized - fun isTrustedByUser(cert: X509Certificate): Boolean = + override fun isTrustedByUser(cert: X509Certificate): Boolean = userKeyStore.getCertificateAlias(cert) != null @Synchronized - fun setTrustedByUser(cert: X509Certificate) { + override fun setTrustedByUser(cert: X509Certificate) { val alias = CertUtils.getTag(cert) logger.info("Trusted by user: ${cert.subjectDN.name} ($alias)") @@ -161,7 +140,7 @@ class CustomCertStore internal constructor( } @Synchronized - fun setUntrustedByUser(cert: X509Certificate) { + override fun setUntrustedByUser(cert: X509Certificate) { logger.info("Distrusted by user: ${cert.subjectDN.name}") // find certificate @@ -202,4 +181,25 @@ class CustomCertStore internal constructor( } } + companion object { + + private const val KEYSTORE_DIR = "KeyStore" + private const val KEYSTORE_NAME = "KeyStore.bks" + + @SuppressLint("StaticFieldLeak") // we only store the applicationContext, so this is safe + private var instance: CustomCertStore? = null + + @Synchronized + fun getInstance(context: Context): CustomCertStore { + instance?.let { + return it + } + + val newInstance = CustomCertStore(context.applicationContext) + instance = newInstance + return newInstance + } + + } + } \ No newline at end of file diff --git a/lib/src/test/java/at/bitfire/cert4android/CustomCertManagerTest.kt b/lib/src/test/java/at/bitfire/cert4android/CustomCertManagerTest.kt new file mode 100644 index 0000000..274aa0c --- /dev/null +++ b/lib/src/test/java/at/bitfire/cert4android/CustomCertManagerTest.kt @@ -0,0 +1,211 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + */ + +package at.bitfire.cert4android + +import androidx.annotation.VisibleForTesting +import kotlinx.coroutines.flow.StateFlow +import org.junit.Assume.assumeNotNull +import org.junit.Before +import org.junit.Test +import java.io.IOException +import java.net.URL +import java.security.SecureRandom +import java.security.cert.CertificateException +import java.security.cert.X509Certificate +import java.util.logging.Level +import java.util.logging.Logger +import javax.net.ssl.SSLContext +import javax.net.ssl.SSLSocket +import javax.net.ssl.TrustManager +import javax.net.ssl.X509TrustManager + +class CustomCertManagerTest { + + private lateinit var certStore: CertStore + private lateinit var certManager: CustomCertManager + private lateinit var paranoidCertManager: CustomCertManager + + private var siteCerts: List? = + try { + getSiteCertificates(URL("https://www.davx5.com")) + } catch(_: IOException) { + null + } + init { + assumeNotNull("Couldn't load certificate from Web", siteCerts) + } + + @Before + fun createCertManager() { + certStore = TestCertStore() + certManager = CustomCertManager(certStore, true, null) + paranoidCertManager = CustomCertManager(certStore, false, null) + } + + + @Test(expected = CertificateException::class) + fun testCheckClientCertificate() { + certManager.checkClientTrusted(null, null) + } + + @Test + fun testTrustedCertificate() { + certManager.checkServerTrusted(siteCerts!!.toTypedArray(), "RSA") + } + + @Test(expected = CertificateException::class) + fun testParanoidCertificate() { + paranoidCertManager.checkServerTrusted(siteCerts!!.toTypedArray(), "RSA") + } + + @Test + fun testAddCustomCertificate() { + addTrustedCertificate() + paranoidCertManager.checkServerTrusted(siteCerts!!.toTypedArray(), "RSA") + } + + @Test(expected = CertificateException::class) + fun testRemoveCustomCertificate() { + addTrustedCertificate() + + // remove certificate again + // should now be rejected for the whole session + addUntrustedCertificate() + + paranoidCertManager.checkServerTrusted(siteCerts!!.toTypedArray(), "RSA") + } + + + // helpers + + private fun addTrustedCertificate() { + certStore.setTrustedByUser(siteCerts!!.first()) + } + + private fun addUntrustedCertificate() { + certStore.setUntrustedByUser(siteCerts!!.first()) + } + + /** + * Get the certificates of a site (bypassing all trusted checks). + * + * @param url the URL to get the certificates from + * @return the certificates of the site + */ + fun getSiteCertificates(url: URL): List { + val port = if (url.port != -1) url.port else 443 + val host = url.host + + // Create a TrustManager which accepts all certificates + val trustAll = object : X509TrustManager { + override fun checkClientTrusted(chain: Array?, authType: String?) {} + override fun checkServerTrusted(chain: Array?, authType: String?) {} + override fun getAcceptedIssuers(): Array = emptyArray() + } + + // Create an SSLContext using the trust-all manager + val sslContext = SSLContext.getInstance("TLS").apply { + init(null, arrayOf(trustAll), SecureRandom()) + } + + // Create an SSL socket and force a TLS handshake + // (HttpsURLConnection performs the handshake lazily and sometimes the handshake is not + // executed before this method gets called) + sslContext.socketFactory.createSocket(host, port).use { socket -> + val sslSocket = socket as SSLSocket + // Explicitly start the handshake (gets certificate) + sslSocket.startHandshake() + // server certificates now available in SSLSession + return sslSocket.session.peerCertificates.map { it as X509Certificate } + } + } + + + class TestCertStore(): CertStore { + + private val logger + get() = Logger.getLogger(javaClass.name) + + /** custom TrustStore (simple map) */ + @VisibleForTesting + internal val userKeyStore = mutableMapOf() + + /** in-memory store for untrusted certs */ + @VisibleForTesting + internal var untrustedCerts = HashSet() + + @Synchronized + override fun clearUserDecisions() { + logger.info("Clearing user-(dis)trusted certificates") + + for (alias in userKeyStore.keys) + userKeyStore.remove(alias) + + // clear untrusted certs + untrustedCerts.clear() + } + + /** + * Determines whether a certificate chain is trusted. + */ + override fun isTrusted(chain: Array, authType: String, trustSystemCerts: Boolean, appInForeground: StateFlow?): Boolean { + if (chain.isEmpty()) + throw IllegalArgumentException("Certificate chain must not be empty") + val cert = chain[0] + + synchronized(this) { + // explicitly accepted by user? + if (isTrustedByUser(cert)) + return true + + // explicitly rejected by user? + if (untrustedCerts.contains(cert)) + return false + + // trusted by system? (if applicable) + if (trustSystemCerts) + return true // system trusts all certificates + } + logger.log(Level.INFO, "Certificate not known and running in non-interactive mode, rejecting") + return false + } + + /** + * Determines whether a certificate has been explicitly accepted by the user. In this case, + * we can ignore an invalid host name for that certificate. + */ + @Synchronized + override fun isTrustedByUser(cert: X509Certificate): Boolean = + userKeyStore.containsValue(cert) + + @Synchronized + override fun setTrustedByUser(cert: X509Certificate) { + val alias = CertUtils.getTag(cert) + logger.info("Trusted by user: ${cert.subjectDN.name} ($alias)") + userKeyStore[alias] = cert + untrustedCerts -= cert + } + + @Synchronized + override fun setUntrustedByUser(cert: X509Certificate) { + logger.info("Distrusted by user: ${cert.subjectDN.name}") + + // find certificate + val alias = userKeyStore.entries.find { it.value == cert }?.key + if (alias != null) + // and delete, if applicable + userKeyStore.remove(alias) + untrustedCerts += cert + } + + } + +} \ No newline at end of file diff --git a/sample-app/src/main/java/at/bitfire/cert4android/demo/MainActivity.kt b/sample-app/src/main/java/at/bitfire/cert4android/demo/MainActivity.kt index b95ba79..246a20a 100644 --- a/sample-app/src/main/java/at/bitfire/cert4android/demo/MainActivity.kt +++ b/sample-app/src/main/java/at/bitfire/cert4android/demo/MainActivity.kt @@ -176,7 +176,7 @@ class MainActivity : ComponentActivity() { // set cert4android TrustManager and HostnameVerifier val certManager = CustomCertManager( - context, + certStore = CustomCertStore.getInstance(context), trustSystemCerts = trustSystemCerts, appInForeground = appInForeground ) From e1c8e0673bcb2bb6e32d72754d31d3484178bf01 Mon Sep 17 00:00:00 2001 From: Sunik Kupfer Date: Tue, 18 Nov 2025 11:34:51 +0100 Subject: [PATCH 2/2] Add copyright header --- lib/src/main/java/at/bitfire/cert4android/CertStore.kt | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/lib/src/main/java/at/bitfire/cert4android/CertStore.kt b/lib/src/main/java/at/bitfire/cert4android/CertStore.kt index c6fd846..0295ecf 100644 --- a/lib/src/main/java/at/bitfire/cert4android/CertStore.kt +++ b/lib/src/main/java/at/bitfire/cert4android/CertStore.kt @@ -1,3 +1,13 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + */ + package at.bitfire.cert4android import kotlinx.coroutines.flow.StateFlow