在传输或存储用户数据(尤其是私人对话)时,必须考虑采用加密技术来确保隐私。
通过阅读本教程,您将了解如何仅使用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应用程序中实现基本的端到端加密,重要的是要知道这是端对端加密的最基本形式。它缺乏一些额外的调整,可以让它在现实世界中更加弹性,比如随机化填充、数字签名和前向保密等等。此外,对于实际使用而言,获得应用程序安全专业人员的帮助也至关重要。