PAYATHON 2026

Flutter 在 Android 13/14 上证书校验失败,如何安全解决?

数据小许

结论

优先检查服务器是否提供了兼容旧设备的 Sectigo 交叉签名证书链。暂时无法修改服务器时,可以通过 SecurityContext 为专用的 Dart HttpClient 添加经过核验的 R46 根证书,保留正常的 TLS 校验。

仅凭 Android 版本和 SSL Labs 评级,无法确定故障是否由缺少 R46 根证书引起。当前错误表明,客户端无法构建通向本地可信证书的验证路径。中间证书是否缺失、信任根是否不同,以及实际连接的是否为不同服务器节点,都需要排查。

不要在生产环境中使用现有的 badCertificateCallback。即使按域名返回 true,客户端仍会接受该域名下无法通过正常认证的证书,而攻击者可以冒充这个域名。Dart 文档说明,回调返回 true 就会接受连接。Dart 回调说明

为什么同一份代码在不同 Android 设备上表现不同?

Dart 文档说明,Android 上默认安全上下文的可信 CA 来自平台内置的证书存储。因此,同一份应用代码在可信证书集合不同的设备上,可能得到不同的验证结果。SecurityContext.defaultContext

一种可能是:Android 16 测试设备具有可用的 R46 信任路径,而 Android 13/14 测试设备缺少相应信任锚,服务器也没有提供通向旧信任根的交叉签名证书。需要检查设备证书存储和握手链才能确认,不能由此认为“所有 Android 13/14 都不信任 R46”。

SSL Labs 的 A- 评级和 Chain Issues: None 也无法证明每台客户端都能验证成功。即使扫描器能够构建信任路径,目标设备的信任根也可能不同。现有扫描结果只是摘要,尚不能确认应用实际收到的完整证书链。

优先修复服务器的兼容证书链

Sectigo 为新根证书提供了交叉签名路径,供尚未信任新根的客户端使用。对于题述 RSA 证书,重点检查是否可以通过以下路径验证:

myworkhub.talbridge.in 的服务器证书
  → Sectigo Public Server Authentication CA DV R36
  → Sectigo Public Server Authentication Root R46
    (由 USERTrust RSA Certification Authority 交叉签名)
  → 客户端已信任的 USERTrust RSA Certification Authority

Sectigo 官方资料列出了 R46 的 USERTrust 交叉签名证书及对应的 DV R36 bundle。Sectigo 证书层级与兼容证书包

注意区分两种 R46 证书:

  • 自签名 R46 根证书:作为信任锚,必须由客户端预先信任或显式信任。
  • USERTrust 交叉签名的 R46 证书:在兼容链中充当中间证书,将验证路径连接到 USERTrust 根。

把自签名 R46 放进服务器发送的链,并不会让旧设备自动信任它。

建议按以下顺序处理:

  1. 从 Sectigo 官方资料或证书签发平台获取与当前 DV R36 证书匹配的兼容链。
  2. 在实际终止 TLS 的位置配置服务器证书、DV R36 中间证书和交叉签名 R46 证书。该位置可能是 Web 服务器、CDN 或负载均衡器。
  3. 检查服务器实际发送的链。通常无需发送客户端已信任的自签名根证书。
  4. 删除证书校验绕过逻辑,再用原先失败的 Android 13/14 设备复测。

可以用以下命令查看服务器发送的证书:

openssl s_client \
  -connect myworkhub.talbridge.in:443 \
  -servername myworkhub.talbridge.in \
  -showcerts </dev/null

关注每张证书的 Subject、Issuer 和链条衔接。命令使用运行它的机器上的环境,验证结果不能代替 Android 真机测试。如果存在多个服务节点,还要确认它们提供的证书链是否一致。

无法调整服务器时,如何在 Flutter 中添加 R46?

对于使用 dart:io 的请求,可以创建独立的 SecurityContext,保留平台默认信任根,再加入经过核验的 R46 根证书。setTrustedCertificatesBytes 用于添加可信 X509 证书;withTrustedRoots: true 用于初始化默认信任根。SecurityContext 构造方法、添加可信证书

1. 准备并声明证书资源

从可信来源获取自签名的 Sectigo Public Server Authentication Root R46,核对证书身份、有效期及 SHA-256 指纹。不要直接把故障连接返回的未知证书当作可信根。

以下 Android 示例假设证书已保存为 PEM 格式,文件内容包含 -----BEGIN CERTIFICATE----- 和 -----END CERTIFICATE-----。不能只把 DER 文件改名为 .pem。

在现有 pubspec.yaml 的 flutter 节点中加入:

flutter:
  assets:
    - assets/certs/sectigo_r46.pem

Flutter 资源需要在 pubspec.yaml 中声明,之后可以通过 rootBundle 加载。Flutter 资源加载说明

2. 创建使用该安全上下文的客户端

import 'dart:io';
import 'package:flutter/services.dart';

Future<HttpClient> createApiHttpClient() async {
  final data = await rootBundle.load(
    'assets/certs/sectigo_r46.pem',
  );

  final context = SecurityContext(withTrustedRoots: true);
  context.setTrustedCertificatesBytes(
    data.buffer.asUint8List(
      data.offsetInBytes,
      data.lengthInBytes,
    ),
  );

  return HttpClient(context: context);
}

在 WidgetsFlutterBinding.ensureInitialized() 之后加载资源,并确保实际 API 请求使用这个客户端。先删除原有的 MyHttpOverrides 注册,以免它继续绕过验证,掩盖配置问题。

下面的函数只检查域名根路径的 HTTPS 连接,不能说明某个业务 API 一定会返回成功状态码:

Future<int> checkApiConnection() async {
  final client = await createApiHttpClient();

  try {
    final request = await client.getUrl(
      Uri.https('myworkhub.talbridge.in', '/'),
    );
    request.followRedirects = false;

    final response = await request.close();
    await response.drain<void>();
    return response.statusCode;
  } finally {
    client.close(force: true);
  }
}

添加可信 CA 后,客户端仍会执行证书链、有效期和主机名校验,不会像 badCertificateCallback 返回 true 那样直接接受验证失败的证书。

接入和维护时需要注意什么?

  • 这个安全上下文不会自动用于所有请求。如果业务请求仍使用另一个默认客户端,添加证书不会生效。使用封装库或原生网络插件时,需要确认实际网络实现及其客户端注入方式。
  • 添加根证书不等于域名绑定。该 CA 会成为此客户端的信任锚之一。建议把客户端封装在 API 服务层,只允许预期的 HTTPS 主机和端口,并明确处理重定向。
  • 添加 R46 不能补齐任意缺失的中间证书。服务器仍应提供叶子证书到 R46 之间所需的中间证书。
  • 内置 CA 需要维护。证书链迁移、信任策略变化或 CA 更换后,需要重新评估并更新应用资源。

验证时,先关闭所有绕过逻辑,再在原先失败的设备上测试。除了确认目标站点能够连接,还应在受控测试环境中确认不受信任的证书和主机名不匹配的证书仍会被拒绝。如果修复服务器兼容链后,默认客户端已经能够正常访问,就可以省去应用内额外的 CA 配置。

备注:内容仅供参考。