使用Web Crypto API的端到端加密聊天

在傳輸或存儲用戶數據(尤爲是私人對話)時,必須考慮採用加密技術來確保隱私。javascript

經過閱讀本教程,您將瞭解如何僅使用JavaScript和Web Crypto API(一種本地瀏覽器API)在Web應用程序中對數據進行端到端加密。html

請注意,本教程很是基礎,而且具備嚴格的教育意義,可能包含一些簡化,不建議使用您本身的加密協議,若是沒有在安全專家的幫助下正確使用,所使用的算法可能包含某些「陷阱」java

若是您碰巧迷路了,也能夠在此GitHub倉庫中找到完整的項目。git

什麼是端到端加密?

端到端加密是一種通訊系統,其中惟一可以讀取消息的人就是進行通訊的人。沒有任何竊聽者能夠訪問解密對話所需的加密密鑰,甚至是運行消息傳遞服務的公司也沒法訪問。github

什麼是Web Crypto API?

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密鑰配置項目。

配置Stream Chat Dashboard

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 獲取該用戶。該調用的結果將包含用戶的公鑰,咱們將用它來導出加密/解密密鑰。

KeyDeriver組件

src/components/KeyDeriver.js 中,咱們定義了第三個屏幕,其中密鑰是使用咱們在 deriveKey.js 中實現的方法派生的,該方法使用發送方(us)的私鑰和接收方的公鑰。該組件只是一個被動加載屏幕,由於所需的信息已在前兩個屏幕中收集。可是若是密鑰有問題,它會顯示一個錯誤。

EncryptedMessage組件

src/components/EncryptedMessage.js 中,咱們自定義Stream Chat的Message組件,使用咱們在 decrypt.js 中定義的方法對消息進行解密,同時提供加密數據和派生密鑰。

若是不對Message組件進行此自定義,它將顯示以下:

經過包裝Stream Chat的 MessageSimple 組件並使用 useEffect 鉤子來使用DEcrypt方法修改消息屬性來進行自定義。

EncryptedMessageInput組件

src/components/EncryptedMessageInput.js 中,咱們自定義Stream Chat的MessageInput組件,以便在發送以前使用咱們在 encrypt.js 中定義的方法將寫好的消息與原始文本一塊兒加密。

定製是經過包裝Stream Chat的 MessageInputLarge 組件並將 overrideSubmitHandler prop設置爲一個函數來完成的,該函數在發送到通道以前對文本進行加密。

Chat組件

最後,在 src/components/Chat.js 中,咱們使用Stream Chat的組件和咱們自定義的Message和EncryptedMessageInput組件構建整個聊天屏幕。

Web Crypto API的後續步驟

恭喜你!您剛剛學習瞭如何在Web應用程序中實現基本的端到端加密,重要的是要知道這是端對端加密的最基本形式。它缺少一些額外的調整,可讓它在現實世界中更加彈性,好比隨機化填充、數字簽名和前向保密等等。此外,對於實際使用而言,得到應用程序安全專業人員的幫助也相當重要。

相關文章
相關標籤/搜索