1. 介绍
最近在Android项目上,需要在native层提供一个单独与服务器进行通信的组件,于是便在App的SDK中加入了Curl和OpenSSL库,以期望能在C++层正常使用HTTPS与服务端进行通信。在搭建的过程中发现Android上使用OpenSSL 1.1.1版本无法正常使用系统CA证书的问题,在此予以记录。
2. 背景知识
下面先介绍一下curl及证书校验时的方式,方便后续问题的描述。
2.1 使用Curl时控制证书校验
可以通过开启CURLOPT_SSL_VERIFYPEER来验证对端(服务端)的证书,CURLOPT_SSL_VERIFYHOST 用于控制是否严格验证主机名,验证服务器的身份是否与证书中的主机名相匹配。
CURLOPT_CAPATH字段可以传入一个文件夹路径,curl会自动使用文件夹下的所有可能的CA证书文件来校验服务器证书。 CURLOPT_CAINFO字段可以传入一个文件路径,curl会默认使用该文件的内容作为CA证书来校验服务器。
2.2 CA证书的存放
Linux下的CA证书存放位置在/etc/ssl/certs下:
Android的CA证书存放在
/etc/security/cacerts下:
🧐 为什么有的命名是<Certificate_Hash>.<Number>,有的是<Certificate_Name>.pem?
- 首先这些证书都是X509标准,而且严格上来讲,都是.pem的存储格式
- <Certificate_Hash>.<Number>的文件名是Openssl校验证书的一种机制。
X509中Issuer字段表明的是“证书的签发者”(CA的名称),Subject字段表明的是“证书的主体”(证书所属人),根CA是信任源,其证书是一个自签发证书,所以Issuer和Subject字段是一致的。
<Certificate_Hash>更准确来说是CA证书的Issuer/Subject字段生成的Hash。<Number>是当前Hash值发生碰撞时加的偏移索引。
在一次SSL握手过程中,当收到服务器的证书时,openssl会提取服务器证书的Issuer字段,拿到证书的颁发者的名称,然后用同样的方式生成对应的Hash,在本地保存的CA证书文件中,通过文件名,尝试找到对应的CA证书,然后用CA的公钥来校验服务器证书。
以下是一个.pem格式的证书文件,可以看见主要部分是使用Base64编码。
对应的X509证书的“明文”部分,对于CA证书,颁发者Issuer和主体Subject两个字段是一致的:
3. 问题场景
在Android环境下,我们使用Curl+OpenSSL来进行SSL,并正确设置了系统的CA证书文件夹,但是依然提示"unable to get local issuer certificate",没办法找到“本地的(服务器证书)颁发者的证书”。
如果了解上面的证书知识,我们知道问题的直接原因——Android本地没办法找到能用来验证服务端证书的CA证书。那么进一步就有以下的怀疑方向:
- Android上缺少相应的CA证书
- CA证书的匹配机制有问题
那么后续可以有的一个正向的处理方式就是确认服务器证书的颁发者,以及解开Android本地系统存放的CA证书,确认是否有遗漏。
没有同名CA证书 => 服务器用的证书太小众(不太可能)
有同名CA证书 => 匹配机制有问题
通过对服务器证书进行抓取,可以看到颁发者Issuer字段为GlobalSign,是个老牌的CA机构,基本可以排除服务器的证书问题。
那么接下来就是确认匹配机制的问题。
4. 原因
在经过查阅后发现,造成问题的原因是OpenSSL,在OpenSSL 1.0以前,会默认使用MD5算法来查找CA证书的文件名,而在OpenSSL 1.0之后是使用SHA1算法(此外输入内容也不相同,后面会提及)。
而Android上,系统的CA证书文件名是按旧方式使用MD5处理的(系统自带的openssl版本并不旧,只是默认如此处理),而在SDK放入的OpenSSL库,使用的是1.1.1版本,默认是使用新的方式来搜寻文件。
下图摘自:curl mails:How to make SSL peer_verify work on Android?
5. 解决办法
在了解问题是由于OpenSSL检索CA证书的Hash方式不同之后,我们可以有以下几类解决方法。
5.1 换用0.9.*的OpenSSL
降低SDK的OpenSSL版本,旧版本的OpenSSL默认使用MD5来生成CA文件名。这种方式风险是使用降版本的三方库,可能存在安全隐患。
5.2 修改OpenSSL中的文件名生成方式
比较Hack的方式,修改SDK中OpenSSL的源码,在Android上换用旧版的方式来查文件名。需要重新编译。这种方式下需要我们手动修改三方库的源码,自行维护,测试面也不确定。
5.3 使用单独打包的证书文件
可以在curl的网站CA certificates extracted from Mozilla上下载打包好的CA证书文件(单文件,内含多组CA证书)。在使用Curl时通过CURLOPT_CAINFO传入。
这个应该是最合适的方式,不需要修改或更换OpenSSL库,变动较小,但需要App维护额外的证书文件,每次请求都需要明确设置文件路径。
p.s. 这个文件中含多个CA证书,如果需要使用CURLOPT_CAPATH的方式设置,需要调用openssl的工具拆分及重新命名生成hash。
6. CA证书名的生成方式
下面我们来更深入探索一下新旧两种方式下CA证书名的生成方式。
6.1 新旧方法的差异
事实上,从代码上看新旧两种方式,不止是Hash算法所用的不一样。 在新版本上,openssl会将证书部分转换成“canonical encoding”(规范编码)的形式,再计算的hash。 以下是新方法X509_NAME_hash()和旧方法X509_NAME_hash_old()的对比,可以看见旧方法中输入是直接使用的x->bytes,而新方法中,输入则是x->canon_enc,并且计算hash的方法也不一样。
“canonical encoding”的内容可以用X509_name_canon()这个方法得到。
6.2 旧式CA证书名生成(Android下的)
下面我们通过实验来验证下旧式证书名的生成方式,下面使用了从Android系统下获取到的一个证书文件01419da9.0
6.2.1 openssl命令
可以使用下面的方式来获得证书对于subject字段的hash
openssl x509 --subject_hash_old -in <证书> --noout
可以看见得到的CA文件名符合预期,和Android上的文件名一致:01419da9
6.2.2 计算方式
我们可以通过下面的方式来“手动”计算证书的文件名
- 将证书文件放入wireshark,可以看到DER格式的证书内容
- 选中subject字段
- 拷贝对应的字段,使用下面的方式计算md5(实际上这里就是拿到对应的subject对应的bytes字段,计算了md5)
echo '30 65 31 0b 30 09 06 03 55 04 06 13 02 55 53 31 1e 30
1c 06 03 55 04 0a 13 15 4d 69 63 72 6f 73 6f 66
74 20 43 6f 72 70 6f 72 61 74 69 6f 6e 31 36 30
34 06 03 55 04 03 13 2d 4d 69 63 72 6f 73 6f 66
74 20 45 43 43 20 52 6f 6f 74 20 43 65 72 74 69
66 69 63 61 74 65 20 41 75 74 68 6f 72 69 74 79
20 32 30 31 37' | xxd -r -p | md5sum
# 以下是输出
# a99d4101e97a6fa07417d461a9a3407f -
可以看到下图中对应的输出,将前4个字节进行大小端转换后就是预期的输出01419da9
6.3 新式CA证书名生成 (openssl 1.x)
下面我们尝试验证下新式证书名的生成方式,输入的仍为6.2中的CA证书文件01419da9.0,但在新方式下,它输出的hash值应该不同。
6.3.1 openssl命令
openssl x509 --subject_hash -in <证书名> --noout
可以看见,这个CA文件在新式的计算方式下,文件名应该为8d89cda1
6.3.2 计算方式(?)
要想使用新方式计算CA的hash,我们需要先获得证书Issuer字段的规范编码内容,我们可以在Openssl的代码中加一点打印,获得输入:
可以得到对应的输入内容:
同样的,我们可以通过下面的方法生成新式的hash:
echo '31 0b 30 09 06 03 55 04 06 0c 02 75 73 31 1e 30 1c 06 03 55 04 0a 0c 15 6d 69 63 72 6f 73 6f 66 74 20 63 6f 72 70 6f 72 61 74 69 6f 6e 31 36 30 34 06 03 55 04 03 0c 2d 6d 69 63 72 6f 73 6f 66 74 20 65 63 63 20 72 6f 6f 74 20 63 65 72 74 69 66 69 63 61 74 65 20 61 75 74 68 6f 72 69 74 79 20 32 30 31 37' | xxd -r -p | sha1sum
# 以下为输出
# a1cd898ddce7b6e4232ff5ad227b0bda455d547a -
下图为对应的输出,将前4个字节大小端转换后即为预期值8d89cda1
6.4 其他
- 可以看到实验过程中这种“canonical encoding”与原本“明文”的区别,至少文本可读部分,都统一转换成了小写。
- 在linux下是这个证书文件是存在的,但是是新式的文件名
7. 总结
在上面内容中,我们讨论了在Android上使用Curl+OpenSSL进行HTTPS请求时,无法使用系统CA证书对服务器证书进行验证的问题,了解到问题的原因在与Android上的系统CA证书名与新版OpenSSL的证书名生成和检索方式不同,并给出了三种解决方式,此外还讨论了新旧两种方式下CA证书名的生成方式。