TLS与通信安全

Posted by Hsz on December 3, 2020

TLS与通信安全

互联网的通信安全是建立在SSL/TLS协议之上.

从明文通信到TLS加密通信

在TLS出现之前的蛮荒时代,数据都是明文传输的,明文传输的缺点当然就很明显–消息在每个经过的节点上都可以被读出窃取到.因此早期qq经常会有盗号的,那就是明文传输的锅.

明文通信

总结来说明文传输的风险点有3个:

  1. 窃听风险(eavesdropping):第三方可以获知通信内容

  2. 篡改风险(tampering):第三方可以修改通信内容

  3. 冒充风险(pretending):第三方可以冒充他人身份参与通信

那很容易的我们会想到给数据加密,如果是对称加密,那么只要在客户端获得密码,那就相当于没有加密了,因此肯定不行.一般来说安全起见都是使用的非对称加密的方式,即客户端公钥加密,服务端私钥解密.因此我们可以在每次建立连接时先请求服务端的公钥,拿到公钥后对负载数据加密,服务端收到负载后解密获得数据.

这个方法有两个缺陷

  1. 非对称加密慢,会影响通信效率
  2. 无法保证公钥可信

针对第一个缺陷,我们可以使用对称加密的方式,在获取到公钥后向服务器申请对称加密的密码,这个密码只在当次会话中有效,这样就不会影响通信效率了,而且即便是对称加密的密码被窃取了它也只会影响一次会话.

针对第二个缺陷,我们就只能借助第三方机构通过签发证书的方式保证公钥可信了.于是我们得到了如下的请求流程

TLS流程

这就是TLS的基本流程了,也就是

  1. 客户端向服务器端索要并验证公钥
  2. 双方协商生成”对话密钥”
  3. 双方采用”对话密钥”进行加密通信

当然上面的整个过程都是忽略细节的.细节可以写本叫HTTPS权威指南:在服务器和Web应用上部署SSL/TLS和PKI或者图解密码技术的书

TLS解决的安全问题

TLS主要解决的问题可以归结为3点:

  1. 第三方无法窃听,因为所有信息都是加密传播
  2. 双方可以立即发现通信是否被篡改,因为TLS具有校验机制
  3. 身份无法冒充,因为配备身份证书.

但是依然没有银弹,TLS只是可以解决大部分问题,而没办法解决所有问题,同时也会带来新的问题:

  1. TLS本身无法确定证书是否可信,如果你信任了不可信的证书,那风险就只有自己担了.
  2. TLS会降低通信效率,毕竟多了加密和多次请求.

因此使用需要权衡.但是在多数情况下是推荐使用的.

TLS目前已经广泛应用于HTTP协议,SMTP协议,GRPC协议等应用层协议之上,就如本文开头说的一样,成为了通信安全的基石.

证书

TLS协议有一步非常关键的是由可信第三方给客户端发送包含服务器公钥信息的证书.这一步直接决定了后面每一步的安全性.

那么怎么证明证书可靠呢?

答案就是人工看.

我们可以打开chrome,在设置->安全->管理证书位置找到你在各个https站点获得的证书.随便点一个进去看看

证书

一个证书至少会有签发人,签发对象,到期时间和公钥这4个信息.上面这个证书的详情页就可以查看到各种证书信息.我们看这些信息是否和访问的服务相符,有没有过期,签发人是否可信,就可以判断证书是否可信了.

根证书

我们在证书中可以看到一项叫根证书,它是什么呢?

根证书是证书签发认证中心给自己颁发的证书,是信任链的起始点.客户端需要先在本地有签发机构的根证书,然后当请求到服务证书后通过于根证书进行校验来确定服务证书可信.这里面的逻辑类似:

小A和小B不认是,但是小A信任他的老师C.小B拿着一封老师给他的介绍信来找小A,而且信的笔记一看就是老师写的,那小A就可以信任小B了.根证书就类似老师笔迹的角色.

我们只有信任签发机构才可能信任由签发机构签发的服务.因此客户端首先需要本地有信任的根证书.像浏览器中一般都会内置信任的根证书.

客户端证书

