从数字签名到数字证书, All I write is now ……

吖,终于放假了,睡了两天的充满电键者今天勤奋的爬起来码字了,马上春节,正文前先拜个早年,祝各位童鞋新年快乐,心想事成。

最近键者打算在项目中使用基于TLS的认证体系,来管理服务之间的相互认证问题,于是开始研究怎么实现并管理自签名的证书,于是牵扯到了键者常年黑箱的数字证书,所以……今天我们来聊聊数字证书吧☺️

作为常年的黑箱攻城狮,终究还是逃不过开箱的时候,也许就像薛定谔说的,不打开箱子,怎么知道箱子里的猫是活着还是死了呢……

虽然,可能打开了也不知道……

快照:数字签名

以前对接微信的开发者接口时,需要对请求的业务进行签名,微信会提供给键者一段字符串,键者每次起调微信的接口时,均需要将所有请求参数及参数值,按照参数名开头字母顺序排序,然后将这段字符串附加在排序后的请求之后,并用微信指定的哈希函数进行一次哈希计算,将哈希结果与其他请求参数一同发给微信的接口。

  1. // 充满真诚的伪代码
  2. req := "更新"
  3. user := "大悦天"
  4. secret := "HelloWorld"
  5. // 准备用于签名的字符串
  6. strToSign := fmt.Sprintf("req=%s&user=%s&secret=%s", req, user, secret)
  7. signedStr := Sha256(strToSign)
  8. // 发送给服务端的内容
  9. strToSend := fmt.Sprintf("req=%s&user=%s&sign=%s", req, user, signedStr)

基于哈希函数不可逆的特性,微信只要将收到的请求参数用相同的规则排序,并使用同一段字符串再做一次哈希,并将哈希结果与键者提供的进行比对,就可以确保请求是否真的是键者发出的。

前文用到的那段字符串,从职能上看,就是密钥;而根据这段请求密钥生成哈希结果的过程,就是签名,而这个哈希结果,自然就是数字签名

其实这里还巧妙地实现了在如何避免在信道上传输用户的密钥的情况下,验证用户的身份。

用户在参数中表述自己的身份(user),及请求的详细内容(req),再用自己的密钥对所有请求参数进行签名,将签名结果提交给服务端;服务端根据用户身份拿出自己存储的那份密钥,就可以计算并判定签名是否正确,然后再根据用户的权限设置,选择拒绝或者接受并执行相关的操作。

当然,工程上我们一般还会设置一个随机字串作为盐值,并加入当前的时间戳,以确保哪怕请求的内容一样,每次产生的数字签名结果都不一样,防止重放攻击。

简单小结一下:一般来说,数字签名的核心技术在于哈希函数的不可逆性及足够高的结果唯一性。

漫谈:数字证书

那么通过数字签名,可以确保通讯双方的安全么?很遗憾,并不能,显而易见的一个问题是,数字签名可以确保请求方发出的请求没有被中间人篡改,通过恰当的设计还能一定程度上避免回放攻击,然而前文中的每个参数,仍然是公开的,也就是说,它并不能防止内容被窃听。

理论上,通讯安全需要防止三重威胁:窃听风险、篡改风险、冒充风险。

那么这个时候,很自然而然地,就会想到对内容进行加密咯?

相信各位童鞋肯定都对计算机中对称加密不对称加密两种加密体系的特点有一定了解,键者在本文就不啰嗦了。

我们先来考虑对称加密的思路,服务端和客户端事前协商一把对称密钥,客户端对请求内容先进行一次加密,服务端收到请求后,先用对称密钥对请求进行一次解密,然后再校验数字签名……

假设服务端仅使用一把对称密钥,那么所有客户端都知道这把对称密钥,倒也能完成通讯的需求。可是密钥这东西,知道的人越多,泄漏的可能性就越大,对于管理来说也不是一个可靠的方案,一个客户端的管理不善会波及所有的客户端,这个风险成本太大了,并不是一个可靠的方案。

