HTTPS 理论基础及其在 Android 中的最佳实践

我们知道,HTTP请求都是明文传输的,所谓的明文指的是没有经过加密的信息,如果HTTP请求被黑客拦截,并且里面含有银行卡密码等敏感数据的话,会非常危险。为了解决这个问题,Netscape 公司制定了HTTPS协议,HTTPS可以将数据加密传输,也就是传输的是密文,即便黑客在传输过程中拦截到数据也无法破译,这就保证了网络通信的安全。

密码学基础

在正式讲解HTTPS协议之前,我们首先要知道一些密码学的知识。

明文: 明文指的是未被加密过的原始数据。

密文:明文被某种加密算法加密之后,会变成密文,从而确保原始数据的安全。密文也可以被解密,得到原始的明文。

密钥:密钥是一种参数,它是在明文转换为密文或将密文转换为明文的算法中输入的参数。密钥分为对称密钥与非对称密钥,分别应用在对称加密和非对称加密上。

对称加密:对称加密又叫做私钥加密,即信息的发送方和接收方使用同一个密钥去加密和解密数据。对称加密的特点是算法公开、加密和解密速度快,适合于对大数据量进行加密,常见的对称加密算法有DES、3DES、TDEA、Blowfish、RC5和IDEA。
其加密过程如下:明文 + 加密算法 + 私钥 => 密文
解密过程如下:密文 + 解密算法 + 私钥 => 明文
对称加密中用到的密钥叫做私钥,私钥表示个人私有的密钥,即该密钥不能被泄露。
其加密过程中的私钥与解密过程中用到的私钥是同一个密钥,这也是称加密之所以称之为“对称”的原因。由于对称加密的算法是公开的,所以一旦私钥被泄露,那么密文就很容易被破解,所以对称加密的缺点是密钥安全管理困难。

非对称加密:非对称加密也叫做公钥加密。非对称加密与对称加密相比,其安全性更好。对称加密的通信双方使用相同的密钥,如果一方的密钥遭泄露,那么整个通信就会被破解。而非对称加密使用一对密钥,即公钥和私钥,且二者成对出现。私钥被自己保存,不能对外泄露。公钥指的是公共的密钥,任何人都可以获得该密钥。用公钥或私钥中的任何一个进行加密,用另一个进行解密。
被公钥加密过的密文只能被私钥解密,过程如下:
明文 + 加密算法 + 公钥 => 密文, 密文 + 解密算法 + 私钥 => 明文
被私钥加密过的密文只能被公钥解密,过程如下:
明文 + 加密算法 + 私钥 => 密文, 密文 + 解密算法 + 公钥 => 明文
由于加密和解密使用了两个不同的密钥,这就是非对称加密“非对称”的原因。
非对称加密的缺点是加密和解密花费时间长、速度慢,只适合对少量数据进行加密。
在非对称加密中使用的主要算法有:RSA、Elgamal、Rabin、D-H、ECC(椭圆曲线加密算法)等。

HTTPS通信过程

HTTPS协议 = HTTP协议 + SSL/TLS协议,在HTTPS数据传输的过程中,需要用SSL/TLS对数据进行加密和解密,需要用HTTP对加密后的数据进行传输,由此可以看出HTTPS是由HTTP和SSL/TLS一起合作完成的。

SSL的全称是Secure Sockets Layer,即安全套接层协议,是为网络通信提供安全及数据完整性的一种安全协议。SSL协议在1994年被Netscape发明,后来各个浏览器均支持SSL,其最新的版本是3.0

TLS的全称是Transport Layer Security,即安全传输层协议,最新版本的TLS(Transport Layer Security,传输层安全协议)是IETF(Internet Engineering Task Force,Internet工程任务组)制定的一种新的协议,它建立在SSL 3.0协议规范之上,是SSL 3.0的后续版本。在TLS与SSL3.0之间存在着显著的差别,主要是它们所支持的加密算法不同,所以TLS与SSL3.0不能互操作。虽然TLS与SSL3.0在加密算法上不同,但是在我们理解HTTPS的过程中,我们可以把SSL和TLS看做是同一个协议。

HTTPS为了兼顾安全与效率,同时使用了对称加密和非对称加密。数据是被对称加密传输的,对称加密过程需要客户端的一个密钥,为了确保能把该密钥安全传输到服务器端,采用非对称加密对该密钥进行加密传输,总的来说,对数据进行对称加密,对称加密所要使用的密钥通过非对称加密传输。

以下图片来自于limboy的博客
《HTTPS 理论基础及其在 Android 中的最佳实践》

