在传输或存储用户数据(尤其是私人对话)时,必须考虑采用加密技术来确保隐私。

通过阅读本教程,您将了解如何仅使用JavaScript和Web Crypto API(一种本地浏览器API)在Web应用程序中对数据进行端到端加密。

请注意,本教程非常基础,并且具有严格的教育意义,可能包含一些简化,不建议使用您自己的加密协议,如果没有在安全专家的帮助下正确使用,所使用的算法可能包含某些“陷阱”

如果您碰巧迷路了,也可以在此GitHub仓库中找到完整的项目。

什么是端到端加密?

端到端加密是一种通信系统,其中唯一能够读取消息的人就是进行通信的人。没有任何窃听者可以访问解密对话所需的加密密钥,甚至是运行消息传递服务的公司也无法访问。

什么是Web Crypto API?

Web Cryptography API定义了一个低级接口,用于与用户代理管理或暴露的加密密钥材料进行交互。API本身对密钥存储的底层实现是不可知的,但提供了一组通用的接口,允许富Web应用执行诸如签名生成和验证、散列和验证、加密和解密等操作,而不需要访问原始密钥材料。

基础知识

在以下步骤中,我们将声明端到端加密所涉及的基本功能。您可以将每个文件复制到 lib 文件夹下的专用 .js 文件中。请注意,由于Web Crypto API的异步特性,它们都是异步函数。

注意:并不是所有的浏览器都能实现我们将使用的算法。说的就是IE和旧版Microsoft Edge。请查看MDN网页文档中的兼容性表:Subtle Crypto - Web APIs

生成密钥对

加密密钥对对于端到端加密至关重要。密钥对由公共密钥私有密钥组成。应用程序中的每个用户都应具有一个密钥对来保护其数据,其他用户可以使用公共组件,而密钥对的所有者只能访问私有组件。您将在下一部分中了解这些功能的作用。

要生成密钥对,我们将使用 window.crypto.subtle.generateKey 方法,并使用具有 JWK格式的 window.crypto.subtle.exportKey 导出私钥和公钥。可以将其视为序列化密钥以在JavaScript之外使用的一种方法。

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应用程序中实现基本的端到端加密,重要的是要知道这是端对端加密的最基本形式。它缺乏一些额外的调整,可以让它在现实世界中更加弹性,比如随机化填充、数字签名和前向保密等等。此外,对于实际使用而言,获得应用程序安全专业人员的帮助也至关重要。