在傳輸或存儲用戶數據(尤爲是私人對話)時,必須考慮採用加密技術來確保隱私。javascript
經過閱讀本教程,您將瞭解如何僅使用JavaScript和Web Crypto API(一種本地瀏覽器API)在Web應用程序中對數據進行端到端加密。html
請注意,本教程很是基礎,而且具備嚴格的教育意義,可能包含一些簡化,不建議使用您本身的加密協議,若是沒有在安全專家的幫助下正確使用,所使用的算法可能包含某些「陷阱」java
若是您碰巧迷路了,也能夠在此GitHub倉庫中找到完整的項目。git
端到端加密是一種通訊系統,其中惟一可以讀取消息的人就是進行通訊的人。沒有任何竊聽者能夠訪問解密對話所需的加密密鑰,甚至是運行消息傳遞服務的公司也沒法訪問。github
Web Cryptography API定義了一個低級接口,用於與用戶代理管理或暴露的加密密鑰材料進行交互。API自己對密鑰存儲的底層實現是不可知的,但提供了一組通用的接口,容許富Web應用執行諸如簽名生成和驗證、散列和驗證、加密和解密等操做,而不須要訪問原始密鑰材料。web
在如下步驟中,咱們將聲明端到端加密所涉及的基本功能。您能夠將每一個文件複製到 lib
文件夾下的專用 .js
文件中。請注意,因爲Web Crypto API的異步特性,它們都是異步函數。算法
注意:並非全部的瀏覽器都能實現咱們將使用的算法。說的就是IE和舊版Microsoft Edge。請查看 MDN網頁文檔中的兼容性表:Subtle Crypto - Web APIs。
加密密鑰對對於端到端加密相當重要。密鑰對由公共密鑰和私有密鑰組成。應用程序中的每一個用戶都應具備一個密鑰對來保護其數據,其餘用戶可使用公共組件,而密鑰對的全部者只能訪問私有組件。您將在下一部分中瞭解這些功能的做用。後端
要生成密鑰對,咱們將使用 window.crypto.subtle.generateKey
方法,並使用具備 JWK格式的 window.crypto.subtle.exportKey
導出私鑰和公鑰。能夠將其視爲序列化密鑰以在JavaScript以外使用的一種方法。api
generateKeyPair.js數組
export default async () => { const keyPair = await window.crypto.subtle.generateKey( { name: "ECDH", namedCurve: "P-256", }, true, ["deriveKey", "deriveBits"] ); const publicKeyJwk = await window.crypto.subtle.exportKey( "jwk", keyPair.publicKey ); const privateKeyJwk = await window.crypto.subtle.exportKey( "jwk", keyPair.privateKey ); return { publicKeyJwk, privateKeyJwk }; };
此外,我選擇了具備P-256橢圓曲線的ECDH算法,由於它獲得了很好的支持,而且在安全性和性能之間達到了適當的平衡。隨着新算法的推出,這種偏好會隨着時間而改變。
注意:導出私鑰可能會致使安全問題,所以必須謹慎處理。本教程集成部分將介紹的讓用戶複製粘貼的作法,並非一個很好的作法,只是出於教育目的。
咱們將使用在最後一步中生成的密鑰對來派生對稱加密密鑰,該密鑰對數據進行加密和解密,而且對於任何兩個通訊用戶都是惟一的。例如,用戶A使用他們的私鑰和用戶B的公鑰派生密鑰,用戶B使用他們的私鑰和用戶A的公鑰派生相同的密鑰。沒有人能夠在不訪問至少一個用戶私鑰的狀況下生成派生密匙,所以保證它們的安全很是重要。
在上一步中,咱們以JWK格式導出了密鑰對。在推導出密鑰以前,咱們須要使用 window.crypto.subtle.importKey
將這些導入到原始狀態。爲了導出密鑰,咱們將使用 window.crypto.subtle.deriveKey
。
deriveKey.js
export default async (publicKeyJwk, privateKeyJwk) => { const publicKey = await window.crypto.subtle.importKey( "jwk", publicKeyJwk, { name: "ECDH", namedCurve: "P-256", }, true, [] ); const privateKey = await window.crypto.subtle.importKey( "jwk", privateKeyJwk, { name: "ECDH", namedCurve: "P-256", }, true, ["deriveKey", "deriveBits"] ); return await window.crypto.subtle.deriveKey( { name: "ECDH", public: publicKey }, privateKey, { name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"] ); };
在這種狀況下,我選擇AES-GCM算法是由於它具備已知的安全性/性能平衡和瀏覽器可用性。
如今,咱們可使用派生密鑰對文本進行加密,所以能夠安全地傳輸文本。
在加密以前,咱們將文本編碼爲 Uint8Array
,由於這就是加密功能所須要的。咱們使用 window.crypto.subtle.encrypt
對該數組進行加密,而後將其 ArrayBuffer
輸出返回給 Uint8Array
,而後將其轉換爲字符串並將其編碼爲Base64。JavaScript使它有點複雜,但這只是將咱們的加密數據轉換爲可傳輸文本的一種方式。
encrypt.js
export default async (messageJSON, derivedKey) => { try { const message = JSON.parse(messageJSON); const text = message.base64Data; const initializationVector = new Uint8Array(message.initializationVector).buffer; const string = atob(text); const uintArray = new Uint8Array( [...string].map((char) => char.charCodeAt(0)) ); const algorithm = { name: "AES-GCM", iv: initializationVector, }; const decryptedData = await window.crypto.subtle.decrypt( algorithm, derivedKey, uintArray ); return new TextDecoder().decode(decryptedData); } catch (e) { return `error decrypting message: ${e}`; } };
如您所見,AES-GCM算法參數包括一個初始化向量(iv)。對於每個加密操做,能夠是隨機的,但絕對必須是惟一的,以保證加密的強度。它包含在信息中,因此它能夠用於解密過程,這是下一步。另外,雖然不太可能達到這個數字,但你應該在2³²次使用後丟棄鑰匙,由於此時隨機IV會重複。
如今咱們可使用派生密鑰來解密咱們收到的任何加密文本,作的事情與加密步驟正好相反。
在解密以前,咱們檢索初始化向量,將字符串從Base64轉換回來,變成一個 Uint8Array
,並使用相同的算法定義進行解密。以後,咱們對 ArrayBuffer
進行解碼,並返回人類可讀的字符串。
decrypt.js
export default async (messageJSON, derivedKey) => { try { const message = JSON.parse(messageJSON); const text = message.base64Data; const initializationVector = new Uint8Array(message.initializationVector).buffer; const string = atob(text); const uintArray = new Uint8Array( [...string].map((char) => char.charCodeAt(0)) ); const algorithm = { name: "AES-GCM", iv: initializationVector, }; const decryptedData = await window.crypto.subtle.decrypt( algorithm, derivedKey, uintArray ); return new TextDecoder().decode(decryptedData); } catch (e) { return `error decrypting message: ${e}`; } };
也有可能因爲使用了錯誤的派生密鑰或初始化向量,致使這個解密過程失敗,這意味着用戶沒有正確的密鑰對來解密他們收到的文本。在這種狀況下,咱們會返回一個錯誤信息。
而這就是全部須要的加密工做!在下面的章節中,我將解釋我是如何使用咱們在上面實現的方法來對一個使用Stream Chat強大的React聊天組件構建的聊天應用程序進行端到端加密的。
將encrypted-web-chat倉庫克隆到本地文件夾中,安裝依賴項並運行它。
$ git clone https://github.com/getstream/encrypted-web-chat $ cd encrypted-web-chat/ $ yarn install $ yarn start
以後,應打開瀏覽器選項卡。可是首先,咱們須要使用咱們本身的Stream Chat API密鑰配置項目。
在GetStream.io上建立賬戶,建立一個應用程序,而後選擇開發而不是生產。
爲簡化起見,讓咱們同時禁用身份驗證檢查和權限檢查。確保點擊保存。當您的應用程序在生產中,您應該保持這些啓用,並有一個後端爲用戶提供令牌。
請注意Stream憑據,由於下一步將使用它們在應用程序中初始化聊天客戶端。因爲咱們禁用了身份驗證和權限,所以咱們如今僅真正須要密鑰。不過,在將來,你仍是會在你的後臺使用密鑰來實現認證,爲Stream Chat發行用戶令牌,這樣你的聊天應用就能夠有適當的訪問控制。
如您所見,我已編輯密鑰。最好保留這些憑據的安全性。
在 src/lib/chatClient.js
中,用您的密鑰更改密鑰。咱們將使用此對象進行API調用並配置聊天組件。
chatClient.js
import { StreamChat } from "stream-chat"; export default new StreamChat("[api_key]");
在此以後,您應該可以測試應用程序。在如下步驟中,您將瞭解咱們定義的函數適用於何處。
在 src/lib/setUser.js
中,咱們定義了設置聊天客戶端的用戶並使用給定的公鑰對更新的函數。發送公共密鑰對於其餘用戶來講是必要的,以便得到與咱們的用戶進行加密和解密通訊所需的密鑰。
setUser.js
import chatClient from "./chatClient"; export default async (id, keyPair) => { const response = await chatClient.setUser( { id, name: id, image: `https://getstream.io/random_png/?id=cool-recipe-9&name=${id}`, }, chatClient.devToken(id) ); if ( response.me?.publicKeyJwk && response.me.publicKeyJwk != JSON.stringify(keyPair.publicKeyJwk) ) { await chatClient.disconnect(); throw "This user id already exists with a different key pair. Choose a new user id or paste the correct key pair."; } await chatClient.upsertUsers([ { id, publicKeyJwk: JSON.stringify(keyPair.publicKeyJwk) }, ]); };
在此函數中,咱們導入上一版中定義的 chatClient
。它須要一個用戶ID和一個密鑰對,而後調用 chatClient.setUser
來設置用戶。此後,它將檢查該用戶是否已經具備公共密鑰,而且是否與給定密鑰對中的公共密鑰匹配。若是公鑰匹配或不存在,咱們將使用給定的公鑰更新該用戶;若是不是,咱們斷開鏈接並顯示錯誤。
在 src/components/Sender.js
中,咱們定義了第一屏,在這裏選擇咱們的用戶id,可使用咱們在 generateKey.js
中描述的函數生成一個密鑰對,若是這是一個現有的用戶,則能夠粘貼用戶建立時生成的密鑰對。
在 src/components/Recipient.js
中,咱們定義了第二個屏幕,在這裏咱們選擇要與之通訊的用戶的id。該組件將使用 chatClient.queryUsers
獲取該用戶。該調用的結果將包含用戶的公鑰,咱們將用它來導出加密/解密密鑰。
在 src/components/KeyDeriver.js
中,咱們定義了第三個屏幕,其中密鑰是使用咱們在 deriveKey.js
中實現的方法派生的,該方法使用發送方(us)的私鑰和接收方的公鑰。該組件只是一個被動加載屏幕,由於所需的信息已在前兩個屏幕中收集。可是若是密鑰有問題,它會顯示一個錯誤。
在 src/components/EncryptedMessage.js
中,咱們自定義Stream Chat的Message組件,使用咱們在 decrypt.js
中定義的方法對消息進行解密,同時提供加密數據和派生密鑰。
若是不對Message組件進行此自定義,它將顯示以下:
經過包裝Stream Chat的 MessageSimple
組件並使用 useEffect
鉤子來使用DEcrypt方法修改消息屬性來進行自定義。
在 src/components/EncryptedMessageInput.js
中,咱們自定義Stream Chat的MessageInput組件,以便在發送以前使用咱們在 encrypt.js
中定義的方法將寫好的消息與原始文本一塊兒加密。
定製是經過包裝Stream Chat的 MessageInputLarge
組件並將 overrideSubmitHandler
prop設置爲一個函數來完成的,該函數在發送到通道以前對文本進行加密。
最後,在 src/components/Chat.js
中,咱們使用Stream Chat的組件和咱們自定義的Message和EncryptedMessageInput組件構建整個聊天屏幕。
恭喜你!您剛剛學習瞭如何在Web應用程序中實現基本的端到端加密,重要的是要知道這是端對端加密的最基本形式。它缺少一些額外的調整,可讓它在現實世界中更加彈性,好比隨機化填充、數字簽名和前向保密等等。此外,對於實際使用而言,得到應用程序安全專業人員的幫助也相當重要。