启用 HTTPS

最近准备尝试下基于 HTTP/2 的前端构建方案,虽然 HTTP/2 协议并不严格要求必须基于 HTTPS,不过目前所有的浏览器都只支持基于 HTTPS 的 HTTP/2,所以在构建 HTTP/2 前先得将站点升级至 HTTPS。

数据安全

为了达到安全的数据传输,对数据进行加密是必不可少的。加密的算法有很多,一般分为对称加密(Symmetric-key algorithms)与非对称加密两种(Asymmetric-key algorithms)两种。加密过程大致是算法借助于一个随机的密钥对数据进行加密与解密,加密算法本身通常并不保密,甚至是完全公开的,所以数据的安全性在于密钥的安全性。除了密钥的物理安全,密钥的长度是安全的关键。密钥越长,密文就越难被暴力破解,而相应的加密、解密计算也会更耗时。对称加密只依赖于一个唯一的密钥。而非对称加密使用一对公钥与私钥,公钥是公开的,用于加密数据,数据的解密则依赖于私钥。一般在相同的安全级别下,对称加密所需的密钥长度要小于非对称加密密钥,因此对称加密的速度与安全性在同等情况下会优于非对称加密。不过非对称加密除了用于数据的安全传输,还能用于身份的验证。由于公钥公开,私钥保密的特性,可以将数据的摘要(通过 Hash 计算得到)使用私钥加密后作为数据的签名,接收方在使用公钥解密签名后,使用同样的方式计算数据的摘要,将两摘要对比就能确定数据的签名方是否是公钥对应的私钥持有方,也就完成了身份的验证。

TLS

HTTPS 基于 TLS 提供安全的数据传输,TLS(Transport Layer Security) 使用对称加密传输数据,而对称密钥是通过数据传输之前的 TSL 握手阶段(TSL handshake)借助非对称加密协商确定的,在握手阶段除了完成对称密钥的协商以外,还会利用服务器的证书完成对服务器端的身份验证。TSL 握手有两种方式:RSA 与 DH(Diffie-Hellman)

RSA 握手

RSA 是一种经典的非对称加密算法,采用这种加密算法的握手过程大致如下:

第一步

客户端发送客户端随机数(Client random)及支持的加密方式到服务器端

第二步

服务器发送服务器端随机数(Server random)、服务器端证书及选定的加密方式

第三步

客服端生成一个附加参数(Pre-master parameter)并使用证书中的服务器端公钥进行加密后发送给服务器端

第四步

服务器端使用私钥解密得到附加参数,至此服务器与客户端都有完整的客户端随机数、服务器端随机数与附加参数,双方各自使用这些数据组合得到后续对称加密需要的密钥。

RSA 握手的优点在于第三步中同时完成了服务器端的认证与附加参数的交换,过程简单,速度较快。如果服务器没有证书中公钥对应的私钥,就不能获取到正确的附加参数,从而无法得到正确的对称加密密钥。RSA 握手的缺点在于过度依赖服务器端的私钥,如果攻击者记录了客户端与服务器端间的所有通信数据,日后又通过某些手段获取到了服务器端的私钥,那么就能解密之前记录的所有数据,从而获取到一些可能仍然有价值的数据(比如不长更改的密码神马的)。这种情况称为向前的安全性(Forward Secrecy),RSA 握手方式对此无能为力,而 DH 握手可以很好的解决这个问题。

DH 握手

DH(Diffie–Hellman) 是一种密钥的交换方式,整个握手过程大致如下:

第一步

客户端发送客户端随机数(Client random)及支持的加密方式到服务器端

第二步

服务器发送服务器端随机数(Server random)、服务器端证书及选定的加密方式,同时还有服务器端的 DH 参数,以及所有这些信息(不包含证书)的签名。

第三步

客户端通过证书中的公钥对签名进行验证,同时生成客户端的 DH 参数并发送给服务器端

第四步

至此客户端与服务器端都有了客户端随机数,服务器端随机数,客户端 DH 参数与服务器端 DH 参数,双方各自使用这些数据生成后续的对称密钥

DH 握手将服务器认证与 key(也就是 DH 参数)交换分开成了两步进行。服务器证书认证依旧基于非对称加密,通过签名验证进行(可选的算法有 RSA 或者 DSA)。DH 参数是在握手的过程中实时产生的,而且在传输前进行了不可逆的计算,所以即使第三方截取到了传输过程中的 DH 参数也无法生成最后的对称密钥,很好的解决了向前的安全性。DH 握手的缺点在于兼容性不及 RSA 握手方式(Windows XP SP3 以前的版本并不支持)。

证书验证

