这就是编程:TLS/SSL和密钥协商的那些事

669 阅读22分钟

前言

“编程,就像一场开卷考试,题目的答案取决于对书本内容的熟悉程度;而一份源代码就好比一本书,正所谓读书百遍其义自见,读懂源码就读懂了编程!”

上周六,笔者临时有事,没来得及更新~😜

《这就是编程》本期一起来聊一聊关于网络安全的那些事。

首先,相信大家对HTTP及HTTPS都有所了解,但是你真的了解HTTPS的实现原理吗?假如你的认识只是停留在HTTPS=HTTP+TLS/SSL😄,而对TLS/SSL的实现原理不甚了解,那本期带你彻底了解HTTPS。

Let's start!

网络四层协议

首先来看一下网络四层协议,如下图:

超文本传输协议(HyperText Transfer Protocol,缩写HTTP)是一种用于分布式、协作式和超媒体信息系统的应用层协议。HTTP在传输层协议TCP之上,基于TCP实现面向连接、可靠的HTTP请求链接,但是它还不够安全。而HTTPS作为对HTTP的增强,弥补了HTTP安全方面的缺陷,在HTTP与TCP之间增加一层安全会话层TLS/SSL,它们对HTTP安全负责。

那TLS/SSL是如何使得HTTP变得安全的呢?不急,在介绍TLS/SSL实现原理之前,先来了解一下密码学的相关知识,它是实现TLS/SSL的基础。

密码学

密码是一种特殊的报文编码方式和一种与之对应的解码方式的结合体。加密之前的原始报文通常被称为明文(plaintext或cleartext);使用了密码之后的编码报文通常被称作密文(ciphertext)。

其中密码是算法,简单理解就是一些函数,输入明文,输出密文;而密钥是数字化参数。 密码作为算法是公开且被大众所熟知,因此加密的本质就是对密钥这个数字化参数的使用与保存。

主要的加密技术分为:

  • 对称密钥加密技术,顾名思义,加密密钥和解密密钥是相同的
  • 公开密钥加密技术,通常也称为非对称密钥加密技术,也就是加密密钥和解密密钥不相同

对称密钥加密技术

在对称密钥加密技术中,发送端和接收端要共享相同的密钥K才能进行通信。发送端用共享的密钥来加密报文,并将得到的密文发送给接收端;接收端收到密文,并对其应用解密函数和相同的共享密钥,恢复出原始的明文。

使用加密技术的情况下,就算中途数据被截取,只要截取者没有密钥就无法解读信息。但是一旦密钥被截获,就能对密文解密得到原始明文。由于对称密钥加密技术中,密钥是共享的,需要通过某种方式相互传递、保存,因此密钥的分配和管理就尤为重要。这也导致对称密钥加密技术存在一个明显的问题:

  • 发送者和接收者在相互通信之前,一定要有一个共享的保密密钥。意味着如果有N个节点, 每个节点都要和其他所有N-1个节点进行安全对话,总共大概会有N²个保密密钥,这将是一个管理噩梦。

但是对称密钥加密技术计算速度快,这个优点非常重要,如果一次加密计算需要很长时间,那势必会影响信息通信的效率。常见的对称密钥加密算法有DES、IDEA、AES、TDEA、RC2/RC4/RC5等等。

公开密钥加密技术

公开密钥加密技术是非对称的,即加解密所使用的密钥是不相同的。其中一个用来对报文编码;另一个用来对报文解码。

而其中编码密钥是众所周知的(这也是公开密钥加密这个名字的由来),但只有接收者才拥有其私有的解密密钥。

如下图:

公钥密码体制的产生主要是因为两个方面的原因:

  • 其一是由于常规对称密钥密码体制的密钥分配问题
  • 另外是对数字签名的需求

所有公钥加密系统所面临的共同挑战是,要确保即便有人拥有了下面所有的线索也无法计算出保密的私有密钥:

  • 公开密钥(是公有的,所有人都可以获得)

  • 一小片拦截下来的密文(可通过对网络的嗅探获取)

  • 一条报文及与之相关的密文(对任意一段文本运行加密器就可以得到) 

