手机和操作系统

HandshakeException CERTIFICATE_VERIFY_FAILED(Flutter)怎么修

作者: · 最后核实

报错信息HandshakeException: Handshake error in client (OS Error: CERTIFICATE_VERIFY_FAILED

HandshakeException: Handshake error in client (OS Error: CERTIFICATE_VERIFY_FAILED ...) 的意思是:Dart 的 TLS 栈验证不了服务器证书,连接在发出任何 HTTP 请求之前就被关闭了。 Flutter 应用不管用的是 HttpClient、package:http 还是 Dio,在手机上底层都是 dart:io,都会遇到它。具体原因写在 CERTIFICATE_VERIFY_FAILED: 后面,修法也取决于这部分。

先看冒号后面写的什么

冒号后面的文字决定修法,最常见的是 unable to get local issuer certificate。完整的报错长这样(最后括号里的源文件和行号随版本变化):

HandshakeException: Handshake error in client (OS Error:
    CERTIFICATE_VERIFY_FAILED: unable to get local issuer certificate(handshake.cc:393))

Dart 把 BoringSSL 的原因码 CERTIFICATE_VERIFY_FAILED 和证书校验结果拼在一起:

冒号后面的文字 含义 对应小节
unable to get local issuer certificate 链里某张证书找不到受信任的签发者 1、3
self signed certificate 服务器证书是自签名的 2
self signed certificate in certificate chain 链的终点是设备不信任的根 2
Hostname mismatch 证书没有覆盖你访问的主机名 4
certificate has expired / certificate is not yet valid 日期问题,或者设备时间不对 5

别和这些报错搞混

HandshakeException: Connection terminated during handshake 是另一回事:连接在检查证书之前就断了,原因在协议不匹配、代理或服务器主动断开,而不是证书信任。不带 CERTIFICATE_VERIFY_FAILED 的其他 HandshakeException,比如 WRONG_VERSION_NUMBER,说明那个端口上跑的不是 TLS。证书校验以外的握手失败,从 SSL 握手失败总览 看起。

Dart 信任哪些证书

Dart 用的是各平台的系统信任库,不读用户安装的 CA,也不自动下载缺失的中间证书。

  • 安卓: Dart 不自带根证书,而是读取设备的系统 CA 目录:Android 14 及以上是 /apex/com.android.conscrypt/cacerts,更早的版本是 /system/etc/security/cacerts。用户自己安装的 CA 不在其中。
  • iOS 和 macOS: Dart 把证书链交给苹果系统做信任校验,再加上你在 SecurityContext 里配置的证书。
  • Windows 和 Linux 桌面: 操作系统的根证书库,或者标准 CA 证书包路径。

Dart 在安卓上不走 Java 那套网络栈,而是用自带的 BoringSSL 直接读取系统 CA 目录。所以你在安卓工程的 network_security_config.xml 里写的信任设置(比如信任用户证书、信任某个自定义 CA),对原生 Java/Kotlin 代码有效,对 dart:io 发出的请求不起作用。同一个接口,原生页面正常、Flutter 页面报错,往往就是这个原因。

推荐的排查顺序

排查从冒号后面的原因文字开始,先查服务器证书链,再查设备,最后才动代码里的信任设置。

  1. 记下冒号后面的原因文字,对照上面的表格判断属于哪一类。
  2. 用 openssl s_client -showcerts 看服务器发了几张证书、证书里有哪些域名、有效期是否正常。
  3. 在另一台较新的安卓真机和 iOS 设备上各跑一次,判断是不是只有某台设备的问题。
  4. 确认请求的地址和证书里的域名完全一致,没有用 IP 或别名。
  5. 最后才考虑在代码里调整信任设置,而且只在调试版里调整。

常见原因(按出现频率排序)

最常见的原因是服务器没发中间证书:浏览器能自己补上,Dart 不会。

1. 服务器没发中间证书

“浏览器里明明正常”多半就是这个原因。在阿里云、腾讯云下载证书后手工部署时,如果只用了证书文件里的第一段,或者 Nginx 配成了不含中间证书的文件,就会这样。数一数服务器发了几张证书:

openssl s_client -connect api.example.com:443 -servername api.example.com -showcerts </dev/null 2>/dev/null \
  | grep -c 'BEGIN CERTIFICATE'

公共 CA 的证书只返回 1 张,就是链不完整,要在服务器上修,见 缺少中间证书。

2. 开发服务器或抓包代理用了内部 CA

本地起的 HTTPS 服务、用内部 CA 的测试环境接口,或者中间挂了 Charles、Fiddler、Proxyman、mitmproxy。在安卓上,把代理的 CA 装进系统设置也没用,因为 Dart 只读系统 CA 目录。抓包时 Flutter 应用的请求抓不到或直接报这个错,原因就在这里。

3. 老安卓设备缺根证书

老版本安卓的系统证书库里没有较新的根证书。Dart 依赖这个证书库,只链到这些新根的证书在这些手机上就会失败,见 老安卓与 Let's Encrypt。

4. 主机名不匹配

应用访问的是 IP、内部别名,或者在安卓模拟器里访问 10.0.2.2(安卓文档说明这是指向开发电脑本机回环接口的特殊别名),而证书里写的是别的名字。

5. 设备时间不对

日期偏差很大的设备会把正常证书判为过期或尚未生效。打开自动设置日期和时间即可。

怎么修

生产环境在服务器上补全证书链,开发环境只在调试版里信任内部 CA,发布版不放行任何证书。

生产服务器: 发送完整证书链(Nginx 用 fullchain.pem,其他服务器把中间证书放进证书链文件),再用 证书链工具 检查。

开发环境和内部 CA: 只在调试版里显式信任你的 CA:

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

Future<HttpClient> buildClient() async {
  final context = SecurityContext(withTrustedRoots: true);
  if (kDebugMode) {
    final ca = await rootBundle.load('assets/dev-ca.pem');
    context.setTrustedCertificatesBytes(ca.buffer.asUint8List());
  }
  return HttpClient(context: context);
}

用 Dio 的,在 IOHttpClientAdapter(createHttpClient: ...) 里返回这个 client;Dio 在手机上默认的适配器就是 IOHttpClientAdapter,底层正是 dart:io 的 HttpClient。用 package:http 的,把它包进 IOClient。

不要在任何要发布的代码里写 client.badCertificateCallback = (cert, host, port) => true;。Dart 文档对这个回调的描述是:决定是否接受一张无法被任何受信任根证书验证的证书;返回 true 就是全部接受。

模拟器: 签发开发证书时把应用会用到的域名和 IP 都写进去,需要的话包括 10.0.2.2。

怎么确认已经修好

修好的标准是:openssl 验证返回 0,真机不覆盖回调也能连上,发布代码里没有返回 true 的回调。

  1. openssl s_client -connect api.example.com:443 -servername api.example.com -verify_return_error </dev/null 最后显示 Verify return code: 0 (ok)。
  2. 在安卓真机和 iOS 上分别运行,不覆盖回调也能正常连接。
  3. 在代码里搜索 badCertificateCallback,确认调试代码之外没有任何地方返回 true。公网接口再用 SSL 检测 复查一遍。

常见问题

把 badCertificateCallback 设成返回 true 行不行?

只能在一次性的调试里临时用。返回 true 等于接受任何证书,包括公共 Wi-Fi 上攻击者出示的证书。实在要用,至少用 kDebugMode 包起来并判断主机名;更好的做法是用 SecurityContext 信任你自己的开发 CA。

接口在浏览器、Postman 里都正常,Flutter 应用里却报错,为什么?

浏览器会用缓存或自动下载补全缺失的中间证书,桌面工具用的是电脑的信任库。安卓上的 Dart 只用手机系统根证书和服务器发来的证书链,链不完整、或者 CA 只在你电脑上受信任,应用里就会失败。

为什么只有老安卓手机报错?

安卓上的 Dart 用的是手机自带的系统 CA 目录。老版本安卓没有较新的根证书,只链到这些新根的证书就会失败,详见 老安卓与 Let's Encrypt。

安卓模拟器上报 CERTIFICATE_VERIFY_FAILED: Hostname mismatch 是怎么回事?

应用多半访问的是 https://10.0.2.2,这是模拟器里指向开发电脑本机回环地址的特殊别名,而开发证书里只写了 localhost。给开发证书加上 10.0.2.2 这个 IP SAN,或者让模拟器通过证书覆盖的域名访问。

资料来源

  1. Dart SDK: secure_socket_filter.cc(HandshakeException)
  2. Dart SDK: security_context_linux.cc(安卓根证书目录)
  3. Dart API: SecurityContext
  4. Dart API: HttpClient.badCertificateCallback
  5. Android Studio: 模拟器网络地址
  6. pub.dev: dio