V1.4.3: 修复老系统(API<24)不认 Let's Encrypt 新根,导致无法下载曲库

根因(用远程日志的 STORAGE 行 + 本地复现 Muse 协议定位):
- 曲库服务 mconn.cherryonline.cn 与点歌登录 mws.cherryonline.cn 都用 Let's Encrypt 新链
  leaf ← YR2 ← Root YR ← ISRG Root X1。
- res/xml/network_security_config.xml 里虽然已把这三个根加为信任锚,但
  **该配置只在 Android 7.0(API 24)+ 被系统读取**;Android 5.1.1 上被完全忽略,
  系统信任库又没有 ISRG 新根 → SSLHandshakeException: Trust anchor for certification
  path not found → login 失败 → fetchVersion()=null → 根本不触发下载,
  界面只剩兜底文案「未找到可用曲库」,而失败只写 logcat,远程日志里看不到。
- 这也解释了为什么只有这台老设备出问题(其它设备都 >= API 24)。

修复:
- 新增 TlsCompat: 在 API<24 上把「系统默认信任 ∪ 内置根证书」的复合 X509TrustManager
  装到全局 HttpsURLConnection.setDefaultSSLSocketFactory,覆盖 App 内所有 HttpsURLConnection
  (曲库同步/JS 热更新下载/更新检查/头像下载)。主机名校验保持系统默认,不放行任意证书。
- WebView 的 TLS 无法用 SSLContext 覆盖(JS 会直连 mws): 在 KtvJsBridge 的
  onReceivedSslError 里不盲目 proceed,先用 TlsCompat.isHostTrustedByOurStore(host)
  对同一主机独立校验,通过才放行。
- TlsCompat.install 在 KtvApplication.attachBaseContext 调用(早于任何网络请求)。
- 补齐可观测性: 曲库同步每一步失败改为 MiliLog 上报
  (MUSE-DB login/sync ...、DB-SYNC download failed/空间不足/成功);
  fetchRemoteVersion 失败时设置明确的 bootstrapError,不再只显示兜底文案。
- AGENTS.md 增补「老系统 TLS 根证书」条目。