HTTPS在传输的过程中会涉及到三个密钥:

  • 服务器端的公钥和私钥,用来进行非对称加密

  • 客户端生成的随机密钥,用来进行对称加密

一个HTTPS请求实际上包含了两次HTTP传输,可以细分为8步。

  1. 客户端向服务器发起HTTPS请求,连接到服务器的443端口。

  2. 服务器端有一个密钥对,即公钥和私钥,是用来进行非对称加密使用的,服务器端保存着私钥,不能将其泄露,公钥可以发送给任何人。

  3. 服务器将自己的公钥发送给客户端。

  4. 客户端收到服务器端的公钥之后,会对公钥进行检查,验证其合法性,如果发现发现公钥有问题,那么HTTPS传输就无法继续。严格的说,这里应该是验证服务器发送的数字证书的合法性,关于客户端如何验证数字证书的合法性,下文会进行说明。如果公钥合格,那么客户端会生成一个随机值,这个随机值就是用于进行对称加密的密钥,我们将该密钥称之为client key,即客户端密钥,这样在概念上和服务器端的密钥容易进行区分。然后用服务器的公钥对客户端密钥进行非对称加密,这样客户端密钥就变成密文了,至此,HTTPS中的第一次HTTP请求结束。

  5. 客户端会发起HTTPS中的第二个HTTP请求,将加密之后的客户端密钥发送给服务器。

  6. 服务器接收到客户端发来的密文之后,会用自己的私钥对其进行非对称解密,解密之后的明文就是客户端密钥,然后用客户端密钥对数据进行对称加密,这样数据就变成了密文。

  7. 然后服务器将加密后的密文发送给客户端。

  8. 客户端收到服务器发送来的密文,用客户端密钥对其进行对称解密,得到服务器发送的数据。这样HTTPS中的第二个HTTP请求结束,整个HTTPS传输完成。

数字证书

通过观察HTTPS的传输过程,我们知道,当服务器接收到客户端发来的请求时,会向客户端发送服务器自己的公钥,但是黑客有可能中途篡改公钥,将其改成黑客自己的,所以有个问题,客户端怎么信赖这个公钥是自己想要访问的服务器的公钥而不是黑客的呢? 这时候就需要用到数字证书。

在讲数字证书之前,先说一个小例子。假设一个镇里面有两个人A和B,A是个富豪,B想向A借钱,但是A和B不熟,怕B借了钱之后不还。这时候B找到了镇长,镇长给B作担保,告诉A说:“B人品不错,不会欠钱不还的,你就放心借给他吧。” A听了这话后,心里想:“镇长是全镇最德高望重的了,他说B没问题的话那就没事了,我就放心了”。 于是A相信B的为人,把钱借给了B。

与此相似的,要想让客户端信赖公钥,公钥也要找一个担保人,而且这个担保人的身份必须德高望重,否则没有说服力。这个担保人的就是证书认证中心(Certificate Authority),简称CA。 也就是说CA是专门对公钥进行认证,进行担保的,也就是专门给公钥做担保的担保公司。 全球知名的CA也就100多个,这些CA都是全球都认可的,比如VeriSign、GlobalSign等,国内知名的CA有WoSign。

那CA怎么对公钥做担保认证呢?CA本身也有一对公钥和私钥,CA会用CA自己的私钥对要进行认证的公钥进行非对称加密,此处待认证的公钥就相当于是明文,加密完之后,得到的密文再加上证书的过期时间、颁发给、颁发者等信息,就组成了数字证书。

不论什么平台,设备的操作系统中都会内置100多个全球公认的CA,说具体点就是设备中存储了这些知名CA的公钥。当客户端接收到服务器的数字证书的时候,会进行如下验证:

  1. 首先客户端会用设备中内置的CA的公钥尝试解密数字证书,如果所有内置的CA的公钥都无法解密该数字证书,说明该数字证书不是由一个全球知名的CA签发的,这样客户端就无法信任该服务器的数字证书。

  2. 如果有一个CA的公钥能够成功解密该数字证书,说明该数字证书就是由该CA的私钥签发的,因为被私钥加密的密文只能被与其成对的公钥解密。

  3. 除此之外,还需要检查客户端当前访问的服务器的域名是与数字证书中提供的“颁发给”这一项吻合,还要检查数字证书是否过期等。

