Skip to content

Commit

Permalink
feat: Use Let's Encrypt certificates on API 23 devices (#640)
Browse files Browse the repository at this point in the history
Android 7 devices no longer trust certificates issued by Let's Encrypt
(see https://letsencrypt.org/2020/11/06/own-two-feet and
https://letsencrypt.org/2023/07/10/cross-sign-expiration.html for
details).

To work around that provide the Let's Encrypt root certs as resources.

On API 24+ devices add those via network_security_config.xml.

On API 23 devices they need to be installed manually for OkHttp SSL
connections, and checked when there is an SSL error in
LoginWebViewActivity.

The root certificates were downloaded from
https://letsencrypt.org/certificates/:

- https://letsencrypt.org/certs/isrgrootx1.der (self-signed)
- https://letsencrypt.org/certs/isrg-root-x1-cross-signed.der
(cross-signed)
- https://letsencrypt.org/certs/isrg-root-x2.der (self-signed)
- https://letsencrypt.org/certs/isrg-root-x2-cross-signed.der
(cross-signed)

Fixes #638
  • Loading branch information
nikclayton authored Apr 24, 2024
1 parent fefeb0a commit 3e1d94d
Show file tree
Hide file tree
Showing 12 changed files with 152 additions and 14 deletions.
33 changes: 22 additions & 11 deletions app/lint-baseline.xml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<issues format="6" by="lint 8.3.1" type="baseline" client="gradle" dependencies="false" name="AGP (8.3.1)" variant="all" version="8.3.1">
<issues format="6" by="lint 8.3.2" type="baseline" client="gradle" dependencies="false" name="AGP (8.3.2)" variant="all" version="8.3.2">

<issue
id="InvalidPackage"
Expand Down Expand Up @@ -43,14 +43,25 @@
<issue
id="UnusedAttribute"
message="Attribute `localeConfig` is only used in API level 33 and higher (current min is 23)"
errorLine1=" android:localeConfig=&quot;@xml/locales_config&quot;>"
errorLine1=" android:localeConfig=&quot;@xml/locales_config&quot;"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/AndroidManifest.xml"
line="24"
column="9"/>
</issue>

<issue
id="UnusedAttribute"
message="Attribute `networkSecurityConfig` is only used in API level 24 and higher (current min is 23)"
errorLine1=" android:networkSecurityConfig=&quot;@xml/network_security_config&quot;>"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/AndroidManifest.xml"
line="25"
column="9"/>
</issue>

