记一次处理Android证书链的问题

发布时间:2024-05-28 22:00
最后更新:2024-05-29 09:31
所属分类:
Android

在编写移动端应用的时候,我们常常会使用RESTful形式的API作为数据服务来使用,这就意味着数据服务端是基于HTTP的。随着近些年对于HTTP安全的越发重视,HTTPS越来越在更加广泛的范围内被推荐。但是TLS加密使用的证书这个东西,其实并不复杂,我们甚至在本地通过一些工具就可以生成一个供使用。这就带来了一个SSL证书使用时的一个安全问题:如何确保通讯过程中使用的SSL证书是安全的?

证书链

在HTTPS通讯过程中,服务器会向客户端提供一个CA证书(Certificate Authority)用来向客户端表明自己的身份。客户端可以通过验证这个证书的有效性来确认服务器的身份,从而达到保证通讯安全的目的。这个CA证书就是我们常说的SSL证书。

但是这里就有一个问题了,客户端是怎么确定服务器提供的这个CA证书是有效的?

服务器所提供的CA证书实际上是由其他第三方机构颁发的,相当于第三方担保。举个例子,这个第三方机构就相当于公证处,它向你要见的人(路人甲)提供了一张证明,来证明他的身份;你并不认识这个路人甲,所以并不相信他,但是你信任这个提供身份证明的公证处,所以你也就相信了你所见到的这个人就是路人甲。

这个例子看起来比较容易懂,因为在现实生活中,公证处往往是具有一定权威性的,所以不太容易产生一些其他的疑虑。但是在互联网上,就会出现一个质疑溯源的问题,那就是,我作为客户端,怎么确定颁发CA证书的第三方机构也是可信的?

要解决这个问题,实际上跟最开始的解决方案一样,再找一个机构来证明就好了。也就是这个新的机构在证明之前颁发CA证书的那个机构的可信度。这就已经开始形成一个只有两个环节的链条了,但是由于这种质疑溯源的存在,这个链条必然会无穷无尽的循环下去,形成一个非常长的链条。这个链条实际上就是由各个认证机构颁发的证书形成的,上面一级的机构签发下面一级机构的CA证书,所以这也就可以称为证书链,在很多情况下也常常称为受信证书锚点(Trust Anchor)。

但是在实际生活中,这个证书链并不是无穷无尽的。证书链的尽头就是根证书。根证书全称是根CA证书,是最顶级的证书颁发机构,这些机构也是被广泛信任的,它们的主要职责就是向其他的机构颁发和管理证书。

所以在进行HTTPS通讯的时候,客户端会根据服务器提供的CA证书,根据其中的签发信息,沿着证书链一直验证到根证书。能够完成整条证书链验证的CA证书,才是一个有效的CA证书。而且在Windows、Linux、macOS等操作系统中,实际也已经提前预装了一些根证书颁发机构的根证书,这样客户端就可以根据这些预装的根证书来验证服务器提供的CA证书了。

在Android开发中遇到的问题

其实我在完成Android开发过程中遇到的问题其实非常具有误导性。我的Android应用是使用React Native开发的,其中有一些功能需要访问RESTful形式的数据服务,所以也就需要使用fetch来完成基于HTTP的访问功能。为了方便调试,我将数据服务通过FRP映射到了用于开发的域名上,并且启用了HTTPS。然后在Android模拟器中运行的时候,fetch函数就报出了一个令人莫名其妙的Network request failed错误。

这个错误乍一看起来像是模拟器的网络不可用,而且模拟器上的Wifi和移动网络上的确也是挂着一个!标记。既然存在错误使设计功能不能正常运行,那么就需要研究一下了。

确定网络问题

经过在网络上的搜索,很多答案都集中到了模拟器的DNS设置上。因为模拟器的DNS始终展现为10.0.2.3,无法确定其是不是存在问题,所以只能通过adb shell来确认一下。