那如果给每个客户端都分配一把对称密钥呢?也会有问题,每个客户端发来的消息都是加密过的。也就是说,服务端无法直接从客户端发来的消息中得知,这个客户端是哪个用户,也就无法选择合适的对称密钥对内容进行解密,除非用所有客户端的对称密钥“轮询”一遍……

所以根据电视剧的常规套路,这里先出现的对称加密一般都是炮灰的节奏?

那么是时候引入传说中的不对称加密了,加入服务端自己生成好一对不对称密钥,然后将其中一把自己严加保管(即私钥),另一个把公开给所有希望与自己通讯的客户端(即公钥),则所有的客户端都可以通过公钥加密自己的请求,且该请求只有持有私钥的服务端才能解开,那么通讯的安全就算是有保证了……吗?

可惜后来就是一个先有鸡还是先有蛋的问题……

如果是完全私有的业务中,我们可以假定,客户端拿到的公钥总是可靠的,毕竟站在计算机的角度来讲,也许每个运维人员都是上帝,不信仰运维的服务器都会下火狱……

除非出Bug了,出Bug的时候服务器是大爷?

可惜,我们并没有合适的渠道能确保我们获得的公钥总是可靠的。同样,随着新服务的增加,我们也难以及时地获取最新的公钥

于是证书中心(CA)应劫而生,定位上,CA是客户端和服务器都信任的第三方,由CA对公钥提供担保。服务器只要持有CA签发的证书,就可以向客户端证明自己的身份。客户端只要能验证服务器持有的证书确实是CA签发的,就可以该服务端是可靠的。

现实中,公共业务的CA证书一般都是根据操作系统或者浏览器直接发布到客户端设备的,一般来说,我们可以认为这个渠道已经是比较可靠的了。

具体实现上,CA需要自己先生成一对密钥,保管好自己的私钥(ca.key),公开自己公钥(ca.pub)。

而客户端需要生成一份对自己身份的描述文件(cli.csr),并附带自己的公钥(cli.pub),其内容可能是这样的:

  1. 账户: '大悦天'
  2. 公钥: '大悦天的公钥'

通过CA认为可靠的渠道(例如,当面……)交给CACA再用ca.key对这份文件进行加密,并将加密后结果返回给客户端。此时,这份加密后的结果就是传说中的数字证书cli.crt)了。

这个身份描述文件的官方称呼是证书签名请求,缩写为CSR

那么这份数字证书怎么工作呢?首先,ca.pub是公开的,也就是说,任何人拿到这份cli.crt的时候,都可以通过ca.pub解密证书,获得身份描述文件。但是由于ca.key是被严加保管的,也就是说,除了当事人,没有人能伪造一份公钥不同的证书。

则客户端生成请求的时候,除了附带数字签名以外,同时应使用cli.key将请求内容加密,将加密后的内容与cli.crt一起发给服务器。

注意,这里的数字签名=Encrypt( HASH($req), $cli.key ),而不是前文的方式,但本质没有区别,只是不同的实现思路而已……

服务器收到了请求以后,首先通过ca.pub解密cli.crt,则从结果中可以拿到可靠的cli.pub,再通过cli.pub解密请求正文,就可以拿到请求的内容和数字签名了,根据数字签名可以验证请求的内容是否被篡改,并执行后续业务。

为什么说这里的cli.pub是可靠的?