<issue
id="SelectedPhotoAccess"
message="Your app is currently not handling Selected Photos Access introduced in Android 14+"
Expand Down Expand Up @@ -758,7 +769,7 @@
<issue
id="UnusedTranslation"
message="The language `ber (Berber languages)` is present in this project, but not declared in the `localeConfig` resource"
errorLine1=" android:localeConfig=&quot;@xml/locales_config&quot;>"
errorLine1=" android:localeConfig=&quot;@xml/locales_config&quot;"
errorLine2=" ~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/AndroidManifest.xml"
Expand All @@ -769,7 +780,7 @@
<issue
id="UnusedTranslation"
message="The language `el (Greek)` is present in this project, but not declared in the `localeConfig` resource"
errorLine1=" android:localeConfig=&quot;@xml/locales_config&quot;>"
errorLine1=" android:localeConfig=&quot;@xml/locales_config&quot;"
errorLine2=" ~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/AndroidManifest.xml"
Expand All @@ -780,7 +791,7 @@
<issue
id="UnusedTranslation"
message="The language `fi (Finnish)` is present in this project, but not declared in the `localeConfig` resource"
errorLine1=" android:localeConfig=&quot;@xml/locales_config&quot;>"
errorLine1=" android:localeConfig=&quot;@xml/locales_config&quot;"
errorLine2=" ~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/AndroidManifest.xml"
Expand All @@ -791,7 +802,7 @@
<issue
id="UnusedTranslation"
message="The language `fy (Western Frisian)` is present in this project, but not declared in the `localeConfig` resource"
errorLine1=" android:localeConfig=&quot;@xml/locales_config&quot;>"
errorLine1=" android:localeConfig=&quot;@xml/locales_config&quot;"
errorLine2=" ~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/AndroidManifest.xml"
Expand All @@ -802,7 +813,7 @@
<issue
id="UnusedTranslation"
message="The language `in (Indonesian)` is present in this project, but not declared in the `localeConfig` resource"
errorLine1=" android:localeConfig=&quot;@xml/locales_config&quot;>"
errorLine1=" android:localeConfig=&quot;@xml/locales_config&quot;"
errorLine2=" ~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/AndroidManifest.xml"
Expand All @@ -813,7 +824,7 @@
<issue
id="UnusedTranslation"
message="The language `lv (Latvian)` is present in this project, but not declared in the `localeConfig` resource"
errorLine1=" android:localeConfig=&quot;@xml/locales_config&quot;>"
errorLine1=" android:localeConfig=&quot;@xml/locales_config&quot;"
errorLine2=" ~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/AndroidManifest.xml"
Expand All @@ -824,7 +835,7 @@
<issue
id="UnusedTranslation"
message="The language `ml (Malayalam)` is present in this project, but not declared in the `localeConfig` resource"
errorLine1=" android:localeConfig=&quot;@xml/locales_config&quot;>"
errorLine1=" android:localeConfig=&quot;@xml/locales_config&quot;"
errorLine2=" ~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/AndroidManifest.xml"
Expand All @@ -835,7 +846,7 @@
<issue
id="UnusedTranslation"
message="The language `si (Sinhala)` is present in this project, but not declared in the `localeConfig` resource"
errorLine1=" android:localeConfig=&quot;@xml/locales_config&quot;>"
errorLine1=" android:localeConfig=&quot;@xml/locales_config&quot;"
errorLine2=" ~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/AndroidManifest.xml"
Expand All @@ -846,7 +857,7 @@
<issue
id="UnusedTranslation"
message="The language `sk (Slovak)` is present in this project, but not declared in the `localeConfig` resource"
errorLine1=" android:localeConfig=&quot;@xml/locales_config&quot;>"
errorLine1=" android:localeConfig=&quot;@xml/locales_config&quot;"
errorLine2=" ~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/AndroidManifest.xml"
Expand Down
3 changes: 2 additions & 1 deletion app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@
android:supportsRtl="true"
android:theme="@style/AppTheme"
android:usesCleartextTraffic="false"
android:localeConfig="@xml/locales_config">
android:localeConfig="@xml/locales_config"
android:networkSecurityConfig="@xml/network_security_config">

<activity
android:name=".SplashActivity"
Expand Down
30 changes: 30 additions & 0 deletions app/src/main/res/xml/network_security_config.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright 2024 Pachli Association
~
~ This file is a part of Pachli.
~
~ This program is free software; you can redistribute it and/or modify it under the terms of the
~ GNU General Public License as published by the Free Software Foundation; either version 3 of the
~ License, or (at your option) any later version.
~
~ Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
~ the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
~ Public License for more details.
~
~ You should have received a copy of the GNU General Public License along with Pachli; if not,
~ see <http://www.gnu.org/licenses>.
-->

<network-security-config xmlns:tools="http://schemas.android.com/tools">
<base-config cleartextTrafficPermitted="false">
<trust-anchors>
<certificates src="@raw/isrg_root_x1_cross_signed" />
<certificates src="@raw/isrg_root_x2" />
<certificates src="@raw/isrg_root_x2_cross_signed" />
<certificates src="@raw/isrgrootx1" />
<certificates src="system" />
<certificates src="user" tools:ignore="AcceptsUserCertificates" />
</trust-anchors>
</base-config>
</network-security-config>
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import app.pachli.core.network.json.HasDefault
import app.pachli.core.network.retrofit.InstanceSwitchAuthInterceptor
import app.pachli.core.network.retrofit.MastodonApi
import app.pachli.core.network.retrofit.apiresult.ApiResultCallAdapterFactory
import app.pachli.core.network.util.localHandshakeCertificates
import app.pachli.core.preferences.PrefKeys.HTTP_PROXY_ENABLED
import app.pachli.core.preferences.PrefKeys.HTTP_PROXY_PORT
import app.pachli.core.preferences.PrefKeys.HTTP_PROXY_SERVER
Expand Down Expand Up @@ -111,6 +112,13 @@ object NetworkModule {
} ?: Timber.w("Invalid proxy configuration: (%s, %d)", httpServer, httpPort)
}