其实上面讲的都是客户端不信任服务器的情况,另一种情况是服务端也不信任客户端.这时候TLS就可以使用双向认证功能–给客户端也发个证书证明其可信.这里面的逻辑类似:

小A和小B不认是,但是小A小B都信任老师C.小B拿着一封老师C给他的介绍信来找小A,而且信的笔记一看就是老师写的,那小A就可以信任小B了.而小A也拿出一封老师C写的信,笔迹以看就是老师C写的,于是小B就可以信任小A了.

给客户端签发证书并校验的过程被称作双向认证.

双向认证

获得服务证书

如何获得这个证书以及其对应的公私钥对呢?

有如下几种方法

找你的域名服务商,在它的平台上购买域名对应的证书

可以使用这种方式的前提是你得先有一个域名,而且这个域名得是从域名服务商那边买来的.这种方式应该是最安全最省事的,就是可能会要钱.当然也有不要钱的.以阿里为例

阿里申请证书位置

进入后按下图配置

阿里免费证书

之后进入付款步骤一路付款即可(反正也是0元)

在购买好证书后我们需要做配置,进入SSL证书控制台后会我们可以看到有可以申请签发的证书,这个时候只需要点击进入,按提示的填入域名即可(域名需要是已经解析过的域名)

填写申请

全部填好后会需要等待验证.

等待验证

在验证完成后我们就可以在证书下载位置下载到证书和私钥了.

证书下载

下载下来的会包含两个文件

  • xxxxx.pem这就是证书本体
  • xxxxx.key这就是私钥

Github Page这样的静态网页服务只要验证过了就可以使用https协议替代http协议访问了.但如果是自己使用nginx等服务器部署的服务,尤其是接口服务那就要额外配置证书和私钥了.

类似的腾讯云也有对应服务,可以参考官方教程

使用letsencrypt签发免费证书

Let’s Encrypt本身是一个证书颁发机构.只是它是免费的

要使用这种方式,你需要:

  • 有域名,可以控制域名解析
  • 可以登录到域名指向的服务器
  • 服务器的80端口可用

由于在国内家庭宽带的80端口是被封了的,所以一般家庭用户就别想用这个方法了

签发步骤:

  1. 登录服务器,安装letsencrypt

     sudo apt-get install letsencrypt
    

    Let's Encrypt客户端名称可能是letsencryptcertbot. 可以用which命令确定,后文将统一使用letsencrypt指代Let's Encrypt客户端指令.

  2. 确保你的域名以A Record方式(不能是CNAME)指向了服务器.

  3. 服务器终端输入下面的指令获取证书

     sudo letsencrypt certonly --standalone
    

    之后就会要求填写邮箱,域名等信息.稍等片刻,客户端就会完成身份验证.证书与相关信息存放在/etc/letsencrypt文件夹下.

使用OpenSSL签发自签名证书

如果我们要使用OpenSSl签发自签名证书,那么就和上面的流程不一样了,上面都是机构为服务签发证书,而我们没有机构,因此我们需要先使用OpenSSl签发一个机构签名(根证书).然后再用这个根证书为服务签发签名.

由于我们自己成了签发机构,所以这也让双向认证成为可能.

创建根证书

  • 创建私钥

      openssl genrsa -aes256 -out ca-key.pem 4096
    

    然后根据要求输入一个4至1023位长度的字符串作为私钥密码(pass phrase)

  • 使用私钥创建根证书(1年)

      openssl req -new -x509 -days 365 -key ca-key.pem -sha256 -out ca.pem
    

    创建证书时会要输入刚才私钥的密码,以及证书的一些字段,包括国家,省,城市,组织名,邮箱等.

签发服务证书

  • 创建私钥

      openssl genrsa -out server-key.pem 4096
    

    和上面一样的创建方式

  • 构造服务证书中间请求文件

      openssl req -subj "/CN={HostName}" -sha256 -new -key server-key.pem -out server.csr
    

    注意HostName为服务器主机域名或者ip

  • 构造证书的额外信息

      echo subjectAltName = DNS:{HostName},IP:10.10.10.20,IP:127.0.0.1 >> extfile.cnf #扩展域名,这个例子就是说IP为10.10.10.20,127.0.0.1的也都可以用这个证书
    
      echo extendedKeyUsage = serverAuth >> extfile.cnf # 指定所有经过的VPN服务器都需要在其上签名
    

    这部分额外信息主要是些元信息和行为定义,可以查看https://superuser.com/questions/738612/openssl-ca-keyusage-extension或者去详情和范围

  • 签发服务证书

      openssl x509 -req -days 365 -sha256 -in server.csr -CA ca.pem -CAkey ca-key.pem \
          -CAcreateserial -out server-cert.pem -extfile extfile.cnf
    