在握手阶段可以确定证书中描述的站点就是当前访问的站点,避免了钓鱼攻击,但是并不能确定证书本身的合法性。为了保证证书的合法性,证书中包含有签发机构(证书认证机构,Certificate Authority,简称CA)的签名,通过该签名可以确定证书一定是签发于一个确定的机构,如果该机构被认为是可信的,那么该机构签发的证书也就被认为是有效的。操作系统中内置了很多可信的证书签发机构(被称为Root CA,最高等级的 CA)的信息,客户端利用这些数据就可以在本地完成证书合法性的验证。不过通常情况下个人用户申请的证书一般都不会是由 Root CA 直接签发的,而是由下游 CA(相当于 Root CA 的代理商)签发的。所以在握手阶段传输给用户的证书信息中,除了包含站点本身的证书外,还需要包含非 Root CA 的证书(称为中间证书),使得客户端能从站点证书开始层级向上直至查询到存在于本地的 Root CA,从而确定整个证书链的合法性。

除此之外还有一种极端情况:证书本身是合法的,但基于某种原因签发机构需要强制取消该证书的有效性。针对这种情况,客户端可以通过 OCSP(Online Certificate Status Protocol) 协议向证书签发机构发送请求实时验证证书的有效性。

TLS 与 SSL

SSL(Secure Sockets Layer),也是用于安全数据传输的协议,两者有些时候会同时出现,比如写做 “TSL/SSL”。 SSL 是由网景公司(Netscape)制定的协议,最后一个版本为 3.0。TSL 是 SSL 的后续继承者(TSL 1.0 相当于 SSL 3.1),提供了新的特性并修复了一些安全问题,最新稳定版本是 1.2。业界没有继续开发 SSL,转而
推出 TLS 更多的是为了让协议本身不受限于某个具体公司,保证其独立与开源的本质,目前 TSL 由 IETF 维护,发布为 RCF

Nginx 启用 HTTPS

申请证书

本站选择由 Let’s Encrypt 提供的免费证书,虽然过期时间较短(90天),但可以借助工具自动化申请与更新证书,整体流程简单。Let’s Encrypt 推荐的自动化工具是 Certbot,其官网上有分系统环境的详细使用文档,在这里主要说一下 Ubuntu 16.04 系统的使用方式:

安装 Cerbot

Cerbot 有针对 Ubuntu 16.04 的发行版本,直接通过 apt-get 安装就好:

1
$ apt-get install letsencrypt

申请证书

使用如下命令向 Let’s Encrypt 申请证书

1
$ letsencrypt certonly --webroot -w /var/www -d example.com -d www.example.com

--webroot 表示使用 webroot 插件,此插件借助已有的 Web 服务器来申请证书,为了证明申请方对域名有完全的控制能力,申请过程中会使用申请域名下的一个特殊路径 /.well-known/ 来存放临时的验证信息,如果 Let’s Encrypt 的服务器能获取并验证通过这些临时信息就能说明申请方对该域名有完全的控制能力。因此使用 webroot 插件除了需要 -d 指定需要申请的域名外,还需要通过 -w 指定域名对应的网站根目录,用于存放临时的验证文件(Cerbot 会自动在该目录下建立 .well-known 目录)。一个证书可以包含多个域名,多个域名可对应一个网站根路径,同时也可以指定多个网站根路径:

1
$ letsencrypt certonly --webroot -w /var/www/home -d example.com -d www.example.com -w /var/www/live -d live.example.com

域名 example.comwww.example.com 对应的根目录为 /var/www/home,而域名 live.example.com 对应的根目录是 /var/www/live

除了使用现有的 Web 服务器,也可以使用 standalone 插件自带的 Web 服务器来完成验证,具体可以参考其说明。

开始申请后,Let’s Encrypt 会要求提供 email,主要为了做证书过期通知,一切顺利的话不到10秒就能完成证书的申请,最后会给出证书的存放路径,其目录下会包含四个文件:

自动更新证书

配置自动更新前先测试下相关配置是否正确

1
$ letsencrypt renew --dry-run --agree-tos

如果有 Warning 提示 email 没有配置的话可以忽略,如果有错误就得具体瞅瞅了,最常遇到的问题估计是 /.well-known/ 路径的访问问题。检查 OK 后就可以配置系统的定时任务自动更新证书,最简单的方式是使用 crontab 添加定时任务:

1
2
3
4
# 建立一个定时器任务文件夹,存放定时任务与其日志
$ mkdir timer
# 创建定时任务 renewcerts 及其日志文件
$ touch timer/renewcerts timer/renewcerts.log

crontab 使用六列数据(空格分割)来定义一个定时器任务:

前五列中,可以使用 * 表示忽略该条件

本站使用的 renewcerts 内容如下:

1
2
# Renew certificates
0 0 25 2,4,6,8,10,12 * echo "[`date '+%Y-%m-%d %H:%M:%S'`]" >> $HOME/timer/renewcerts.log && letsencrypt renew >> $HOME/timer/renewcerts.log 2>&1

任务执行的时间为2月、4月、6月、8月、10月及12月的25日凌晨零点,具体间隔时间根据首次证书的申请日期来计算就好。执行的命令首先是在日志文件中输出执行时间,然后调用 letsencrypt renew 更新证书,并且将该命令的输出重定向到日志文件做保存。(2>&1 是将错误输出也定向到日志文件,其中 1 表示标准输出,2 表示标准错误输出,由于之前已经将该命令的标准输出重定向到了日志文件,所以此时 1 就是指日志文件)

最后使用如下命令,启用该定时器任务

1
2
3
4
5
# 添加定时任务
$ crontab timer/renewcerts
# 列出目前所有的定时任务
# 检查下任务是否已经添加成功
$ crontab -l

配置 Nginx

可以参考 Mozilla SSL Configuration Generator 给出的配置建议,其中最重要的是指定证书,ssl_certificate 用于配置带有完整证书链的网站证书,也就是之前申请的 fullchain.pem,而 ssl_certificate_key 用于指定网站的私钥。ssl_dhparam 是 DH 密钥交换时使用的,通过 openssl dhparam -out dhparams.pem 2048 生成。ssl_ciphers 用于确定服务器使用的加密套件及其先后顺序。

ssl_session 是 session 复用的相关设置,建立 TLS 链接比较耗时,如果每次链接都重新握手必定会消耗大量的时间,因此可以考虑在完成一次正常的握手后保存相关的会话信息(也就是正文的对称加密方式等),下一次建立请求时复用之前的会话信息,就可以避免频繁的握手操作。目前有两种 Session 复用的方式,Session IdentifierSession TicketSession Identifier 是指客户端与服务器端各自保存会话对应的信息,在后续建立请求时客户端发送想要复用的 Session ID,如果服务器可以获取到 Session ID 对应的数据,就可以略过正常的握手,直接复用之前的会话信息。Session Identifier 不好的地方在于多服务器间的 Session 信息同步,而 Session Ticket 可以搞定这个问题,解决方案就是服务器端完全不存储会话信息,全有客户端提供,当然数据的传输是要加密的,而这个密钥是服务器端独有的,只要在多台服务器间共享这个密钥,就可以完成在多台服务器间的 Session 复用。

ssl_stapling 是另一个帮助快速建立 TLS 链接的配置。之前提到过为了验证网站证书的有效性,客户端需要进行额外的 OCSP 请求来确认,Nginx 开启了 ssl_stapling 后,会代客户端发起这个 OCSP 验证请求并将结果缓存,在 TLS 握手阶段缓存的 OCSP 随证书一同发送给客户端,这样就能免去客户端额外的一次 OCSP 查询。另外 OCSP 的结果是包含有 CA 签名的,不可能伪造,所以服务器缓存的 OCSP 结果也是可信的。另外如果开启了 ssl_stapling 记得使用 resolver 指定服务器进行 OCSP 查询时使用的 DNS 服务器。

如果包含多个子站点的话,可以考虑将 ssl 相关的配置放到 /etc/nginx/nginx.conf 全局配置文件中,后续各站点就只需要启用 https 就好(前提当然是证书包含了所有的子站点,但是过多的站点在同一个证书中会增大证书的体积,所以在必要的时候也需要考虑拆分证书)。

开启了 443 端口提供 HTTPS 服务后,还需要改造原有的 80 端口服务,将所有的 HTTP 访问都重定向到 HTTPS,比如这样:

1
2
3
location / {
rewrite ^/(.*)$ https://$http_host/$1 permanent;
}

在进行 HTTP 重定向时需要注意保留之前用于证书申请的验证路径,也就是那个 ./well-known 目录,后续自动化更新证书还需要用到这个路径,所以不要把这个路径也重定向了,让它继续工作在 80 端口就好。

另外 Mozilla 的这个建议配置默认开启了 HSTSHTTP Strict Transport Security),用于在 HTTPS 的响应头中添加一个 Strict-Transport-Security 头信息,用于告诉客户端在此后的一段时间内访问此域名(或者旗下的子域名)都必须强制使用 HTTPS 链接,它能防止用户在 HTTP 重定向前被劫持。不过在站点 HTTPS 服务稳定前,不建议开启此设置,因为一旦此设置生效,网站就不能再切换回 HTTP 服务了,除非等生效时间过期或者更换域名… 可以考虑在 HTTPS 服务稳定、证书更新正常后再开启该设置。

参考链接

特别推荐 Jerry Qu系列文章,包含了很多关于 HTTPS/HTTP2 部署、实战的问题。