通过浏览器直接获取服务器的公钥很容易,各个浏览器操作大同小异。百度现在已经实现了全站点HTTPS,我们就以百度为例如何从Chrome中获取其公钥。

  1. 用Chrome打开百度首页,在https左侧我们会发现有一个绿色的锁头。
    《HTTPS 理论基础及其在 Android 中的最佳实践》

  2. 点击该锁头,出现一个弹出面板,点击面板中的“详细信息”几个字。
    《HTTPS 理论基础及其在 Android 中的最佳实践》

  3. 这是会打开Chrome的Developer Tool,并自动切换到Security这个页面。
    《HTTPS 理论基础及其在 Android 中的最佳实践》

  4. 点击“View ceertificate”按钮就可以查看该网站的证书了,如下所示:
    《HTTPS 理论基础及其在 Android 中的最佳实践》

    在“常规”这个面板中,我们从中可以查看该证书是又Symantec颁发给baidu.com这个网站的,有效期是从2015年9月17到2016年9月1日。

  5. 切换到“详细信息”面板,可以查看证书的一些详细信息,比如证书所使用的数字签名的算法,如下图所示:
    《HTTPS 理论基础及其在 Android 中的最佳实践》

    上面有个“复制到文件”的按钮,点击该按钮就可以将百度的数字证书导出成文件,从而我们就可以保存到自己的机器上,界面如下所示:

    《HTTPS 理论基础及其在 Android 中的最佳实践》

    《HTTPS 理论基础及其在 Android 中的最佳实践》

    我们将其导出成X.509格式的证书,以.cer作为文件扩展名,最后保存到本地机器如下所示:
    《HTTPS 理论基础及其在 Android 中的最佳实践》

  6. 切换到“证书路径”面板,可以查看证书的证书链。
    《HTTPS 理论基础及其在 Android 中的最佳实践》

    这里先解释一下什么是证书链。我们之前提到,VeriSign是一个全球知名的CA,但是一般情况下,CA不会用自己的私钥去直接签名某网站的数字证书,一般CA会首先签发一种证书,然后用这种证书再去签发百度等的数字证书。在此例中,VeriSign签发了Symantec证书,然后Symantec又签发了baiduc.om,VeriSign位于最顶端,类似于根结点,因此叫做根CA,Symatec位于中间,叫做中间CA,当然,有可能有多个中间CA,这样从根CA到中间CA,再到最终的网站的证书,这样自上而下形成了一条证书链。如果想要查看证书链中的某个证书,只需要选中它,比如选中了Symantec,然后点击下面的“查看证书”按钮就会弹出另一个对话框,在其中可以查看Symantec的数字证书,当然也可以将其导出成证书文件保存在硬盘上。

Android中访问HTTPS

在Android中我们也会经常发送HTTPS请求,这时需要使用HttpsURLConnection这个类,HttpsURLConnection是继承自HttpURLConnection的,其用法跟HttpURLConnection是一样的,比如我们想用HTTPS访问百度的首页,代码如下所示:

URL url = new URL("https://www.baidu.com");
HttpsURLConnection conn = (HttpsURLConnection)url.openConnection();
InputStream is = conn.getInputStream();
...

我们通过得到的InputStream就可以解析服务器的返回结果了。

如果对应服务器的数字证书存在问题,那么客户端就无法信任该证书,从而无法建立HTTPS链接,我们以国内的12306.cn网站为例进行说明。

12306.cn的用户登录是需要HTTPS的访问的,如果浏览器第一次打开页面kyfw.12306.cn/otn/regist/…,那么浏览器要么显示证书警告信息,要么索性直接不显示页面,因为12306.cn的数字证书存在问题。

其证书链如下所示:
《HTTPS 理论基础及其在 Android 中的最佳实践》

大家可以看到,该12306.cn的证书是由SRCA这个机构签发的,也就是说SRCA是证书链上的根CA。

但是SRCA是啥呢?没听过啊!

我们选中SRCA后,点击“查看证书”按钮,SRCA的证书如下所示:
《HTTPS 理论基础及其在 Android 中的最佳实践》

也就是说SRCA的全称是Sinorail Certification Authority, 在百度里面搜索该名称,可以查到一个叫做中铁信息工程集团的网站,www.sinorail.com/ProductInfo…,里面有这么一段描述:
《HTTPS 理论基础及其在 Android 中的最佳实践》

也就是说SRCA是铁道部给旗下的网站等做签名的一个所谓CA,但是它不具备公信力,因为它不是一个全球知名的CA,所以客户端根本不认可它。

我们在Android中直接访问kyfw.12306.cn/otn/regist/…时,会得到如下异常:
java.security.cert.CertPathValidatorException: Trust anchor for certification path not found.