签发客户证书(如果要双向认证)

  • 创建私钥

      openssl genrsa -out client-key.pem 4096
    
  • 构造客户证书中间请求文件

      openssl req -subj '/CN=client' -new -key client-key.pem -out client.csr
    
  • 构造证书的额外信息

      echo extendedKeyUsage = clientAuth > extfile.cnf # 指定所有经过的VPN客户端都需要在其上签名
    
  • 签发客户证书

      openssl x509 -req -days 365 -sha256 -in client1.csr -CA ca.pem -CAkey ca-key.pem \
          -CAcreateserial -out client-cert1.pem -extfile extfile.cnf
    
  • 构造包含私钥的个人信息交换文件(pfx令牌)

      openssl pkcs12 -export -in client-cert.pem -inkey client-key.pem -out client-cert.pfx
    

    构造过程中需要创建密码,毕竟这个带着私钥信息

扫尾工作

  • 删除证书请求文件:

      rm -v client.csr server.csr
    
  • 修改私钥权限

    默认的私钥权限太开放了,为了更加的安全我们需要更改证书的权限,删除写入权限,限制阅读权限:

      chmod -v 0400 ca-key.pem client-key.pem server-key.pem
    

    证书文件删除其写入权限:

      chmod -v 0444 ca.pem server-cert.pem client-cert.pem
    

吊销客户证书

我们可以向客户发证书,但如果因为一些原因客户失去了访问服务的资格,那我们该如何处理呢?

最简单的方法是发送短期证书,我们在签发证书时都可以设置参数-days 365.这个参数的意思是证书的有效期为365天,过期的证书自然就是不可信的.我们可以适当缩短证书的有效期,这样只要下次不发给客户它自然就失去了访问的权限.当然了这种方式治标不治本.

另一种方式是吊销客户证书.它的基本原理是维护一份被吊销的证书列表,如果请求的证书在这个列表中那么就无权访问了.

  • 吊销客户证书

      openssl ca -revoke client.pem -cert ca.pem -keyfile ca-key.pem
    
  • 生成吊销证书列表文件(crl)

      openssl ca -gencrl -out client.crl -cert ca.pem -keyfile ca-key.pem
    
  • 生成crl的pem格式文件(python只认这个)

      openssl crl -inform der -in client.crl  > client.crl.pem
    

自签证书的局限性和应用场景

由于自签证书是不被外界信任的,因此公网环境一般不会用自签证书.比如古早时期的12306就是自签证书,你要用它你还得下载它的根证书.但在一些对用户可信度有较高要求的场合反而应该使用自签证书,因为可以使用TLS双向认证的,通过给有资格访问的用户发根证书和客户端证书.

在内网环境自签证书就用处大了.它可以直接禁止不受信用的客户端访问服务器.这也是为什么像Docker Engine,grpc框架这样的内网服务也要支持TLS.

部署服务证书

TLS支持各种协议,它本身只负责通信安全,与业务是完全解耦的,这部分我们以最简单的https服务器为例介绍如何部署证书.