为了避免可能存在的问题,我在启动模拟器的时候就已经使用-dns-server "114.114.114.114,8.8.8.8"的参数为模拟器设置了DNS服务器,从院里上来说,如果这时在Shell里PING一些Ineternet上的网站,应该是没有问题的。

为了简化问题的排查,我直接选择了PING数据服务所在的域名。结果毫无疑问,网络是通畅的,不存在网络链路上的问题。

确定中间件问题

既然不是网络本身的问题,那么问题就集中在应用本身或者系统上了。

首先考虑的是应用中用来完成数据服务访问的是fetch,不是其他项目中常见的axios。所以更换一下数据访问的支持库来确定一下是否fetch在功能和实现上与其他项目中反映没有问题的axios存在区别。

替换的过程很简单,结果也很明确,更换成的axios同样报出了Network request failed错误。

尝试使用基于HTTP的明文数据服务

在网络上有一个搜索结果是反映,如果数据服务使用的是HTTP明文传输的话,是不存在这种问题的。那么这是可以比较容易验证的,只需要给数据服务主机上的Nginx配置调整一下即可。

这一次试验的结果,基本上把问题而核心锁定在了SSL证书上。因为在切换成使用基于HTTP的明文数据服务以后,fetch可以正常与数据服务通讯了。

尝试使用Native Module确定问题所在

fetch封装了非常多的内容,不可能用来完成进一步针对SSL证书的调试。想要完成进一步的检查,还需要使用更加贴近底层的方法。对于React Native来说,那就是使用OkHttp编写一个Native Module,自行控制网络访问。

如何编写一个基于OkHttp的Native Module,在网络上的示例很多,甚至ChatGPT也能给出一个能够直接使用的简短示例,所以这里不再赘述。

在使用Native Module访问基于HTTPS的数据服务以后,应用报出的错误发生了变化,从原来的Network request failed,变成了java.security.cert.CertPathValidatorException: Trust anchor for certification path not found。这种提示就已经非常明确了:无法找到可信的证书验证路径。换句话就是证书链不存在。

解决证书链不存在的问题

其实这个问题不是App或者React Native亦或是哪个中间件的问题,而是Android中存在的问题。这主要受Android严格的外部数据访问策略影响的,现目前比较常用的Android版本中,Android要求外部数据服务必须是基于HTTPS的。

这并不是说Android不允许访问基于HTTP的数据服务,而是如果要使用基于HTTP的数据服务,需要进行额外的配置。

但是现在能够提供免费SSL证书的机构有不少,这实际上对数据安全造成了一定的漏洞。为了安全起见,Androi系统中就没有预置这些机构的CA证书。所以如果数据服务使用的是这些证书颁发机构提供的SSL证书,那么在Android中就一定会报出这个缺少证书链的错误。

要解决这个问题主要有两种方法:

  1. 数据服务降级到HTTP使用,放弃更加安全的HTTPS通讯。
  2. 找一个能够补充证书链的方法。

很显然,第一种方法比较省事,但这并不是我所需要的。所以目标就集中在了如何向Android系统提供有效的证书链上。所幸,我所使用的证书颁发机构提供了一个现成的证书链,在我的文件系统中,这个证书链的文件名是fullchain.cert,看名字就知道这是服务器SSL证书的完整证书链。

在Android 7.0(API 24)以上的版本中,提供了一个细粒度管理网络安全的设置。这个配置需要在Android项目的res/xml目录中放置一个名为network_security_config.xml的文件来描述网络安全方面的配置。在这个文件中有一项功能就是为应用添加证书链,为了方便引用证书链文件,现在将fullchain.cert文件放置到Android项目目录的res/raw文件夹下。然后就可以编写以下配置。

首先是编写network_security_config.xml文件。

1
2
3
4
5
6
7
8
9
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
  <base-config cleartextTrafficPermitted="true">
    <trust-anchors>
      <certificates src="system" />
      <certificates src="@raw/fullchain" />
    </trust-anchors>
  </base-config>
</network-security-config>