现有最著名的公钥密码体制是RSA体制,它是基于数论中大数分解问题的体制,由美国三位科学家Rivest、Shamir和Adleman于1976年提出并在1978年正式发表的公钥密码体制。

分别取了三位科学家名字的首字母

它的特点是:

  • 加密密钥(即公钥) PK是公开信息,而解密密钥(即私钥) SK是需要保密的
  • 加密算法E和解密算法D也都是公开的,且可以相互对调
  • 虽然私钥SK是由公钥PK决定的,但却不能根据PK计算出SK 

 从上图可以看到:

发送者A用B的公钥PK对明文X加密(E运算)后,在接收者B用自己的私钥SK解密(D运算),即可恢复出明文。

虽然公开密钥加密技术解决了对称密钥加密技术关于密钥分配的问题,但它计算速度可能会很慢。

数字签名

接下来聊一下数字签名(digital signing)技术, 它对后面将要讨论的安全证书系统来说非常重要。

刚刚提到,公开密钥加密技术产生的原因之一是对数字签名的需求。为什么数字签名需要公开密钥加密技术呢?接下来看看数字签名的实现原理。

一般情况下,在对报文进行加密之后,还需要用加密系统对报文进行签名(sign),以保证报文的真实性,同时证明报文的有效性,即未被篡改过。

整个签名过程如下图:

  • 发送者A用他的私钥SK对明文报文摘要P进行加密,得到加密后的摘要C,然后和原始报文一起发送给接收者B
  • 接收者B使用A的公钥PK对加密后的报文摘C要进行解密,如果得到的明文报文摘要和原始报文摘要相同,既可以证明该信息确实属于发送者A

从图中,可以发现数字签名实际上是附加在报文上的特殊加密校验码。

因为只有所有者才知道其私钥, 所以可以将作者的私钥当作一种“指纹”使用。

使用数字签名有以下两个好处:

  • 签名可以证明是作者编写了这条报文。只有作者才会有最机密的私有密钥,因此只有作者才能计算出这些校验和。校验和就像来自作者的个人“签名”一样。 
  • 签名可以防止报文被篡改。如果有恶意攻击者在报文传输过程中对其进行了修改,校验和就不再匹配了。由于校验和只有作者保密的私有密钥才能产生,所以攻击者无法为篡改了的报文伪造出正确的校验码。 

以上过程说明数字签名其实是一次公开密钥加密过程,所以为了数字签名,需要公开密钥加密技术,这就是公开密钥加密技术产生的原因之一。

虽然数字签名可以保证报文的真实性和有效性,但考虑下面这个场景:

大家有没有发现上图中的过程是不安全的,为什么说是不安全的呢?因为:

  • 如果A向B发送自已的公钥,M能够在传输过程中将其修改为自己的公钥
  • B可能察觉不到自已使用的是M的公钥,而认为这是A的公钥,这样就使得M能够轻易地扮演A的角色

更通俗的理解如下图所示:

假如你获取到的公钥并不是服务端给的,而是黑客给你的呢?而你却把这个假公钥当成真的。那么当你使用这个假公钥加密一些敏感信息时,黑客就可以截获你的这段信息,由于这信息是用黑客自己的公钥加密的,这样一来,黑客拿自己的私钥就能解密得到你的敏感信息了。

所以,与公钥加密系统相伴的一个重要挑战就是正确的决定某个公钥确实属于某一主体或身份。要解决这个问题,其实只要保证公钥是可信的,也就是说只有服务端发给你的公钥你才能拿,而坏人给你的公钥,你要懂得识别并丢弃它。

这个时候,数字证书就应运而生了。

数字证书

数字证书技术解决了数字签名技术中无法确定公钥是属于指定拥有者的问题。

简单理解,数字证书就是经过认证的公钥,这个证书证明了这个公钥就是A的,B拿到公钥之后不用怀疑是其他人伪造的。

当然,它里面还会包含被认证主体的相关信息。比如服务器证书里还会包含服务器对外提供服务的域名信息等。下面这张图展示了一个有效证书的所需信息:

事实上,任何人都可以创建一个数字证书,但并不是所有人都有权威的签发权,来给证书信息担保,并用其私钥签发证书。 

因此就需要有一个公共权威设施来保证证书的“权威性”,这就涉及到公钥基础设施PKI(Public Key Infrastructure),PKI负责提供创建、吊销、分发以及更新密钥对的服务。

通常情况下,PKI还要依赖一些中心化证书颁发机构CA(Certificate Authority)才能运行。证书颁发机构是用于管理与认证一些个体与它们的公钥间的绑定关系的实体。目前有数百家商业证书颁发机构。一个证书颁发机构通常采用层次的签名构架。这意味着一个公钥可能会被一个父密钥签名,而这个父密钥可能会被一个祖父密钥签名,依次类推。最终,一个证书颁发机构会拥有一个或多个根证书,许多下属的证书都会依赖根证书来建立信任。

一般来说,我们将自己生成的CSR提交给签名商,他们用中级证书机构的私钥Private Key给我们的签名成证书。而他们的的证书又是通过Root CA颁发的(即Root CA通过它的私钥对中级机构提交的CSR进行了签名)。

证书结构

接下来看下常用的证书结构,如下图:

证书的各字段解释:

  • 版本:即使用X.509的版本,目前普遍使用的是v3版本
  • 序列号:颁发者分配给证书的一个正整数,同一颁发者颁发的证书序列号各不相同,可用与颁发者名称一起作为证书唯一标识
  • 签名算法:颁发者颁发证书使用的签名算法
  • 颁发者:颁发该证书的设备名称,必须与颁发者证书中的主体名一致。通常为CA服务器的名称
  • 有效期:包含有效的起、止日期,不在有效期范围的证书为无效证书
  • 主体名:证书拥有者的名称,如果与颁发者相同则说明该证书是一个自签名证书
  • 公钥信息:用户对外公开的公钥以及公钥算法信息
  • 扩展信息:通常包含了证书的用法、CRL的发布地址等可选字段
  • 签名:颁发者用私钥对证书信息的签名

注意:

对于服务器证书,还会包含服务器相关域名等信息,通过域名信息也可以初步识别证书对应的服务器是否可信。比如下面这个天猫的服务器证书:

证书类型

  • 自签名证书:自签名证书又称为根证书,是自己颁发给自己的证书,即证书中的颁发者和主体名相同。
  • CA证书:CA自身的证书。如果PKI系统中没有多层级CA,CA证书就是自签名证书;如果有多层级CA,则会形成一个CA层次结构,最上层的CA是根CA,它拥有一个CA“自签名”的证书。
  • 本地证书:CA颁发给申请者的证书。
  • 设备本地证书:设备根据CA证书给自己颁发的证书,证书中的颁发者名称是CA服务器的名称。

申请证书

既然证书能保证公钥的权威性,那如何申请数字证书呢?大致过程如下图所示:

  1. 在本地生成私钥Private Key和CSR(Certificate Signing Request),CSR中包含了公钥和姓名,机构、地址等身份信息
  2. 使用该CSR向证书机构发起数字证书申请
  3. 证书机构验证申请者身份后,使用CSR中的信息生成数字证书,并使用自己的CA根证书对应的私钥对该证书签名

自签证书

从上面证书类型中可以看到“自签名证书”又称为根证书,是自己颁发给自己的证书。就好比把你自己当成一个“权威”的CA机构,给自己颁发一个“有效”的证书。下面简单的看一下如何签发一个自签证书。这时候需要用到openssl工具。

在计算机网络上,OpenSSL是一个开放源代码的软件库包,应用程序可以使用这个包来进行安全通信,避免窃听,同时确认另一端连线者的身份。这个包广泛被应用在互联网的网页服务器上。 其主要库是以C语言所写成,实现了基本的加密功能,实现了SSL与TLS协议。

