前置閱讀: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 不香麼?
這麼說來,咱們這三篇文章的研究不是白乾了?也沒有,至少有兩個收穫: