第三节:HTTPS数字证书和数字证书链

为了保证信息在传输中的安全和双方的身份不被冒充,HTTPS在建立安全链接阶段使用了公钥、私钥两把“钥匙”——非对称加密。非对称意思是:公钥加密的内容,只有私钥才能解密。私钥加密的内容,只有公钥才能解密。公钥是公开的,私钥保存在服务器端。

由上图可看出只有拥有了“私钥”的服务器才能解密出“公钥”加密的对话内容。如果客户端获取到的公钥确实是由真正服务器生成的,那么就能确保了服务器的身份不是伪造的。现在问题来了,因为公私钥的生成算法是开源的,每个服务器都能提供并生成自己的一对公私钥,客户端如何确认拿到的公钥不是伪造的。

这里引出了另外一个安全机制,就是数字证书链。证书链的核心是证书中心(certificate authority,简称CA),合法CA的公钥是预存在操作系统和浏览器里的,只有通过了CA认证的服务器公钥才被浏览器客户端认为是可信的公钥。认证的原理很简单,依然是公私钥原理。CA拿自己的私钥去给需要认证的服务器公钥签名,生成一个“数字证书”。数字证书是包含了CA的签名,服务器自身公钥等等信息的集合体。浏览器拿着CA的公钥去验证该签名。只有被CA公钥验证通过的证书才是可信任的证书。有了这个逻辑,整个安全证书链信任系统就构成了。

客户端验证服务器证书
第三节:HTTPS数字证书和数字证书链 - 图1

回到最初的思路分析:建立一个可以同时与客户端和服务端进行通信的网络服务。

现在需要解决的是如何得到客户端的信任,才能建立与客户端的通信。经过上面的分析,突破口就是CA证书。只要自定义的CA证书得到了客户端的信任,我就能用CA证书签发各种“伪造”的服务器证书。简单说就是让客户端系统安装上我们自定义的CA证书。

由于生成证书的方法是开源的,这里用到的是一个Node.js的库。但需要注意的是,使用什么样的方式生成CA根证书并不影响我们最终实现一个HTTPS中间人代理,如果你对openssl生成证书的方式比较熟悉,用openssl完成这一步也是可行的。

生成CA证书代码核心部分:

完整源码:../code/chapter3/createRootCA.js

npm script运行方式

⚠️注意:

第一步:

首先双击打开证书文件rootCA/rootCA.crt

第二步:

第三步:

第三节:HTTPS数字证书和数字证书链 - 图2

第四步:

检查证书安装

命令行输入certmgr.msc,如下图可以看到新安装的证书

第三节:HTTPS数字证书和数字证书链 - 图3

Mac

项目根路径下执行下面命令

输入用户密码后即可安装成功。

检查证书安装

输入命令open /Library/Keychains/System.keychain 可查看安装情况如下图

生成一个伪造的github的证书

  1. const forge = require('node-forge');
  2. const pki = forge.pki;
  3. const fs = require('fs');
  4. const path = require('path');
  5. const mkdirp = require('mkdirp');
  6. // CNanme
  7. var domain = 'github.com';
  8. var caCertPem = fs.readFileSync(path.join(__dirname, '../../rootCA/rootCA.crt'));
  9. var caKeyPem = fs.readFileSync(path.join(__dirname, '../../rootCA/rootCA.key.pem'));
  10. var caCert = forge.pki.certificateFromPem(caCertPem);
  11. var caKey = forge.pki.privateKeyFromPem(caKeyPem);
  12. var keys = pki.rsa.generateKeyPair(1024);
  13. var cert = pki.createCertificate();
  14. cert.publicKey = keys.publicKey;
  15. cert.serialNumber = (new Date()).getTime() + '';
  16. cert.validity.notBefore = new Date();
  17. cert.validity.notBefore.setFullYear(cert.validity.notBefore.getFullYear() - 1);
  18. cert.validity.notAfter = new Date();
  19. cert.validity.notAfter.setFullYear(cert.validity.notAfter.getFullYear() + 1);
  20. name: 'commonName',
  21. value: domain
  22. }, {
  23. name: 'countryName',
  24. value: 'CN'
  25. }, {
  26. shortName: 'ST',
  27. value: 'GuangDong'
  28. }, {
  29. name: 'localityName',
  30. value: 'ShengZhen'
  31. }, {
  32. name: 'organizationName',
  33. value: 'https-mitm-proxy-handbook'
  34. }, {
  35. shortName: 'OU',
  36. value: 'https://github.com/wuchangming/https-mitm-proxy-handbook'
  37. }];
  38. cert.setIssuer(caCert.subject.attributes);
  39. cert.setSubject(attrs);
  40. cert.setExtensions([{
  41. name: 'basicConstraints',
  42. critical: true,
  43. }, {
  44. name: 'keyUsage',
  45. critical: true,
  46. digitalSignature: true,
  47. keyEncipherment: true,
  48. dataEncipherment: true,
  49. keyAgreement: true,
  50. keyCertSign: true,
  51. cRLSign: true,
  52. encipherOnly: true,
  53. decipherOnly: true
  54. }, {
  55. name: 'subjectKeyIdentifier'
  56. }, {
  57. name: 'extKeyUsage',
  58. serverAuth: true,
  59. clientAuth: true,
  60. codeSigning: true,
  61. emailProtection: true,
  62. timeStamping: true
  63. }, {
  64. name: 'authorityKeyIdentifier'
  65. }]);
  66. cert.sign(caKey, forge.md.sha256.create());
  67. var certPem = pki.certificateToPem(cert);
  68. var keyPem = pki.privateKeyToPem(keys.privateKey);
  69. console.log(certPem);
  70. console.log(keyPem);

npm script运行方式

执行完npm run createCertByRootCA后,CA根证书的公私钥会生成到项目根路径的cert文件夹下:

通过证书链的原理可以了解,获取CA认证的方法就是用CA证书的私钥给需要认证的子证书签名。上面的代码即是根据这个原理伪造了一个github.com域名的子证书。

证书中的Common Name字段表明了该证书对应的域名
第三节:HTTPS数字证书和数字证书链 - 图4

如果需要代表多个域名时需要用到扩展字段Subject Alternative Name

另外和CA根证书最大的不同是,该子证书是用CA根证书的私钥签名,而CA根证书是用自己的私钥自签名。这也从代码的角度认识到了证书链的原理

  1. // 用CA根证书私钥签名