-new    :说明生成证书请求文件
-x509   :说明生成自签名证书
-key    :指定已有的秘钥文件生成秘钥请求,只与生成证书请求选项-new配合。
-newkey :-newkey是与-key互斥的,-newkey是指在生成证书请求或者自签名证书的时候自动生成密钥,
         然后生成的密钥名称由-keyout参数指定。当指定newkey选项时,后面指定rsa:bits说明产生
         rsa密钥,位数由bits指定。 如果没有指定选项-key和-newkey,默认自动生成秘钥。
-out    :-out 指定生成的证书请求或者自签名证书名称
-days   :证书的有效期限;
-config :默认参数在ubuntu上为 /etc/ssl/openssl.cnf, 可以使用-config指定特殊路径的配置文件
-nodes  :如果指定-newkey自动生成秘钥,那么-nodes选项说明生成的秘钥不需要加密,即不需要输入passphase.  
-batch  :指定非交互模式,直接读取config文件配置参数,或者使用默认参数值

第一步,创建根证书

用openssl x509进行自签署时,使用“-req”选项明确表示输入文件为证书请求文件,否则将默认以为是证书文件,再使用“-signkey”提供自签署时使用的私钥。

// 生成私钥(key文件),这个私钥就是我们这个自签CA的私钥
openssl genrsa -out ca.key 2048
// 使用私钥ca.key生成签名请求(csr文件) CSR 即证书签名申请(Certificate Signing Request)
openssl req -new -key ca.key -out ca.csr
// 使用签名请求生成根证书(crt文件)
openssl x509 -req -days 365 -in ca.csr -signkey ca.key -out ca.crt

在这个过程中,OpenSSL会要求我们提供下列信息:国家、省份、城市、组织以及全域名(FQDN)。前面天猫的那个例子,实际上证书上的那些信息就是从这里来的。当我们有了这样一个自建的CA以后,我们就可以用这个自建的CA去签发证书,这就是自签名CA证书。

第二步:使用CA根证书签名服务器证书

// 生成私钥,这里的私钥是属于服务器的
openssl genrsa -out server.key 2048
// 生成证书请求文件
openssl req -new -key server.key -out server.csr
// 使用 CA 的根证书为服务器证书签名
openssl x509 -req -in server.csr -CA ca.pem -CAkey ca.key -CAcreateserial -out server.crt -days 365 -sha256

证书验证

证书验证实际上就是验证数字签名

现在有了数字证书了,要怎么验证它的权威性呢?看到这里你可能会发现一个问题:

在验证证书时我们需要用到证书机构的公钥,那我们如何拿到证书机构的公钥和身份信息,并保证证书机构自身身份的真实性呢?

上面刚刚说过自签名证书,首先权威证书机构会为自己颁发一个自签名证书,这称为证书机构的根证书,然后操作系统和浏览器会将这些根证书内置到发布的版本中,当验证用户证书时,操作系统和浏览器会认为内置的这些证书机构的证书是可信的,这样就解决了证书机构的信任问题。有时,我们可能想使用一些未被操作系统和浏览器缺省内置的证书机构,则可以把这些证书机构的根证书手动导入到操作系统或者浏览器中。比如前面通过openssl手动生成的自签证书导入key-chain。

CA签发证书的过程,如上图左边部分:

  • 首先CA会把持有者的公钥、用途、颁发者、有效时间等信息打成一个包,然后对这些信息进行Hash计算,得到一个Hash值;
  • 然后CA会使用自己的私钥将该Hash值加密,生成Certificate Signature,也就是CA对证书做了签名;
  • 最后将Certificate Signature添加在文件证书上,形成数字证书;

客户端校验服务端的数字证书的过程,如上图右边部分:

  • 首先客户端会使用同样的Hash算法获取该证书的Hash值H1;
  • 通常浏览器和操作系统中集成了CA的公钥信息,浏览器收到证书后可以使用CA的公钥解密 Certificate Signature内容,得到一个 Hash 值 H2;
  • 最后比较H1和H2,如果值相同,则为可信赖的证书,否则认为证书不可信。

OK,到目前为止,已经明白了数字签名以及数字证书的实现原理。接下来该讨论下TLS/SSL的实现原理了。