这是因为Android的客户端内置的可信任CA列表中没有包含所谓的SRCA,这样就出现了12306.cn的证书不被客户端信任的异常。

为了解决客户端不信任服务器数字证书的问题,网络上大部分的解决方案都是让客户端不对证书做任何检查,这是一种有很大安全漏洞的办法,如下所示:
《HTTPS 理论基础及其在 Android 中的最佳实践》

首先定义一个自己的SSLTrustAllManager,其继承自X509TrustManager,具体来说,用SSLTrustAllManager创建一个SSLContext,然后用SSLContext生成一个SSLSocketFactory,然后通过调用HttpURLConnectoin的setSSLSocketFactory方法将其给某个具体的连接对象使用。由于SSLTrustAllManager没有对其中的三个核心方法进行具体实现,也就是不对证书做任何审查。这样无论服务器的证书如何,都能建立HTTPS连接,因为客户端直接忽略了验证服务器证书这一步。

这样虽然能建立HTTPS连接,但是存在很大的安全漏洞。因为黑客有可能拦截到我们的HTTPS请求,然后黑客用自己的假证书冒充12306.cn的数字证书,由于客户端不对证书做验证,这样客户端就会和黑客的服务器建立连接,这样会导致客户端把自己的12306的账号和密码发送给了黑客,所以客户端不对证书做任何验证的做法有很大的安全漏洞。

解决此问题的办法是让Android客户端信任12306的证书,而不是不对证书做任何检查。

我们通过上面提到的方法得到12306的证书12306.cer,将其放到assets目录下,也就是将12306.cer打包到我们的App中,然后用如下代码访问12306的HTTPS站点。

class DownloadThread extends Thread{
        @Override
        public void run() {
            HttpsURLConnection conn = null;
            InputStream is = null;
            try {
                URL url = new URL("https://kyfw.12306.cn/otn/regist/init");
                conn = (HttpsURLConnection)url.openConnection();

                
                CertificateFactory cf = CertificateFactory.getInstance("X.509");
                
                InputStream cerInputStream = getAssets().open("12306.cer");
                
                
                Certificate ca;
                try {
                    
                    ca = cf.generateCertificate(cerInputStream);
                    System.out.println("ca=" + ((X509Certificate) ca).getSubjectDN());
                } finally {
                    cerInputStream.close();
                }

                
                String keyStoreType = KeyStore.getDefaultType();
                KeyStore keyStore = KeyStore.getInstance(keyStoreType);
                keyStore.load(null, null);
                
                keyStore.setCertificateEntry("ca", ca);

                
                String tmfAlgorithm = TrustManagerFactory.getDefaultAlgorithm();
                TrustManagerFactory tmf = TrustManagerFactory.getInstance(tmfAlgorithm);
                
                tmf.init(keyStore);
                
                TrustManager[] trustManagers = tmf.getTrustManagers();

                
                SSLContext sslContext = SSLContext.getInstance("TLS");
                
                sslContext.init(null, trustManagers, null);

                
                SSLSocketFactory sslSocketFactory = sslContext.getSocketFactory();
                
                
                conn.setSSLSocketFactory(sslSocketFactory);

                is = conn.getInputStream();
                
                final String str = getStringByInputStream(is);
                handler.post(new Runnable() {
                    @Override
                    public void run() {
                        textView.setText(str);
                        btn.setEnabled(true);
                    }
                });
            }catch (Exception e){
                e.printStackTrace();
                final String errMessage = e.getMessage();
                handler.post(new Runnable() {
                    @Override
                    public void run() {
                        btn.setEnabled(true);
                        Toast.makeText(MainActivity.this, errMessage, Toast.LENGTH_LONG).show();
                    }
                });
            }finally {
                if(conn != null){
                    conn.disconnect();
                }
            }
        }

上面的注释写的比较详细,此处我们还是对以上代码进行一下说明。

  1. 首先从asserts目录中获取12306.cer证书的文件流,然后用CertificateFactory的generateCertificate方法将该文件流转化为一个证书对象Certificate,该证书就是12306网站的数字证书。

  2. 创建一个默认类型的KeyStore实例,keyStore用于存储着我们信赖的数字证书,将12306的数字证书放入keyStore中。

  3. 我们获取一个默认的TrustManagerFactory的实例,并用之前的keyStore初始化它,这样TrustManagerFactory的实例也会信任keyStore中12306.cer证书。通过TrustManagerFactory的getTrustManagers方法获取TrustManager数组,该数组中的TrustManager也会信任12306.cer证书。