if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
// API 23 (Android 7) requires the Let's Encrypt certificates, and does not use
// network_security_config.xml.
val handshakeCertificates = localHandshakeCertificates(context)
builder.sslSocketFactory(handshakeCertificates.sslSocketFactory(), handshakeCertificates.trustManager)
}

return builder
.apply {
addInterceptor(instanceSwitchAuthInterceptor)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/*
* Copyright 2024 Pachli Association
*
* This file is a part of Pachli.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Pachli; if not,
* see <http://www.gnu.org/licenses>.
*/

package app.pachli.core.network.util

import android.content.Context
import app.pachli.core.network.R
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import javax.net.ssl.X509TrustManager
import okhttp3.tls.HandshakeCertificates

// Devices running Android 7 (API 23) do not trust the Let's Encrypt certificate and
// will refuse to connect. These functions provide certificates and a trust manager
// that contain the Let's Encrypt certificates and are used when configuring OkHttp
// and handling LoginWebViewActivity SSL errors.
//
// See https://github.com/pachli/pachli-android/issues/638#issuecomment-2071935438
// for the background.

/**
* @return [HandshakeCertificates] containing the platform's trusted certificates and
* the extra certificates in `values/raw`.
*/
fun localHandshakeCertificates(context: Context): HandshakeCertificates {
val certFactory = CertificateFactory.getInstance("X.509")
return HandshakeCertificates.Builder()
.addPlatformTrustedCertificates()
.addTrustedCertificate(certFactory.generateCertificate(context.resources.openRawResource(R.raw.isrg_root_x1_cross_signed)) as X509Certificate)
.addTrustedCertificate(certFactory.generateCertificate(context.resources.openRawResource(R.raw.isrg_root_x2)) as X509Certificate)
.addTrustedCertificate(certFactory.generateCertificate(context.resources.openRawResource(R.raw.isrg_root_x2_cross_signed)) as X509Certificate)
.addTrustedCertificate(certFactory.generateCertificate(context.resources.openRawResource(R.raw.isrgrootx1)) as X509Certificate)
.build()
}

/**
* @return An [X509TrustManager] configured with certificates loaded from
* localCertHandshakeCertificates].
*/
// Exists so that LoginWebViewActivity does not depend on HandshakeCertificates
// (on okHttp type), but X509TrustManager, a javax type.
fun localTrustManager(context: Context): X509TrustManager = localHandshakeCertificates(context).trustManager
Binary file not shown.
Binary file added core/network/src/main/res/raw/isrg_root_x2.der
Binary file not shown.
Binary file not shown.
Binary file added core/network/src/main/res/raw/isrgrootx1.der
Binary file not shown.
1 change: 1 addition & 0 deletions feature/login/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ dependencies {
implementation(projects.core.preferences)

implementation(libs.bundles.androidx)
implementation(libs.androidx.webkit)

// Loading the logo
implementation(libs.bundles.glide)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,12 @@ import android.content.Context
import android.content.Intent
import android.graphics.Color
import android.net.Uri
import android.net.http.SslError
import android.os.Build
import android.os.Bundle
import android.os.Parcelable
import android.webkit.CookieManager
import android.webkit.SslErrorHandler
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebStorage
Expand All @@ -43,8 +46,10 @@ import app.pachli.core.common.extensions.viewBinding
import app.pachli.core.common.extensions.visible
import app.pachli.core.common.util.versionName
import app.pachli.core.navigation.LoginWebViewActivityIntent
import app.pachli.core.network.util.localTrustManager
import app.pachli.feature.login.databinding.ActivityLoginWebviewBinding
import dagger.hilt.android.AndroidEntryPoint
import java.security.cert.X509Certificate
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import timber.log.Timber
Expand Down Expand Up @@ -149,7 +154,7 @@ class LoginWebViewActivity : BaseActivity() {
request: WebResourceRequest,
error: WebResourceError,
) {
Timber.d("Failed to load %s: %s", data.url, error)
Timber.d("Failed to load %s: %d %s", data.url, error.errorCode, error.description)
sendResult(LoginResult.Err(getString(R.string.error_could_not_load_login_page)))
}

Expand Down Expand Up @@ -181,6 +186,29 @@ class LoginWebViewActivity : BaseActivity() {
false
}
}

@SuppressLint("DiscouragedPrivateApi", "WebViewClientOnReceivedSslError")
override fun onReceivedSslError(view: WebView?, handler: SslErrorHandler?, error: SslError?) {
// An SSL error might be because the user is connecting to a server using
// Let's Encrypt certificates on an Android 7 device. If that's the case
// check if the server is trusted using the local trust manager, which
// includes the Let's Encrypt certificates.
if (error?.primaryError != SslError.SSL_UNTRUSTED) return super.onReceivedSslError(view, handler, error)

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) return super.onReceivedSslError(view, handler, error)

// Based on https://www.guardsquare.com/blog/how-to-securely-implement-tls-certificate-checking-in-android-apps
// but uses the OkHttp HandshakeCertificates type.
try {
val certField = error.certificate.javaClass.getDeclaredField("mX509Certificate")
certField.isAccessible = true
val cert = certField.get(error.certificate) as X509Certificate
localTrustManager(this@LoginWebViewActivity).checkServerTrusted(arrayOf(cert), "generic")
handler?.proceed()
} catch (_: Exception) {
super.onReceivedSslError(view, handler, error)
}
}
}