TLS/SSL握手

通过上面对密码学相关知识的了解,可以发现:

  • 对称密钥加密技术的优点是速度高,可加密内容较大,适合用来加密会话过程中的消息

  • 公开密钥加密技术的特点是加密速度较慢,但能提供更好的身份认证技术,适合用来加密对称加密的密钥

所以TLS/SSL综合了以上两种加密技术的优点,在实现过程中即用到了对称加密也用到了非对称加密(公钥加密),

  • 在建立传输链路时,首先对对称加密的密钥使用公钥加密进行非对称加密
  • 链路建立好之后,对传输内容使用上面得到的密钥进行对称加密

握手过程

结合上面流程图,整个握手过程可以分解为:

  1. 客户端向服务器发送Client Hello,告诉服务器支持的协议版本以及加密套件等信息

  2. 服务器收到响应,选择双方都支持的协议、套件,并向客户端发送Server Hello。同时服务器也将自己的证书(Certificate)发送到客户端

  3. 客户端自己生成预主密钥,通过服务器公钥加密预主密钥,将加密后的预主密钥发送给服务器 (Client Exchange)

  4. 服务器用自己的私钥解密加密的预主密钥

  5. 客户端与服务器用相同的算法根据客户端随机数、服务器随机数、预主秘钥生产主密钥

  6. 用主密钥对会话数据进行对称加解密

注意:

事实上,浏览器在对服务器端返回的证书进行校验时,主要关心下面这些信息:

  • 判断域名、有效期等信息是否正确
  • 判断证书是否被篡改,需要由CA服务器进行校验
  • 判断证书来源是否合法,每一份签发的证书都可以按照证书链找到对应的根证书,所以,可以通过操作系统中安装的根证书对证书的来源进行验证
  • 判断证书是否被吊销,需要由 CRL(Certificate Revocation List,即证书注销列表)和 OCSP(Online Certificate Status Protocol, 即在线证书状态协议)来实现

由此,可以发现整个HTTPS单向认证流程实际上结合了对称加密和非对称加密两种加密方式:

  • 非对称加密主要用于客户端、服务器双方的“试探”环节,即证书验证部分
  • 对称加密主要用于客户端、服务器双方的“正式会话”阶段,即数据传输部分

综合上面的分析,简单的说,TLS/SSL握手过程实际上是一个用公开密钥加密技术加密生成对称密钥的过程。

握手恢复

OK,现在握手成功,开始HTTPS信息传输。难道每一次信息传输时都要进行一次密钥协商吗?因为握手阶段使用了非对称加密(例如RSA算法),其计算消耗非常大。

为了解决不重复进行握手,握手机制中提供了会话恢复的功能,从而提高了握手中断后再次握手的性能。

有两种方法可以恢复会话:

  • Session ID
  • Session Tickets

先来讲讲Session ID机制。每次会话都有一个Session ID,如果握手中断,下次重连的时候,只要客户端给出Session ID,并且服务端有对应的记录,那么双方就可以使用之前的“会话密钥”,而不必重新计算生成。

具体过程如下:

  • 在一次会话中,服务器发送Session ID作为Server Hello消息的一部分
  • 客户端将此Session ID与服务端的IP、TCP端口相关联,以便下次重连时简化握手
  • 服务端则会将此Session ID与之前协商的密钥相关联,特别是“会话密钥(Master-Secret)”

但是Session ID机制有一些弊端:

  • 只能保留在一台服务器上。负载匀衡中,多台服务器之间往往没有同步Session信息,如果客户端的两次请求没有被同一台服务器处理,就无法通过Session ID恢复握手
  • 服务端不好控制Session ID对应信息的失效时间。时间太短起不到作用,太长又占用服务器大量资源

Session Tickets的出现就是为了消除Session ID机制的一些弊端。使用Session Tickets时,服务器将会话状态存储在其中,然后将Session Tickets加密后存储到客户端。客户端在恢复会话时,将Session Tickets发送给服务端,服务器验证通过后,就可以使用其中的会话状态来恢复握手。

