From a341a1a59e42321aeb126228bc32f83d8cd7346a Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Fri, 17 Apr 2026 14:48:50 +0200 Subject: [PATCH] Replace `rustls-platform-verifier-android.aar` with single class (#6610) * Replace the `rustls-platform-verifier-android.aar` with the actual source code * Exclude the platform-verifier code from linters * Add manual update instructions * Exclude from Kover too --- build.gradle.kts | 6 + libraries/matrix/impl/build.gradle.kts | 2 +- .../libs/rustls-platform-verifier-android.aar | Bin 9287 -> 0 bytes ...stls-platform-verifier-android.aar.version | 1 - libraries/rustls-tls/README.md | 9 + libraries/rustls-tls/UPDATED.md | 7 + libraries/rustls-tls/build.gradle.kts | 24 + .../platformverifier/CertificateVerifier.kt | 480 ++++++++++++++++++ .../main/kotlin/extension/KoverExtension.kt | 1 + .../tests/konsist/KonsistLicenseTest.kt | 2 + tools/sdk/update-rustls | 35 -- 11 files changed, 530 insertions(+), 37 deletions(-) delete mode 100644 libraries/matrix/impl/libs/rustls-platform-verifier-android.aar delete mode 100644 libraries/matrix/impl/libs/rustls-platform-verifier-android.aar.version create mode 100644 libraries/rustls-tls/README.md create mode 100644 libraries/rustls-tls/UPDATED.md create mode 100644 libraries/rustls-tls/build.gradle.kts create mode 100644 libraries/rustls-tls/src/main/kotlin/org/rustls/platformverifier/CertificateVerifier.kt delete mode 100755 tools/sdk/update-rustls diff --git a/build.gradle.kts b/build.gradle.kts index f699378d54..92847f39b7 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -52,6 +52,9 @@ allprojects { tasks.withType().configureEach { exclude("io/element/android/tests/konsist/failures/**") + + // This file comes from another project and we want to keep it as close to the original as possible + exclude("org/rustls/platformverifier/**") } // KtLint @@ -79,6 +82,9 @@ allprojects { // This file comes from another project and we want to keep it as close to the original as possible exclude("**/SafeChildrenTransitionScope.kt") + + // This file comes from another project and we want to keep it as close to the original as possible + exclude("org/rustls/platformverifier/**") } } // Dependency check diff --git a/libraries/matrix/impl/build.gradle.kts b/libraries/matrix/impl/build.gradle.kts index eae96b5cd9..67386cc592 100644 --- a/libraries/matrix/impl/build.gradle.kts +++ b/libraries/matrix/impl/build.gradle.kts @@ -28,7 +28,7 @@ dependencies { } else { debugImplementation(libs.matrix.sdk) } - implementation(files("libs/rustls-platform-verifier-android.aar")) + implementation(projects.libraries.rustlsTls) implementation(projects.appconfig) implementation(projects.libraries.androidutils) diff --git a/libraries/matrix/impl/libs/rustls-platform-verifier-android.aar b/libraries/matrix/impl/libs/rustls-platform-verifier-android.aar deleted file mode 100644 index 8acc8b5fe0b36db4fb45ad8ac4a8f8c3b24fc0c5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9287 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 { + // Ensure the keystore is loaded. Since all of the trust managers are initialized in a + // `Lazy`, this will only run once. + keystore?.load(null) + + return lazy { createTrustManager(keystore) } + } + + // -- Test only -- + // Ideally, all of this will be optimized out at compile time due to not being accessed + // in release builds. + + @get:Synchronized + private val mockKeystore: KeyStore = KeyStore.getInstance(KeyStore.getDefaultType()) + + @get:Synchronized + private var mockTrustManager: Lazy = + makeLazyTrustManager(mockKeystore) + + @JvmStatic + private fun addMockRoot(root: ByteArray) { + if (!BuildConfig.TEST) { + throw Exception("attempted to add a mock root outside a test!") + } + + val alias = "root_${mockKeystore.size()}" + // Throwing here is fine since test roots should always be well-formed + val cert = certFactory.generateCertificate(ByteArrayInputStream(root)) + mockKeystore.setCertificateEntry(alias, cert) + + reloadMockData() + } + + @JvmStatic + private fun clearMockRoots() { + // Reload to get a completely fresh internal state + mockKeystore.load(null) + reloadMockData() + } + + @JvmStatic + private fun reloadMockData() { + if (mockTrustManager.isInitialized()) { + mockTrustManager = makeLazyTrustManager(mockKeystore) + } + } + + // Get a list of the system's root CAs. + // Function is public for testing only. + @JvmStatic + fun getSystemRootCAs(): List { + val rootCAs = mutableListOf() + + val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) + factory.init(systemKeystore) + + val availableTrustManagers = try { + factory.trustManagers + } catch (e: RuntimeException) { + Log.w(TAG, "exception thrown creating a TrustManager: $e") + return rootCAs + } + + availableTrustManagers.forEach { trustManager -> + if (trustManager is X509TrustManager) { + rootCAs.addAll(trustManager.acceptedIssuers) + } + } + + return rootCAs + } + + // -- End testing requirements -- + + private val certFactory: CertificateFactory = CertificateFactory.getInstance("X.509") + + private var systemTrustAnchorCache = hashSetOf>() + + @get:Synchronized + private var systemCertificateDirectory: File? = System.getenv("ANDROID_ROOT")?.let { rootPath -> + File("$rootPath/etc/security/cacerts") + } + + @get:Synchronized + private val systemKeystore: KeyStore? = try { + KeyStore.getInstance("AndroidCAStore") + } catch (_: KeyStoreException) { + null + } + + @get:Synchronized + private val systemTrustManager: Lazy = + makeLazyTrustManager(systemKeystore) + + @JvmStatic + private fun verifyCertificateChain( + @Suppress("UNUSED_PARAMETER") context: Context, + serverName: String, + authMethod: String, + allowedEkus: Array, + ocspResponse: ByteArray?, + time: Long, + certChain: Array + ): VerificationResult { + // Convert the array of (supposedly) DER bytes into certificates. + val certificateChain = mutableListOf() + certChain.forEach { certBytes -> + val certificate = try { + certFactory.generateCertificate(ByteArrayInputStream(certBytes)) + } catch (e: CertificateException) { + return VerificationResult(StatusCode.InvalidEncoding) + } + certificateChain.add(certificate as X509Certificate) + } + + // Will never throw `ArrayIndexOutOfBoundsException` because `rustls`'s `ServerCertVerifier` trait + // has a mandatory `end_entity` parameter in `verify_server_cert`. + val endEntity = certificateChain[0] + + // Check that the certificate is valid at the point of time provided by `rustls`. + try { + endEntity.checkValidity(Date(time)) + } catch (e: CertificateExpiredException) { + return VerificationResult(StatusCode.Expired) + } catch (e: CertificateNotYetValidException) { + return VerificationResult(StatusCode.Expired) + } + + // Check that this certificate can be used in a TLS server. + if (!verifyCertUsage(endEntity, allowedEkus)) { + return VerificationResult(StatusCode.InvalidExtension) + } + + // Select the trust manager to use. + // + // We select them as follows: + // - If built for release, only use the system trust manager. This should let all test-related + // code be optimized out. + // - If built for tests: + // - If the mock CA store has any values, use the mock trust manager. + // - Otherwise, use the system trust manager. + val (trustManager, keystore) = if (!BuildConfig.TEST) { + val trustManager = + systemTrustManager.value ?: return VerificationResult(StatusCode.Unavailable) + Pair(trustManager, systemKeystore) + } else { + if (mockKeystore.size() != 0) { + val trustManager = mockTrustManager.value!! + Pair(trustManager, mockKeystore) + } else { + val trustManager = + systemTrustManager.value ?: return VerificationResult(StatusCode.Unavailable) + Pair(trustManager, systemKeystore) + } + } + + // Verify that the certificate chain is valid and correct, and nothing more. + // + // NOTE: This does not validate `serverName` is valid for the end-entity certificate. + // That is handled in Rust as Android/Java do not currently provide a RFC 6125 compliant + // hostname verifier. Additionally, even the RFC 2818 verifier is not available until API 24. + // + // `serverName` is only used for pinning/CT requirements. + // + // Returns the "the properly ordered chain used for verification as a list of X509Certificates.", + // meaning a list from end-entity certificate to trust-anchor. + val validChain = try { + trustManager.checkServerTrusted(certificateChain.toTypedArray(), authMethod, serverName) + } catch (e: CertificateException) { + // In test configurations we may see `checkServerTrusted` fail once vendored test + // certificates pass their expiry date. We try to avoid that by using a fixed + // verification time when calling `endEntity.checkValidity` above, however we can't + // fix the time for the `checkServerTrusted` call. + // + // To make diagnosing CI test failures easier we try to find the root cause of + // checkServerTrusted failing, returning a different `StatusCode` as appropriate. + if (BuildConfig.TEST) { + var rootCause: Throwable? = e + while (rootCause?.cause != null && rootCause.cause != rootCause) { + rootCause = rootCause.cause + } + return when (rootCause) { + is CertificateExpiredException, is CertificateNotYetValidException -> VerificationResult( + StatusCode.Expired, + rootCause.toString() + ) + + else -> VerificationResult(StatusCode.UnknownCert, rootCause.toString()) + } + } + // In non-test configurations we should have caught expiry errors earlier and + // can simply return an unknown cert error without digging through the exception + // cause chain. + return VerificationResult(StatusCode.UnknownCert, e.toString()) + } + + // TEST ONLY: Mock test suite cannot attempt to check revocation status if no OSCP data has been stapled, + // because Android requires certificates to an specify OCSP responder for network fetch in this case. + // If in testing w/o OCSP stapled, short-circuit here - only prior checks apply. + if (BuildConfig.TEST && (mockKeystore.size() != 0) && (ocspResponse == null)) { + return VerificationResult(StatusCode.Ok) + } + + // Try to check the revocation status of the cert, if it is supported. + // + // This is supported at >= API 24, but we're supporting 22 (Android 5) for the best + // compatibility. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + // Note: + // + // 1. Android does not provide any way only to attempt to validate revocation from cached + // data like the other platforms do. This means it will always use the network for + // certificates which had no stapled response. + // + // 2: Likely because of 1, Android requires all issued certificates to have some form of + // revocation included in their authority information. This doesn't work universally as + // issuing certificates in use may omit authority access information (for example the + // Let's Encrypt R3 Intermediate Certificate). + // + // Given these constraints, the best option is to only check revocation information + // at the end-entity depth. We will prefer OCSP (to use stapled information if possible). + // If there is no stapled OCSP response, Android may use the network to attempt to fetch + // one. If OCSP checking fails, it may fall back to fetching CRLs. We allow "soft" + // failures, for example transient network errors. + // + // In the case of a non-public root, such as an internal CA or self-signed certificate, + // we opt to skip revocation checks entirely. The only exception is if the server + // provided stapled OCSP data, which is an explicit signal and won't introduce non-ideal + // platform behavior when attempting validation. + // + // This is because these are cases where a user or administrator has explicitly opted to + // trust a certificate they (at least believe) have control over. These certificates rarely + // contain revocation information as well, so these cases don't lose much. + // See https://github.com/rustls/rustls-platform-verifier/issues/69 as well. + if (ocspResponse == null && !isKnownRoot(validChain.last())) { + // Chain validation must have succeeded by this point. + return VerificationResult(StatusCode.Ok) + } + + val parameters = PKIXBuilderParameters(keystore, null) + + val validator = CertPathValidator.getInstance("PKIX") + val revocationChecker = validator.revocationChecker as PKIXRevocationChecker + + revocationChecker.options = EnumSet.of( + PKIXRevocationChecker.Option.SOFT_FAIL, + PKIXRevocationChecker.Option.ONLY_END_ENTITY + ) + + // Use the OCSP data `rustls` provided, if present. + // Its expected that the server only sends revocation data for its own leaf certificate. + // + // If this field is set, then Android will use it and skip any networking to + // attempt a fetch for that certificate. Otherwise, it will attempt to fetch it from the network. + // Ref: https://cs.android.com/android/platform/superproject/+/master:libcore/ojluni/src/main/java/sun/security/provider/certpath/RevocationChecker.java;l=694 + ocspResponse?.let { providedResponse -> + revocationChecker.ocspResponses = mapOf(endEntity to providedResponse) + } + + // Use the custom revocation definition. + // "Note that when a `PKIXRevocationChecker` is added to `PKIXParameters`, it clones the `PKIXRevocationChecker`; + // thus any subsequent modifications to the `PKIXRevocationChecker` have no effect." + // - https://developer.android.com/reference/java/security/cert/PKIXRevocationChecker + parameters.certPathCheckers = listOf(revocationChecker) + // "When supplying a revocation checker in this manner, it will be used to check revocation + // irrespective of the setting of the `RevocationEnabled` flag." + // - https://developer.android.com/reference/java/security/cert/PKIXRevocationChecker + parameters.isRevocationEnabled = false + + // Validate the revocation status of the end entity certificate. + try { + validator.validate(certFactory.generateCertPath(validChain), parameters) + } catch (e: CertPathValidatorException) { + // LetsEncrypt no longer include OCSP information (as OCSP is being deprecated) which Android is not + // happy with since it *only* tries OCSP by default. We aren't 100% decided on how to fix this yet for real + // (see https://github.com/rustls/rustls-platform-verifier/pull/179) so for now we implement an out for + // tests to allow regular maintenance to proceed. + if (BuildConfig.TEST && e.reason == CertPathValidatorException.BasicReason.UNSPECIFIED) { + return VerificationResult(StatusCode.Ok) + } + + return VerificationResult(StatusCode.Revoked, e.toString()) + } + } else { + // This is allowed to be skipped since revocation checking is best-effort. + Log.w(TAG, "did not attempt to validate OCSP due to Android version") + } + + return VerificationResult(StatusCode.Ok) + } + + private fun verifyCertUsage(certificate: X509Certificate, allowedEkus: Array): Boolean { + val ekus = try { + certificate.extendedKeyUsage + } + // This should be unreachable, but could happen. + catch (_: CertificateParsingException) { + return false + } catch (_: NullPointerException) { + // According to Chromium's implementation, this can crash when the EKU data is malformed. + Log.w(TAG, "exception handling certificate EKU") + return false + } ?: return true // If the list is empty, we have nothing to do. + + return ekus.any { allowedEkus.contains(it) } + } + + // Android hashes a principal using the first four bytes of its MD5 digest, encoded in + // lowercase hex and reversed. + // + // Ref: https://source.chromium.org/chromium/chromium/src/+/main:net/android/java/src/org/chromium/net/X509Util.java;l=339 + private fun hashPrincipal(principal: X500Principal): String { + val hexDigits = "0123456789abcdef".toCharArray() + val digest = MessageDigest.getInstance("MD5").digest(principal.encoded) + val hexChars = CharArray(8) + + for (i in 0..3) { + // Kotlin doesn't support bitwise operators for bytes, only Int and Long. + val digestByte = digest[3 - i].toInt() + hexChars[2 * i] = hexDigits[(digestByte shr 4) and 0xf] + hexChars[2 * i + 1] = hexDigits[digestByte and 0xf] + } + + return String(hexChars) + } + + // Check if CA root is known or not. + // Known means installed in root CA store, either a preset public CA or a custom one installed by an enterprise/user. + // + // Ref: https://source.chromium.org/chromium/chromium/src/+/main:net/android/java/src/org/chromium/net/X509Util.java;l=351 + fun isKnownRoot(root: X509Certificate): Boolean { + // System keystore and cert directory must be non-null to perform checking + systemKeystore?.let { loadedSystemKeystore -> + systemCertificateDirectory?.let { loadedSystemCertificateDirectory -> + + // Check the in-memory cache first + val key = Pair(root.subjectX500Principal, root.publicKey) + if (systemTrustAnchorCache.contains(key)) { + return true + } + + // System trust anchors are stored under a hash of the principal. + // In case of collisions, append number. + val hash = hashPrincipal(root.subjectX500Principal) + var i = 0 + while (true) { + val alias = "$hash.$i" + + if (!File(loadedSystemCertificateDirectory, alias).exists()) { + break + } + + val anchor = loadedSystemKeystore.getCertificate("system:$alias") + + // It's possible for `anchor` to be `null` if the user deleted a trust anchor. + // Continue iterating as there may be further collisions after the deleted anchor. + if (anchor == null) { + continue + // This should never happen + } else if (anchor !is X509Certificate) { + // SAFETY: This logs a unique identifier (hash value) only in cases where a file within the + // system's root trust store is not a valid X509 certificate (extremely unlikely error). + // The hash doesn't tell us any sensitive information about the invalid cert or reveal any of + // its contents - it just lets us ID the bad file if a user is having TLS failure issues. + Log.e(TAG, "anchor is not a certificate, alias: $alias") + continue + // If subject and public key match, it's a system root. + } else { + if ((root.subjectX500Principal == anchor.subjectX500Principal) && (root.publicKey == anchor.publicKey)) { + systemTrustAnchorCache.add(key) + return true + } + } + + i += 1 + } + } + } + + // Not found in cache or store: non-public + return false + } +} diff --git a/plugins/src/main/kotlin/extension/KoverExtension.kt b/plugins/src/main/kotlin/extension/KoverExtension.kt index 27e44e31b9..5d6b1ddabb 100644 --- a/plugins/src/main/kotlin/extension/KoverExtension.kt +++ b/plugins/src/main/kotlin/extension/KoverExtension.kt @@ -43,6 +43,7 @@ val excludedKoverSubProjects = listOf( ":libraries:core", ":libraries:coroutines", ":libraries:di", + ":libraries:rustls-tls", ":tests:detekt-rules", ":tests:konsist", ":tests:testutils", diff --git a/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistLicenseTest.kt b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistLicenseTest.kt index f47621da0e..e7f82292a4 100644 --- a/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistLicenseTest.kt +++ b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistLicenseTest.kt @@ -48,6 +48,7 @@ class KonsistLicenseTest { .files .filter { it.moduleName.startsWith("enterprise").not() && + it.moduleName != "libraries/rustls-tls" && it.nameWithExtension != "locales.kt" && it.name.startsWith("Template ").not() } @@ -78,6 +79,7 @@ class KonsistLicenseTest { .scopeFromProject() .files .filter { + it.moduleName.endsWith("rustls-tls").not() && it.nameWithExtension != "locales.kt" && it.nameWithExtension != "KonsistLicenseTest.kt" && it.name.startsWith("Template ").not() diff --git a/tools/sdk/update-rustls b/tools/sdk/update-rustls deleted file mode 100755 index d8ad883d69..0000000000 --- a/tools/sdk/update-rustls +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env bash - -# Copyright (c) 2026 Element Creations Ltd. -# -# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. -# Please see LICENSE files in the repository root for full details. - -set -e -set -u - -VERSION=${1:-} -if [ -n "$VERSION" ]; then - PACKAGE=rustls-platform-verifier-android==$VERSION -else - PACKAGE=rustls-platform-verifier-android -fi - -cargo install cargo-download -mkdir -p tmp/rustls-platform-verifier-android -cargo download $PACKAGE > tmp/rustls-platform-verifier-android/rustls-platform-verifier-android.gz -ROOT=$(git rev-parse --show-toplevel) - -cd tmp/rustls-platform-verifier-android - -echo "Extracting rustls-platform-verifier-android.aar from \`rustls-platform-verifier-android.gz\`" - -tar -xzvf rustls-platform-verifier-android.gz &> /dev/null -DIR=$(find . -type d -name "rustls-platform-verifier-android-*") -AAR=$(find $DIR -type f -name "*.aar") -cp $AAR $ROOT/libraries/matrix/impl/libs/rustls-platform-verifier-android.aar -cd $ROOT -rm -r tmp/rustls-platform-verifier-android - -echo "Updated rustls-platform-verifier-android.aar using \`$(basename $AAR)\`" > libraries/matrix/impl/libs/rustls-platform-verifier-android.aar.version -cat libraries/matrix/impl/libs/rustls-platform-verifier-android.aar.version