这块涉及到的实体有点多,键者在这里简单小结一下:

  1. // 又是充满真诚的伪代码
  2. var ca.key, ca.pub
  3. var cli.key, cli.pub
  4. var cli.csr = `ID: 大悦天`
  5. var cli.crt = ($cli.csr + $cli.pub) + $ca.key // 使用私钥对csr和pub分别加密
  6. // 以下是客户端请求过程
  7. var cli.req = `
  8. ID: '大悦天'
  9. Action: '更新',
  10. `
  11. // tips: sha1已被证明不安全,工程上请选用sha256或以上级别的哈希算法。
  12. var cli.hash.req = HASH( $cli.req )
  13. var cli.encrypt.req = $cli.req + $cli.key // 加密
  14. var cli.encrypt.hash.req = $cli.hash.req + $cli.key // 加密
  15. cli.Send( $cli.encrypt.req, $cli.encrypt.hash.req, $cli.crt)
  16. // 以下是服务端的验证过程
  17. var cli.csr, cli.pub = $cli.crt - $ca.pub // 描述的是解密过程
  18. var cli.hash.req = $cli.encrypt.hash.req - $cli.pub // 解密
  19. var cli.req = $cli.encrypt.req - $cli.pub // 解密
  20. // 服务端根据req重新算一个哈希值
  21. var cli.srvHash.req = HASH( $cli.req )
  22. // 将csr中的身份与req中的身份进行比对
  23. if cli.csr.ID == cli.req.ID {
  24. // 名副其实
  25. }else{
  26. // 名不副实
  27. }
  28. // 将收到的哈希值与自己根据请求重算的哈希值进行比较
  29. if $cli.hash.req == $cli.srvHash.req {
  30. // 认证通过
  31. println("虽然认识你,但就是不更新=。=")
  32. }else{
  33. // 认证失败
  34. println("不认识你,不更新……")
  35. }

事实上,认证的需求是相对的,前文描述的流程是客户端向服务端证明自己身份的思路,而替换一下身份,就可以变成服务端向客户端证明自己身份的场景。后者在实际应用中,就是HTTPS业务中最常见的场景,服务端向客户端证明自己真的是服务端,而不是伪造的中间者。

同样,特定场景中还会有服务端客户端双方都需要认证对方身份的情况,其实也是键者实际业务中真正需要解决的问题,不过先在此按下不表。

抛砖之言

就键者个人的愚见,从工程的角度来说,无论是先有鸡,还是先有蛋,并不妨碍键者今晚吃鸡,啊不是,应该是后续状态的变化。就像我们不知道世界的起源,并不影响我们好好的活在当下。探究起源是有意义的,活在当下也是有意义的,两者并无分高下。

放在前文的场景,就是我们信任某个公钥也好,不信任某个公钥也好,我们终归总是要先信任一些东西。放在数学上,这叫公理。放在宣传中,这叫“我认为下面这些真理是不言而喻的……”。

  1. 而密码只要存在,就有被破解或者泄露的危险。
  2. ——鲁迅《我没说过》

所以在键者看来,引入CA的最大价值并不是解决了先有鸡还是先有蛋的问题,而是更好的解决的多对多认证的问题。

当然定义好初始规则仍然是很重要的,就像盒子不管打不打开,首先,要有猫……

虽然前文描述的都是一对一的场景,但实际上,一个CA可以签发任意数量的证书,一个客户端也可以生成任意数量的CSR,一个CSR甚至也可以被任意数量的CA进行签名,每次签名都会生成由该CA认可的证书。同样,一个服务端也可以信任不止一个CA,只要持有该CA的公钥即可。 一个CA还可以授权给子CA,形成信任链。

所以,数字证书的业务本质是仍然像许多其他引入三方的设计一样,将认证的工作委托给第三方完成,无论是服务端,还是客户端,都可以更专注的完成自己的业务,同时避免关注到对方的存在。特别是面向微服务的场景中,自建证书中心有非常大的应用价值,增加服务时候,不需要逐个向已有的服务添加新的服务的访问密钥,而是通过证书中心签发证书的方式,即可实现动态扩容。

其实还涉及到证书过期的或者撤销的问题,但键者今天的血槽已经空Orz……

附录:关键字

数字签名:Digital Signature

数字证书:Digital Certificate

证书中心:Certificate Authority,CA

签名请求:Certificate Signing Request,CSR