NetworkSecurityConfig 的证书固定,配置注意事项
NetworkSecurityConfig 的证书固定,配置注意事项
去年处理一个支付 SDK 的接入需求时,对方要求必须做证书固定(Certificate Pinning)。当时心想这玩意儿 Android 7.0 就原生支持了,NetworkSecurityConfig 里配几行 XML 就能搞定,应该很快。结果实际折腾了两周多,踩了三个大坑,其中两个在官方文档里要么没提、要么说得模棱两可。这篇文章把整个过程摊开,特别是那些"配置对了但就是不生效"的场景。
基础配置的正确姿势与常见误解
NetworkSecurityConfig 是 Android 7.0(API 24)引入的机制,通过在 AndroidManifest.xml 的 <application> 标签里指定 android:networkSecurityConfig="@xml/network_security_config" 来启用。最基础的证书固定配置长这样:
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<domain-config>
<domain includeSubdomains="true">api.example.com</domain>
<pin-set expiration="2025-12-31">
<pin digest="SHA-256">sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=</pin>
<pin digest="SHA-256">sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=</pin>
</pin-set>
</domain-config>
</network-security-config>这里有个细节很多人第一次配会搞错:includeSubdomains="true" 的位置是在 <domain> 标签里,不是在 <domain-config> 上。我见过有项目把 includeSubdomains 写成 <domain-config includeSubdomains="true">,编译不会报错,运行时也静默失败——子域名完全不生效,但主域名的固定是正常的。这种半生效状态最坑,测试环境如果只验证了主域名,上线后子域名的请求就直接绕过固定了。
另一个容易忽视的是 expiration 属性。官方文档说这是个"备份 pin 的过期时间",但实际上它控制的是整个 <pin-set> 的过期策略。如果所有 pin 都过期了,固定会失效,回退到系统默认的信任链验证。这个行为在 Android 14 之前有个 bug:过期后不会 crash,也不会走 cleartextTrafficPermitted 的逻辑,而是直接允许连接,相当于固定完全失效。Google 在 Android 14(API 34)修复了这个问题,过期后会抛出 SSLPeerUnverifiedException,但低版本设备上这个窗口期是真实存在的安全隐患。
pin 的生成方式也值得说一下。正确的命令是:
openssl s_client -connect api.example.com:443 -servername api.example.com </dev/null 2>/dev/null \
| openssl x509 -pubkey -noout \
| openssl pkey -pubin -outform der \
| openssl dgst -sha256 -binary \
| openssl enc -base64注意 -servername 参数必须加,否则遇到 SNI(Server Name Indication)配置的服务器,拿到的可能是默认证书的公钥,跟实际业务域名对不上。我就栽过这个:测试环境 SNI 没配好,用上述命令打出来的 pin 是 CDN 默认证书的,配到 NetworkSecurityConfig 里之后,正式环境请求直接挂掉,logcat 里 SSLPeerUnverifiedException 的 message 是 "Certificate pinning failure!",但完全不告诉你期望的 pin 和实际的 pin 分别是什么,排查全靠猜。
多证书链场景下的 pin 策略
现代服务器的证书配置往往不止一张叶子证书。以 Let's Encrypt 为例,他们的证书链在 2024 年有过一次重大调整:原来的 DST Root CA X3 交叉签名链在 2024 年 9 月彻底退役,取而代之的是 ISRG Root X1 的自签名链。很多服务器会同时配置两张中间证书,客户端根据信任库情况选择验证路径。
NetworkSecurityConfig 的 pin 到底应该打在哪个环节?答案是:打在叶子证书(即服务器直接返回的第一张证书)的公钥上,或者打在你预期信任链中任意一张证书的公钥上。系统会验证整条链,只要其中任意一个证书的公钥匹配 pin-set 中的某个 pin,就视为验证通过。
这个"任意匹配"的特性既是便利也是陷阱。便利在于你可以 pin 中间证书甚至根证书,减少叶子证书轮换时的运维压力;陷阱在于如果你 pin 了一个中间证书,而这个中间证书被多个不同域名共用,攻击者如果拿到了同一中间证书下的其他合法证书,理论上可以对你的域名发起中间人攻击——虽然这种场景在实际中极难构造,但安全审计时经常会被挑出来。
我个人更倾向于 pin 叶子证书,同时配置一个备份 pin。备份 pin 可以是下一次轮换的叶子证书,也可以是上一级中间证书作为兜底。关键是不能只配一个 pin,一旦证书意外轮换(比如私钥泄露紧急吊销),App 里唯一的 pin 失效,所有用户都连不上服务器,这比没做固定还惨。
实际遇到过的一个 case:某金融 App 只配了一个 pin,对应 RapidSSL 的某张中间证书。2023 年 RapidSSL 逐步淘汰 RSA 2048 中间证书,升级到 RSA 4096,新签发的叶子证书链到了新的中间证书上。App 没及时更新 pin,结果新证书部署后,Android 5.0-6.0 的设备(这些设备信任库较旧,会走新的证书链)全部无法连接,而 Android 7.0+ 的设备因为本地有旧的中间证书缓存,还能勉强走旧链撑几天。这种"部分用户正常、部分用户报错"的故障模式,排查起来极其痛苦。
与 OkHttp 的集成:谁说了算
很多项目并不是直接用 HttpsURLConnection,而是通过 OkHttp 发起请求。这里有个关键问题:OkHttp 从哪个版本开始、以什么方式尊重 NetworkSecurityConfig?
答案是:OkHttp 3.x 系列完全不读 NetworkSecurityConfig。它有自己的 CertificatePinner 机制,需要在代码里显式配置:
val certificatePinner = CertificatePinner.Builder()
.add("api.example.com", "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
.add("api.example.com", "sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=")
.build()
val client = OkHttpClient.Builder()
.certificatePinner(certificatePinner)
.build()OkHttp 4.x 开始引入了 android.net.http.X509TrustManagerExtensions 的调用,但仅限于某些特定场景,仍然没有自动集成 NetworkSecurityConfig。直到 OkHttp 4.12.0(2023 年 10 月发布),才在 okhttp-android 模块中提供了对 NetworkSecurityConfig 的实验性支持,而且需要显式依赖 com.squareup.okhttp3:okhttp-android 并调用 AndroidCertificateChainCleaner,默认行为仍然不变。
这个信息我花了很长时间才确认。最初以为是配置问题,反复检查 XML 语法、domain 匹配规则、debug-overrides 是否干扰,最后抓包发现 OkHttp 的握手过程根本没去查 NetworkSecurityConfig。翻 OkHttp 的源码,RealConnection.kt 里的 connectTls 方法直接用的是 HandshakeCertificates 里的 trust manager,跟系统 NetworkSecurityPolicy 是两条路。
所以实际项目里的正确做法是二选一:要么全用 NetworkSecurityConfig(要求所有网络库都走系统默认的 URLConnection 或支持它的封装),要么在 OkHttp 里配 CertificatePinner,同时把同样的 pin 信息同步到 NetworkSecurityConfig 里照顾 WebView 等场景。我们最后选了后者,因为代码里的 pinner 可以配合单元测试做自动化验证,XML 里的配置则作为兜底。
这里有个小工具可以分享:我写了一个 Gradle Task,在 CI 阶段自动从网络拉取目标域名的证书,生成 pin,跟代码里硬编码的对比,不一致就阻断构建。脚本核心逻辑就是前面提到的 openssl 命令链,加上对证书过期时间的检查(提前 60 天告警)。这个机制救过我们一次——某次证书供应商提前轮换,CI 在合并前就发现 pin 不匹配,避免了带着错误配置发版。
WebView 的特殊行为与 debug-overrides 的陷阱
如果 App 里有 WebView 加载 H5 页面,NetworkSecurityConfig 对 WebView 是否生效?答案是:部分生效,而且行为因 Android 版本而异。
从 Android 8.0(API 26)开始,WebView 开始支持 NetworkSecurityConfig,但有一个限制:WebView 只读取 `<base-config>` 和 `<domain-config>` 里的 `cleartextTrafficPermitted` 和 trust-anchors 配置,不处理 `<pin-set>`。也就是说,证书固定对 WebView 是无效的。如果你需要 WebView 也做固定,得自己实现 WebViewClient 的 onReceivedSslError 或者在 shouldOverrideUrlLoading 里做额外校验,但这两种方案都有明显缺陷:前者是事后拦截,连接已经建立了;后者只能处理导航请求,资源加载管不到。
更隐蔽的是 debug-overrides 的行为。开发时为了方便抓包,很多人会配这个:
<debug-overrides>
<trust-anchors>
<certificates src="user"/>
</trust-anchors>
</debug-overrides>这段配置的意思是:debug 包信任用户安装的证书(比如 Charles、Fiddler 的根证书)。它的生效条件是 android:debuggable="true" 或者 APK 由 debug key 签名。看起来很方便,但实际有个坑:某些国产 ROM 的"开发者模式"会强制让所有 App 处于可调试状态,或者他们的应用商店分发渠道会用 debug key 重签名。我们遇到过一台测试机,release 包安装后 debug-overrides 居然生效了,排查后发现是某厂商的"性能模式"在作祟——它为了做网络加速,注入了自己的证书,同时把系统属性 ro.debuggable 设成了 1。
这个 case 让我彻底放弃了 debug-overrides 方案。现在的做法是:debug 和 release 用完全独立的 XML 文件,debug 版本在 src/debug/res/xml/ 下放一个允许用户证书的配置,release 版本在 src/main/res/xml/ 下是严格配置,两者绝不共用。build.gradle 里通过 sourceSets 控制:
android {
sourceSets {
debug {
res.srcDirs = ['src/debug/res', 'src/main/res']
}
release {
res.srcDirs = ['src/main/res']
}
}
}这样即使 ROM 乱来,release 包也不会加载到 debug 配置。
证书轮换时的灰度与降级策略
证书固定最怕的就是轮换。行业里的最佳实践是 HPKP(HTTP Public Key Pinning)那套思路的变体:同时公布当前证书和下一个证书的 pin,给客户端一个过渡窗口。NetworkSecurityConfig 的 <pin-set> 天然支持多个 pin,就是为这个场景设计的。
但实际操作中,有个时间同步问题容易被忽略。expiration 属性写的是截止日期,但客户端怎么知道现在几点?靠的是系统时钟。如果用户设备时间被调慢(比如某些绕过试用的手段),expiration 判断会延后;如果时间被调快,可能提前失效。更麻烦的是,Android 的 NetworkSecurityConfig 解析是在应用启动时加载到内存的,运行期间不会重新读 XML。所以如果用户在一个 pin 已经过期的状态下启动 App,之后即使时间调正确了,这个过期判断也不会刷新——直到 App 进程被杀死重启。
我们做过一个实验:在 Android 13 设备上,把系统时间调到 pin expiration 之后一天,启动 App,触发固定失败的请求,得到 SSLPeerUnverifiedException。然后把时间调回正确值,不杀进程,再次请求,仍然失败。杀进程重启后恢复正常。这个行为说明 expiration 的检查是静态的,没有运行时重新评估。
基于这个观察,我们的 pin 策略做了调整:expiration 不设具体日期,而是设到一个足够远的未来(比如当前证书有效期后两年),同时通过 App 自身的配置下发机制控制 pin 的启用和停用。也就是说,NetworkSecurityConfig 里的 XML 作为"硬编码底线",保证即使配置下发失败也有基本固定;真正的灵活控制放在服务端下发的配置里,App 启动时动态构建 CertificatePinner 覆盖默认行为。这个双层架构增加了复杂度,但换来了轮换时的可控性。
具体到代码层面,OkHttp 的 CertificatePinner 支持运行时构建:
val pinsFromServer = configService.getCertificatePins() // 从服务端拉取
val pinnerBuilder = CertificatePinner.Builder()
pinsFromServer.forEach { (host, pinList) ->
pinList.forEach { pin -> pinnerBuilder.add(host, pin) }
}
// 兜底:如果服务端配置为空或解析失败,用本地硬编码
val certificatePinner = pinnerBuilder.build()服务端配置里我们放了两个字段:pins(当前生效的 pin 列表)和 next_pins(预备下次轮换的 pin 列表),App 每次启动都同时校验两者,确保本地代码里的硬编码、XML 里的配置、服务端下发的配置三者一致。任何不一致都会上报监控告警,但不断开用户连接——这是运营决策,技术层面完全可以做到强制一致才允许连接。
错误日志解读与调试技巧
证书固定失败时的异常信息,在不同场景下差异很大,也是排查时的主要障碍。
用 HttpsURLConnection 且固定失败时,异常链通常是:
javax.net.ssl.SSLPeerUnverifiedException: Certificate pinning failure!
Peer certificate chain:
sha256/xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx: CN=...
sha256/yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy: CN=...
Pinned certificates for api.example.com:
sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=这段信息非常清晰,会列出实际链上每个证书的 pin 和期望的 pin,直接对比就能定位问题。但要注意,这个详细的错误信息只在 Android 10(API 29)及以上才有。Android 7.0-9.0 的异常 message 只有简单的 "Certificate pinning failure",没有具体 pin 值,排查时需要自己额外打日志或者抓包。
OkHttp 的 CertificatePinner 失败时的异常更详细一些,不受系统版本限制:
javax.net.ssl.SSLPeerUnverifiedException: Certificate pinning failure!
Peer certificate chain:
sha256/xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx: CN=...
Pinned certificates for api.example.com:
sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=不过 OkHttp 只会打印叶子证书的信息,不会打印完整链,如果问题出在中级证书上,还是需要自己写代码遍历 X509Certificate[] 打日志。
调试时一个实用技巧:用 adb shell am set-debug-app --persistent com.example.app 把 App 设为可调试,然后配合 Android Studio 的 Network Inspector,可以看到 TLS 握手详情。但要注意,Network Inspector 本身会代理网络流量,如果配置了严格的证书固定,它自己的证书可能不被信任,导致抓不到包或者请求直接失败。这时候需要临时把 Network Inspector 的根证书 pin 加到配置里,或者改用 debug-overrides 信任用户证书(仅限调试环境)。
另一个冷门但有效的手段:直接用 openssl s_client -showcerts 看服务器返回的完整链,跟浏览器里看到的对比。有些 CDN 配置会对不同客户端返回不同链,Android 的 TLS 栈和 Chrome 的行为并不完全一致。我们遇到过服务器对 User-Agent 含 Android 的请求返回旧链、对其他请求返回新链的情况,这种"智能"配置在证书轮换过渡期简直是灾难。
与 Google Play 安全政策的交叉影响
2024 年 Google Play 更新了安全政策,对证书固定有了更明确的要求。虽然固定本身不是强制的,但如果做了,就必须确保不会导致用户无法正常访问。政策原文说的是 "Certificate pinning must not prevent users from accessing the app or its core functionality due to certificate expiration or misconfiguration"。
这个条款的解读空间很大。我们的理解是:固定配置必须有降级路径,不能一票否决。NetworkSecurityConfig 本身没有"固定失败时允许继续"的选项,它是硬阻断的。所以如果要符合这个政策,要么不用原生固定、改用应用层逻辑控制(比如固定失败时切换到备用域名,或者提示用户检查网络),要么确保固定配置的可靠性达到极高的水准。
我们选择了后者,但增加了监控和熔断。具体实现是在自定义的 TrustManager 里包装系统默认实现,在 checkServerTrusted 时先走正常的固定验证,如果失败,不是立即抛异常,而是上报一个特定错误码,同时根据本地缓存的"最近成功时间"决定是否放行。比如最近 7 天内有过成功连接,说明固定配置大概率是对的,这次失败可能是临时网络问题或攻击,直接阻断;如果超过 7 天没有成功连接,可能是固定配置真的出问题了,给用户一个"安全警告"弹窗,但允许继续操作。这个策略在安全和可用性之间做了折中,也通过了 Google Play 的审核。
代码层面大概是这样:
class PinningAwareTrustManager(
private val defaultTrustManager: X509TrustManager,
private val certificatePinner: CertificatePinner,
private val fallbackPolicy: FallbackPolicy
) : X509TrustManager {
override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) {
try {
// 先尝试固定验证
val peerCertificates = chain?.toList() ?: emptyList()
certificatePinner.check("api.example.com", peerCertificates)
// 成功,更新时间戳
fallbackPolicy.recordSuccess()
} catch (e: SSLPeerUnverifiedException) {
// 固定失败,评估是否熔断
if (!fallbackPolicy.shouldAllowFallback()) {
throw e
}
// 允许降级,但打日志告警
fallbackPolicy.recordFallback(e)
}
// 无论固定是否通过,最后都要走系统信任链
defaultTrustManager.checkServerTrusted(chain, authType)
}
// ... 其他方法委托给 defaultTrustManager
}这个实现有个细节:固定验证和系统信任链验证是分开的,即使固定通过了,系统信任链不通过仍然会失败。这是为了防止固定被绕过时完全失去保护。
最后几个零散但关键的点
NetworkSecurityConfig 的 XML 在编译时会被 AAPT 处理,但不会验证 pin 的 base64 格式是否正确。如果手滑打错了一个字符,运行时解析到这个错误的 pin 会直接忽略它,不会 crash,也不会在 logcat 里留任何痕迹。唯一的征兆是固定突然只对部分证书生效,排查起来很费劲。建议把 pin 的生成和校验做成自动化脚本,避免人工复制粘贴。
domain 标签的匹配规则是后缀匹配,不是精确匹配。配 <domain>example.com</domain> 会匹配 example.com、www.example.com、api.example.com,但不会匹配 badexample.com。这个行为在文档里有写,但很多人没注意到 includeSubdomains 的交互:如果开了 includeSubdomains="true",foo.bar.example.com 也会匹配。有些安全审计会要求显式列出所有子域名,关闭 includeSubdomains,减少意外匹配的风险。
Android 12(API 31)引入了 networkSecurityConfig 在 <base-extension> 和 <domain-extension> 中的合并机制,用于模块化场景。如果主 App 和某个 library 都声明了 NetworkSecurityConfig,合并规则是:相同 domain 的配置,library 的会覆盖主 App 的。这个行为跟 AndroidManifest 的合并逻辑类似,但更容易被忽视,因为 NetworkSecurityConfig 没有可视化的合并预览工具。我们在接入某个第三方 SDK 时,对方在 aar 里带了一个 network_security_config.xml,里面配了一个很宽的 domain-config,差点把我们自己的固定规则覆盖掉。最后是在 build.gradle 里用 pickFirst 强制只保留主工程的配置,同时跟 SDK 方沟通去掉他们的 XML。
还有一个版本差异:Android 9(API 28)以下,NetworkSecurityConfig 不支持 pem 格式的证书文件,只能用 raw resource ID 或者系统预置的证书。如果要在 XML 里引用自定义 CA 证书,需要把 PEM 文件放到 res/raw/ 下,然后用 <certificates src="@raw/my_ca"/>。Android 9+ 开始支持 src="directory" 批量加载,但语法是 <certificates src="system"/> 这种内置关键字,或者 src="@raw/...",仍然没有直接引用 assets 或外部路径的能力。
关于性能:NetworkSecurityConfig 的解析发生在应用启动的 Application 阶段,NetworkSecurityPolicy 的单例在第一次被调用时初始化。XML 解析本身耗时很短,我们测下来通常在 5ms 以内,即使配置很复杂(几十个 domain、上百个 pin)也不会超过 20ms。真正的性能敏感点在于每次 TLS 握手时的 pin 匹配,这是 O(n) 遍历,但 n 是单个 domain 的 pin 数量,通常不超过 3 个,完全可以忽略。如果非要优化,可以把最常用的 domain 配置放在 XML 前面,减少匹配次数——虽然实际收益微乎其微。
证书固定和 Certificate Transparency(CT)的关系也常被问到。CT 是 Chrome 推的机制,要求证书必须被公开日志记录,Android 系统本身不强制 CT,但 Google Play 的 SafetyNet/Play Integrity 会检查。NetworkSecurityConfig 里没有 CT 相关配置,两者是独立的安全层。固定防的是"合法 CA 被攻破或胁迫签发假证书",CT 防的是"CA 偷偷签发证书不被发现"。理想情况下两者都用,但 CT 在移动端的支持度远不如固定成熟,目前主流方案还是固定为主。
写到这里,基本把 NetworkSecurityConfig 证书固定的配置细节、版本差异、集成陷阱和运维策略都覆盖到了。这个机制看起来简单,实际落地时要考虑的场景远比文档里写的复杂,特别是当你的 App 需要支持多版本 Android、多种网络库、WebView 混合、证书轮换频繁时,任何一个环节的疏忽都可能导致安全失效或者服务中断。建议把固定配置纳入 CI/CD 的自动化校验,同时建立证书过期的监控告警,把人的不可靠性降到最低。