我们的例子在tls-example这个tag下.服务端代码使用python的sanic实现.客户端代码使用requests实现.这个项目下的:

  • ca文件夹保存的是根证书和对应的私钥
  • serverkey文件夹保存的是服务证书和对应私钥
  • clientkey文件夹保存的是客户证书和对应私钥

  • app.py为服务端实现

      import ssl
      import argparse
      from sanic import Sanic
      from sanic.request import Request
      from sanic.response import HTTPResponse, json
      from aredis import StrictRedis
    
    
      async def getfoo(request: Request) -> HTTPResponse:
          value = await request.app.redis.get('foo')
          return json({"result": value})
    
    
      async def ping(_: Request) -> HTTPResponse:
          print("inside")
          return json({"result": "pong"})
    
    
      async def setfoo(request: Request) -> HTTPResponse:
          value = request.args.get("value", "")
          await client.set('foo', value)
          return json({"result": "ok"})
    
      if __name__ == "__main__":
          parser = argparse.ArgumentParser(description='执行https服务')
          parser.add_argument('--redis_url', type=str, default="redis://host.docker.internal?db=0", help='是否双向认证')
          parser.add_argument('--authclient', action='store_true', default=False, help='是否双向认证')
          args = parser.parse_args()
          if args.authclient:
              # 双向校验
              print("双向校验")
              context = ssl.SSLContext(ssl.PROTOCOL_SSLv23)
              # 设置服务证书和私钥
              context.load_cert_chain("./serverkey/server-cert.pem", keyfile="./serverkey/server-key.pem")
              # 设置根证书(双向校验有必要控制吊销的证书,文件夹下ca.pem为根证书,ca.crl为吊销证书的列表)
              context.load_verify_locations('./ca/ca.pem')
              # 强制双向认证
              context.verify_mode = ssl.CERT_REQUIRED
              # 设置吊销列表
              # context.load_verify_locations('./ca/client.crl.pem')
              # 允许校验吊销的CRL列表
              # context.verify_flags = ssl.VERIFY_CRL_CHECK_LEAF
    
          else:
              # 单向校验
              #context = ssl.create_default_context(purpose=ssl.Purpose.SERVER_AUTH)
              context = ssl.create_default_context(purpose=ssl.Purpose.CLIENT_AUTH)
              # 设置服务令牌和私钥
              context.load_cert_chain("./serverkey/server-cert.pem", keyfile="./serverkey/server-key.pem")
          app = Sanic("hello_example")
          client = StrictRedis.from_url(args.redis_url, decode_responses=True)
          app.redis = client
          app.add_route(getfoo, '/foo', methods=['GET'])
          app.add_route(ping, '/ping', methods=['GET'])
          app.add_route(setfoo, '/set_foo', methods=['GET'])
          app.run(host="0.0.0.0", port=5000, ssl=context)
    
    

    服务端通过命令行flag--authclient来控制是单向校验还是双向校验

  • cli.py使用requests包请求服务端

      import argparse
      import requests as rq
    
      if __name__ == "__main__":
          parser = argparse.ArgumentParser(description='执行https服务')
          parser.add_argument('--authclient', action='store_true', default=False, help='是否双向认证')
          args = parser.parse_args()
          if args.authclient:
              res = rq.get('https://localhost:5000/ping', verify='./ca/ca.pem', cert=('./clientkey/client-cert.pem', './clientkey/client-key.pem'))
          else:
              res = rq.get('https://localhost:5000/ping', verify='./ca/ca.pem')
          print(res.status_code)
          print(res.json())
    
    

    同样通过命令行flag--authclient来控制是单向校验还是双向校验

上面的代码的双向验证模式可以完全模拟内网环境下带TLS的请求响应模式.即:

  • 服务端需要指定根证书,服务证书和客户密码
  • 客户端需要指定根证书,客户证书和客户密码

在浏览器中导入自签证书的根证书

无论是单向验证还是双向验证,客户端都必须先有可信的根证书,对于自签名证书来说,浏览器中一定不会有对应的根证书,因此我们要访问网页时就会出现如下不安全提示:

https不安全的链接

一些服务器允许我们使用这种不安全的链接访问,但是更好的办法是讲根证书导入到浏览器中设为可信.安装根证书最简单的方式就是

  1. ca.pem复制改名为ca.crt
  2. 双击ca.crt,根据提示将其放入受信任的根证书里
  3. 重启浏览器

这样又根证书派生出来的服务证书也就被认为可信了.

导入客户证书

在强制双向认证的情况下我们需要先导入客户端证书client-cert.pfx,这个步骤类似导入根证书,只是中途会要求输入这个证书的密码.以chrome为例一旦你的系统中导入好了这类客户证书,那么当你第一次请求一个强制双向认证的https网站时,浏览器会提示你选择要使用的客户端令牌.