webView.setBackgroundColor(Color.TRANSPARENT)
Expand Down
5 changes: 4 additions & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ androidx-swiperefresh-layout = "1.1.0"
androidx-testing = "2.2.0"
androidx-test-core-ktx = "1.5.0"
androidx-viewpager2 = "1.0.0"
androidx-webkit = "1.8.0"
androidx-work = "2.9.0"
androidx-room = "2.6.1"
app-update = "2.1.0"
Expand Down Expand Up @@ -145,6 +146,7 @@ androidx-swiperefreshlayout = { module = "androidx.swiperefreshlayout:swiperefre
androidx-test-core-ktx = { module = "androidx.test:core-ktx", version.ref = "androidx-test-core-ktx" }
androidx-test-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-junit" }
androidx-viewpager2 = { module = "androidx.viewpager2:viewpager2", version.ref = "androidx-viewpager2" }
androidx-webkit = { module = "androidx.webkit:webkit", version.ref = "androidx-webkit" }
androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "androidx-work" }
androidx-work-testing = { module = "androidx.work:work-testing", version.ref = "androidx-work" }
app-update = { module = "com.google.android.play:app-update", version.ref = "app-update" }
Expand Down Expand Up @@ -196,6 +198,7 @@ moshix-sealed-codegen = { module = "dev.zacsweers.moshix:moshi-sealed-codegen",
networkresult-calladapter = { module = "at.connyduck:networkresult-calladapter", version.ref = "networkresult-calladapter" }
okhttp-core = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
okhttp-logging-interceptor = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" }
okhttp-tls = { module = "com.squareup.okhttp3:okhttp-tls", version.ref = "okhttp" }
retrofit-converter-moshi = { module = "com.squareup.retrofit2:converter-moshi", version.ref = "retrofit" }
retrofit-core = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" }
Expand Down Expand Up @@ -237,7 +240,7 @@ lint-api = ["kotlin-stdlib", "lint-api", "lint-checks"]
lint-tests = ["junit", "lint-cli", "lint-tests"]
material-drawer = ["material-drawer-core", "material-drawer-iconics"]
mockito = ["mockito-kotlin", "mockito-inline"]
okhttp = ["okhttp-core", "okhttp-logging-interceptor"]
okhttp = ["okhttp-core", "okhttp-logging-interceptor", "okhttp-tls"]
retrofit = ["retrofit-core", "retrofit-converter-moshi"]
room = ["androidx-room-ktx", "androidx-room-paging"]
xmldiff = ["diffx", "xmlwriter"]

0 comments on commit 3e1d94d

Please sign in to comment.