在这个配置文件中声明了cleartexttrafficPermittedtrue,这是因为在React Native开发过程中有一些功能需要使用到明文传输,如果设置为false可能会出现应用无法加载的问题。配置文件中通过<trust-anchors>来配置仅针对应用真神起效的证书链,也就是应用本身信任的证书链。

这里将服务器CA证书颁发机构提供的证书链文件通过<certificates>引入,就完成了应用所使用证书链的扩展。接下来就是在AndroidManifest.xml文件中登记新编写的network_security_config.xml文件配置。

AndroidManifest.xml文件中找到<application>元素,在其中添加android:networkSecurityConfig配置项。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

  <uses-permission android:name="android.permission.INTERNET" />
  <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
  <uses-permission android:name="android.permission.READ_PHONE_STATE" />

  <application
      android:networkSecurityConfig="@xml/network_security_config"
      android:name=".MainApplication"
      android:label="@string/app_name"
      android:icon="@mipmap/ic_launcher"
      android:roundIcon="@mipmap/ic_launcher_round"
      android:allowBackup="false"
      android:theme="@style/AppTheme">
    <activity
        android:name=".MainActivity"
        android:label="@string/app_name"
        android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode"
        android:launchMode="singleTask"
        android:windowSoftInputMode="adjustResize"
        android:exported="true">
      <intent-filter>
        <action android:name="android.intent.action.MAIN" />
        <category android:name="android.intent.category.LAUNCHER" />
      </intent-filter>
    </activity>
  </application>
</manifest>

完成增加这个配置以后,重新启动React Native应用,现在fetch已经可以正常的访问基于HTTPS的数据服务了。

network_security_config.xml中可以完成的配置功能

network_security_config.xml文件中常用的配置功能主要有以下几种:

  1. 进行全局明文流量配置,这个已经在上面的示例中见到了,就是在<base-config>元素中定义属性cleartextTrafficPermitted的值,将其值设置为false可以允许直接访问基于HTTP的数据服务。
  2. 配置针对于特定域的配置,这是通过<domain-config>系列元素来完成的。
  3. 为特定域以及特定路径增加受信证书链。
  4. 配置调试证书。

network_security_config.xml文件的根元素是<network-security-config>,其中可用的配置元素以及节点结构可以参考以下格式示范。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
  <!--base-config节点至多可以出现一次,用于配置domain-config未覆盖的连接-->
  <base-config cleartextTrafficPermitted="true|false">
    <!--配置受信证书链-->
    <trust-anchors>
      <!--system证书表示使用系统提供的证书链-->
      <certificates src="system"/>
      <!--certificates节点可以有任意数量用来定义受信证书链,自定义证书链需要放置在应用项目内,并在此引用-->
      <certificates src="system|user|raw resource" overridePins="true|false"/>
    </trust-anchors>
  </base-config>
  <!--domain-config节点可以有任意数量,用于配置特定目标的安全策略-->
  <domain-config cleartextTrafficPermitted="true|false">
    <!--至少包含一个domain配置,用来设定domain-config的特定目标,includeSubdomains用来指示当前配置是否适用于子域名-->
    <domain includeSubdomains="true|false">example.com</domain>
    <trust-anchors>
      <certificates src=""/>
    </trust-anchors>
    <!--pin-set节点用于定义一组公钥固定,用于信任安全链接的配置-->
    <pin-set expiration="date">
      <pin digest="SHA-256">公钥base64编码</pin>
    </pin-set>
  </domain-config>
  <!--debug-overrides节点用来在调试构建时覆盖以上的配置-->
  <debug-overrides>
    <trust-anchors>
      <certificates src="" overridePins=""/>
    </trust-anchors>
  </debug-overrides>
</network-security-config>

如果计划在服务器上使用自签名证书来使数据服务支持HTTPS,那么可以直接在域名的<domain-config>节点的<trust-anchors>中配置生成的证书即可。或者还可以将公钥的Base64编码的SHA-256 Hash值写在<pin-set>节点中来直接配置域名所使用的自签名公钥。


索引标签
Android
React Native
移动开发