使用例子

现在已经了解了TLS/SSL实现原理,接下来学以致用,通过webpack-dev-server搭建支持HTTPS的本地server。

借住mkcert工具来快速生成自签证书。使用homebrew安装mkcert

1. 生成本地CA根证书并在keychain中信任

mkcert --install

2. 在用户目录新建 /cert ,并生成本地证书

下方生成本地证书选用常用的域名,实际按自己情况增减

cd ~
mkidr cert

mkcert localhost 127.0.0.1 ::1

生成的文件

    .
├── localhost+10-key.pem
└── localhost+10.pem

便于复用,去掉 +10 重命名为 localhost(-key).pem

    .
├── localhost-key.pem
└── localhost.pem

cd 到工程根目录(有package.json),再将本地cert目录link到根目录

cd path/to/your/project

ln -s "$HOME/cert" ./cert

3. https webpack-devServer 配置

参考文档 webpack.js.org/configurati… 配置cert和key即可,注意区分 devServer v3 和 devServer v4(单独新增了 https 配置名,不再平铺到 devServer 下)。

{
  devServer: {
    https: true,
    key: fs.readFileSync(keyFilePath),
    cert: fs.readFileSync(certFilePath),
  }
}

对于devServer v5有关https配置,则使用webpack-dev-server v3编写自定义start.js来启动本地server,因为webpack5内置了有关webpack-dev-server的逻辑,对于像之前形式的https配置不再支持。

const fs = require('fs');
const path = require('path');
const webpack = require('webpack');
const WebpackDevServer = require('webpack-dev-server');
const isAbsoluteUrl = require('is-absolute-url');
const config = require('../configs/webpack.dev');
const devServer = {  
    compress: true,  
    hot: true,
    host: '0.0.0.0',     
    port: 3000,  
    disableHostCheck: true,  
    headers: { 'Access-Control-Allow-Origin': '*' },  
    https: {    
        key: fs.readFileSync(path.join(__dirname, '../cert/localhost-key.pem')),    
        cert: fs.readFileSync(path.join(__dirname, '../cert/localhost.pem')),  
    },
};

let server = null;
const serverData = {  
    server: null,
};

function startDevServer(config) {  
    let compiler;  
    try {    
        compiler = webpack(config);  
    } catch (err) {    
        if (err instanceof webpack.WebpackOptionsValidationError) {      
            console.error(err.message);      
            process.exit(1);    
        }        
        throw err;  
    }  

    const options = processOptions(config);  

    try {    
        server = new WebpackDevServer(compiler, options);    
        serverData.server = server;  
    } catch (err) {    
        if (err.name === 'ValidationError') {      
            console.error(err.message);      
            process.exit(1);    
        }    
        throw err;  
    }  

    server.listen(options.port, options.host, err => {    
        if (err) {      
            throw err;    
        }  
    });
}

function processOptions(config) {  
    const options = devServer;  
    options.publicPath = (config.output && config.output.publicPath) || '';  

    if (!isAbsoluteUrl(String(options.publicPath)) && options.publicPath[0] !== '/') {    
        options.publicPath = `/${options.publicPath}`;  
    }  

    return options;
}

startDevServer(config);

4. 启动项目 🚀

执行 npm run start启动项目

结尾

经过本期对TLS/SSL实现原理的分析,相信对HTTPS肯定有了更深的理解:

  • 非对称加密主要用于客户端、服务器双方的“试探”环节,即证书验证部分
  • 对称加密主要用于客户端、服务器双方的“正式会话”阶段,即数据传输部分

一言以蔽之,TLS/SSL握手是一个对称密钥协商过程,过程中使用公开密钥加密技术加密生成对称密钥;通过CA数字签名、CA数字证书等技术证明公钥的真实性、有效性。

OK,本期先分享到这里,如果读者有任何疑惑,欢迎评论区留言讨论👏🏻,笔者很乐意与大家分享源码阅读的心得,也是一次很好的自我学习机会🤔。

Bye bye,下期同一时间再见👋🏻。

参考资料