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