版本 1.4.2(22) → 1.4.3(23);签名证书不变。
This commit is contained in:
zijunchiang
2026-09-26 16:06:27 +08:00
parent 8ad2aa50d7
commit 62216b0fb8
8 changed files with 247 additions and 6 deletions
@@ -0,0 +1,168 @@
package com.local.ktv
import android.content.Context
import android.util.Log
import java.net.URL
import java.security.KeyStore
import java.security.cert.CertificateException
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import java.util.concurrent.ConcurrentHashMap
import javax.net.ssl.HttpsURLConnection
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLException
import javax.net.ssl.TrustManager
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509TrustManager
/**
* 低版本 Android 的 TLS 信任补丁。
*
* **背景**:`*.cherryonline.cn`(曲库同步 `mconn` / 点歌登录 `mws`)与 `gitea.kasugano.cn`
* 都使用 Let's Encrypt 新链:`leaf ← YR2 ← Root YR ← ISRG Root X1`。
* `res/xml/network_security_config.xml` 里虽然把这三个根证书加了信任锚,但
* **该配置只在 Android 7.0(API 24) 及以上被系统读取**;Android 5.1.1(API 22) 上被完全忽略,
* 系统信任库又没有 ISRG 新根 → `SSLHandshakeException: Trust anchor for certification path not found`
* → 曲库登录失败 → 「不能下载曲库」。这也解释了为什么只有老设备出问题(其它设备都是 API 24+)。
*
* **做法**(只对 API < 24 生效,新系统保持原样):
* 1. [install] 构造「系统默认信任 ∪ 应用内置根证书」的复合 X509TrustManager,装到全局
* `HttpsURLConnection` 默认 SSLSocketFactory 上 —— 覆盖 App 内所有 `HttpsURLConnection`
* (曲库同步、JS 热更新下载、更新检查、头像下载等)。
* **主机名校验保持系统默认,不关闭、不放行任意证书。**
* 2. WebView 的 TLS 信任无法用 SSLContext 覆盖,故提供 [isHostTrustedByOurStore]:
* 用上面增强后的信任库**独立地对同一主机发起一次 TLS 校验**;只有当它校验通过
* (说明证书链本身合法、主机名也匹配,WebView 失败纯粹是因为老系统的根证书缺失)时,
* 才允许 WebView 继续。**不做「见到 SSL 错误就放行」的盲目信任。**
*/
object TlsCompat {
private const val TAG = "TlsCompat"
/** 应用内置的根/中间证书(与 network_security_config.xml 中列出的保持一致)。 */
private val BUNDLED_CERTS = intArrayOf(
R.raw.isrg_root_x1,
R.raw.isrg_root_yr,
R.raw.letsencrypt_yr1,
)
@Volatile private var installed = false
/** 主机 → 是否已被增强信任库校验通过(只缓存肯定结果)。 */
private val trustedHosts = ConcurrentHashMap<String, Boolean>()
/** 是否跑在需要补丁的老系统上(API < 24 才会忽略 network_security_config)。 */
val needsLegacyPatch: Boolean get() = android.os.Build.VERSION.SDK_INT < 24
fun install(context: Context) {
if (installed) return
installed = true
runCatching {
val system = systemTrustManager()
val bundled = bundledTrustManager(context)
if (bundled == null) {
Log.w(TAG, "no bundled certs loaded; keep system trust only")
return
}
if (android.os.Build.VERSION.SDK_INT >= 24) {
// 新系统由 network_security_config 负责,无需接管(避免多此一举)。
Log.i(TAG, "API>=24, network-security-config handles bundled roots")
return
}
val composite = CompositeTrustManager(system, bundled)
val sslContext = SSLContext.getInstance("TLS")
sslContext.init(null, arrayOf<TrustManager>(composite), null)
HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.socketFactory)
Log.i(
TAG,
"legacy TLS trust installed: system(${system?.acceptedIssuers?.size ?: 0} CAs) " +
"+ bundled(${BUNDLED_CERTS.size} certs)",
)
}.onFailure { Log.e(TAG, "install failed", it) }
}
/**
* 用**增强后的信任库**校验 `https://host/` 的证书链。
* 返回 true 仅当 TLS 握手与主机名校验都通过(HttpsURLConnection 的默认主机名校验保持开启)。
*/
fun isHostTrustedByOurStore(host: String): Boolean {
if (host.isBlank()) return false
trustedHosts[host]?.let { return it }
val ok = runCatching {
val conn = (URL("https://$host/").openConnection() as HttpsURLConnection).apply {
connectTimeout = 8000
readTimeout = 8000
requestMethod = "HEAD"
instanceFollowRedirects = false
}
try {
// 任何响应码都说明握手 + 主机名校验通过了。
conn.responseCode
true
} catch (e: SSLException) {
Log.w(TAG, "host $host NOT trusted by our store: ${e.message}")
false
} finally {
runCatching { conn.disconnect() }
}
}.getOrDefault(false)
if (ok) trustedHosts[host] = true
return ok
}
private fun systemTrustManager(): X509TrustManager? = runCatching {
val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
factory.init(null as KeyStore?)
factory.trustManagers.filterIsInstance<X509TrustManager>().firstOrNull()
}.getOrNull()
private fun bundledTrustManager(context: Context): X509TrustManager? = runCatching {
val keyStore = KeyStore.getInstance(KeyStore.getDefaultType()).apply { load(null, null) }
val certificateFactory = CertificateFactory.getInstance("X.509")
BUNDLED_CERTS.forEachIndexed { index, resId ->
context.resources.openRawResource(resId).use { input ->
certificateFactory.generateCertificate(input)?.let {
keyStore.setCertificateEntry("ktv-bundled-$index", it)
}
}
}
val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
factory.init(keyStore)
factory.trustManagers.filterIsInstance<X509TrustManager>().firstOrNull()
}.onFailure { Log.e(TAG, "load bundled certs failed", it) }.getOrNull()
/** 系统信任 ∪ 内置根证书:任一通过即放行;都不过则抛出首个错误。 */
private class CompositeTrustManager(
private val system: X509TrustManager?,
private val bundled: X509TrustManager?,
) : X509TrustManager {
override fun checkClientTrusted(chain: Array<out X509Certificate>, authType: String) {
var first: CertificateException? = null
for (manager in listOfNotNull(system, bundled)) {
try {
manager.checkClientTrusted(chain, authType)
return
} catch (e: CertificateException) {
if (first == null) first = e
}
}
throw first ?: CertificateException("no trust manager available")
}
override fun checkServerTrusted(chain: Array<out X509Certificate>, authType: String) {
var first: CertificateException? = null
for (manager in listOfNotNull(system, bundled)) {
try {
manager.checkServerTrusted(chain, authType)
return
} catch (e: CertificateException) {
if (first == null) first = e
}
}
throw first ?: CertificateException("no trust manager available")
}
override fun getAcceptedIssuers(): Array<X509Certificate> =
(system?.acceptedIssuers ?: emptyArray()) + (bundled?.acceptedIssuers ?: emptyArray())
}
}