  4. 创建一个TLS类型的SSLContext实例,并用之前的TrustManager数组初始化sslContext实例,这样该sslContext实例也会信任12306.cer证书。

  5. 通过sslContext获取SSLSocketFactory对象,将sslSocketFactory通过setSSLSocketFactory方法作用于HttpsURLConnection对象,这样conn对象就会信任keyStore中的12306.cer证书。这样一来,客户端就会信任12306的证书,从而正确建立HTTPS连接。

以上的处理过程是Google官方建议的流程,步骤流程总结如下:

证书文件流 InputStream -> Certificate -> KeyStore -> TrustManagerFactory -> TrustManager[] -> SSLContext -> SSLSocketFactory -> HttpsURLConnection

以上步骤的起点是获取证书文件的文件流,不一定非要从assets目录中获取,也可以通过其他途径得到,只要能拿到证书的文件流即可。

上面的过程是对的,但是还存在一点问题。我们将12306网站自身的12306.cer放到了assets目录中,然后让我们创建的HttpsURLConnection的实例信任了12306.cer。但是,数字证书都是有过期时间的,如果12306网站的数字证书到期了,那么12306会去SRCA那里重新生成一个数字证书,这时候12306网站的公钥和私钥都会更新,那这样就存在问题了。我们App的assets目录中存储的是老的12306.cer证书,这样12306网站重新生成了新的数字证书,那么老的数字证书就自动作废了,因为我们App中的12306.cer中的老的公钥无法解密12306网站最新的私钥了(公钥和私钥只能成对出现,旧的公钥只能解密旧的私钥)。

很不幸的是,网上大部分的解决方案就是直接信任12306.cer这种网站自身的数字证书,虽然这种办法暂时可以解决HTTPS问题,但是不是长久之计,会为以后的数字证书的更新埋下隐患。

那怎么解决这个问题呢?

最好的办法不是让我们的App直接信任12306.cer,而是让我们的App直接信任12306数字证书的签发者SRCA的数字证书。

我们用之前提到过的办法将12306的签发者SRCA的数字证书导出,取名SRCA.cer,将其放到assets目录下,我们只需要改一行代码里面的参数即可,我们将代码:

InputStream cerInputStream = getAssets().open("12306.cer");

修成该

InputStream cerInputStream = getAssets().open("SRCA.cer");

也就是我们读取的是SRCA.cer证书的文件流,而不再是12306.cer的。

通过让HttpsURLConnection实例信任SRCA.cer,也能够正常建立HTTPS连接,这是为什么呢?

我们之前提到了证书链的概念,假设存在如下证书链:
CA -> A -> B -> C

CA是根CA证书,例如SRCA.cer,C是最终的网站的数字证书,例如12306.cer,A和B都是中间证书,理论上来说,只要客户端信任了该数字证书链中的任何一个证书,那么C证书都会被信任。比如客户端信任了根证书CA,由于CA信任A,所以客户端也会信任A,由于A信任B,那么客户端也信任B,由于B信任C,那么客户端也信任C。所以在12306的例子中,只要信任了SRCA.cer,那么客户端就信任12306网站自身的12306.cer数字证书了。

Android客户端不信任服务器证书的原因主要是因为客户端不信任证书链中的根证书CA。12306网站的自身的数字证书可能会过几年就会重新生成,发生变动,但是SRCA作为其签发者,发生变动的次数会少的多,或者说是很长时间内不会改动,所以我们的App去信任SRCA.cer比直接去信任12306.cer要更稳定一些。

总结:

  1. HTTPS的传输过程涉及到了对称加密和非对称加密,对称加密加密的是实际的数据,非对称加密加密的是对称加密所需要的客户端的密钥。

  2. 为了确保客户端能够确认公钥就是想要访问的网站的公钥,引入了数字证书的概念,由于证书存在一级一级的签发过程,所以就出现了证书链,在证书链中的顶端的就是根CA。

  3. Android客户端不信任服务器证书的原因主要是因为客户端不信任证书链中的根证书CA,我们应该让我们的App去信任该根证书CA,而不是直接信任网站的自身的数字证书,因为网站的数字证书可能会发生变化。

希望本身对大家有所帮助!

参考:
Limboy的《图解HTTPS》
阮一峰的《数字签名是什么?》
Google官方的《Security with HTTPS and SSL》

相关阅读:
我的Android博文整理汇总
Android中HttpURLConnection使用详解

    原文作者:HTTP
    原文地址: https://juejin.im/entry/575ab9b36be3ff0069492bc6
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