端到端加密测试不应仅仅证明一个浏览器能够解密另一个浏览器加密的内容。它应当模拟一个主动中继、篡改经过认证的元数据、将密文移入错误的上下文、尝试降级、中断状态转换,然后验证两个结果:每一次攻击都被拒绝,并且诚实通信在此之后仍然正常工作。本文针对一个小型浏览器 E2EE 包开发了该方法,该包使用 Node.js 在本地运行,无需服务器。
最小的有用加密测试看起来似曾相识:
const wire = await sender.encryptText("hello", 1, "primary");
const value = await receiver.decryptText(wire);
assert.equal(value.text, "hello");这能检测出接线错误。但它并不能证明攻击者无法替换公钥、篡改修订号、在另一次传输中重放文件块,或在消息被拒绝后使会话永久不可用。
还有另一个陷阱:实现与其测试辅助代码可能犯同样的错误。如果两者以相同方式推导出错误的密钥,往返测试仍会通过。因此,一个可信的测试套件需要从三个不同视角审视协议:
诚实的端到端路径;
必须失败的对抗性输入;
对重要密码学结果的独立计算。
本示例是 Apache-2.0 许可的 IcyZip E2EE 审查包。其浏览器模块通过 Web Cryptography API(应用程序编程接口) 使用 P-256 ECDH、HKDF-SHA-256、HMAC-SHA-256 和 AES-256-GCM。该设计专用于此应用程序,并非通用密码学库。
威胁边界很重要。一个随机的 32 字节密钥在配对 URL 片段中传输。它在公钥被导入用于 ECDH 之前,对每个对等方的公钥进行认证。中继仍然可以观察到操作元数据,例如配对标识符、公钥、认证标签、密文大小、时序、连接数据,以及文件提议字段(包括文件名、MIME 提示和大小)。这些测试针对的是文档中记录的 E2EE 边界;它们不会将元数据变为密文,也不会使泄露的配对链接变得无害。
浏览器模块派生四个独立的密钥:公钥认证、文本、文件分块和文件邀约。测试套件使用 Node 的 node:crypto API 独立重复该计算,而不是调用模块的派生辅助函数。
基本形态是:
const ecdh = createECDH("prime256v1");
ecdh.setPrivateKey(privateScalar);
const shared = ecdh.computeSecret(peerPublicKey);
const material = Buffer.concat([shared, pairingSecret]);
const salt = sha256(canonical(["v3", pairId, primaryKey, secondaryKey]));
const textKey = hkdfSync(
"sha256",
material,
salt,
"icyzip/e2ee/text/v3",
32
);oracle 分别派生全部四个上下文,并断言四个结果互不相同。然后它验证一个公钥 HMAC,使用 Node 的 AES-GCM 实现解密一个文本信封,检查一个文件邀约 HMAC,并解密一个文件帧。
这并不能构成独立的安全证明。它消除了一种常见的虚假通过:生产辅助函数不能与自己一致并称这种一致为验证。协议上下文也足够可见,使审查者能够将其与 RFC 5869 中的 HKDF 定义以及 Web Cryptography API 进行对比。
未经认证的 ECDH 交换容易受到主动中间人攻击。中继可以与每个浏览器分别建立密钥,并读取或替换它们之间的所有内容。加密传输并不能修复这一点;对等方必须对它们导入的内容进行认证。
在此设计中,URL 片段秘密派生一个 HMAC 密钥。每个浏览器对一个规范值签名,该值包含协议版本、配对标识符、发送方角色和公钥。接收方浏览器在导入对等方密钥之前验证该标签。
回归测试创建两个诚实端点和两个不持有片段秘密的中继端点:
await assert.rejects( primary.acceptPublicMessage(await relayTowardPrimary.publicMessage()) ); await assert.rejects( secondary.acceptPublicMessage(await relayTowardSecondary.publicMessage()) ); assert.equal(primary.ready(), false); assert.equal(secondary.ready(), false);
第二个测试对 subtle.importKey 进行插桩。在无效 HMAC 被拒绝后,原始 ECDH 公钥导入的计数器必须仍为零。这种顺序断言比仅检查后续操作抛出异常更强:不受信任的密钥从未到达 ECDH 导入步骤。
然后变异表一次更改一项:角色、配对、版本、公开点、标签、编码、数组长度和反射的自消息。每种变体都必须失败。
最后,诚实对等方连接并交换一条新的加密消息。最后一个断言并非装饰。它证明拒绝中继没有污染状态机。
应用程序通常加密可见文本,但将控制字段留在其旁边。无法读取内容的中继仍可能更改哪个编辑显示为最新,或哪个端点显示为来源。
此协议中的文本明文是一个经过认证的对象:
{
text: "unchanged plaintext",
t: 4503599627370497,
o: "original-origin"
}AES-GCM 加密该对象。信封仅包含版本、算法标识符、nonce 和密文。对标识符和文本协议标识符是附加认证数据(AAD):
additionalData: canonical(["icyzip/text/v2", pairId])
测试首先确认修订版本和来源在序列化信封中均不可见。然后它在密文之外添加伪造的 t 和 o 属性。解析器拒绝该信封,因为其形状不再精确。位翻转、替换 nonce、截断密文、更改算法、更改版本、使用另一对的密钥,以及经过认证但格式错误的明文元数据也会失败。
这里有一个重要区别。AES-GCM 可以认证字节,但它不决定解码后的修订版本是否为非负安全整数,或来源字符串是否在应用程序的限制范围内。密码学验证和模式验证是两道独立的关卡,测试套件对两者都进行了检验。
在每一组变异之后,原始信封必须仍能解密。同样,失败只是契约的一半。
有效的密文不一定在所有地方都有效。考虑来自传输 A 序列 3 的一个块。如果接收方还将其接受为序列 2,或作为传输 B 的一部分,攻击者可能在不破坏标签的情况下重新排序或拼接经过认证的数据。
文件块的 AAD 绑定四个值:
["icyzip/file/chunk/v3", pairId, transferId, sequence]
变异测试加密 65,508 字节,在 12 字节 nonce 和 16 字节 GCM 标签开销之后产生 65,536 字节的帧。它验证确切的明文,然后尝试:
错误的传输标识符;
错误的序列号;
负的序列号;
nonce、正文和最后一个字节中的位翻转;
帧太短,无法包含有意义的密文。
每一种变体都会被拒绝。未被篡改的帧随后会在两个方向上再次解密。
同样的原则也适用于任何分块到达之前。文件提议会暴露传输 UI 和中继所需的操作字段。提议 HMAC 覆盖传输标识符、文件名、明文大小、MIME 提示、线上大小和加密标记,以及配对和发送方角色。测试套件会分别更改每个可见字段,将提议反射回其发送方,并移除签名。这些都不会被接受。
对于任何加密文件协议,这是一个有用的审查问题:哪些值控制分配、进度、排序或最终下载,以及它们究竟在何处被认证?
加密标记只是一种声明。如果接收方看到 encrypted=true 后便将后续字节视为可信,中继可能能够将明文标记为加密,或将会话降级到旧版路径。
因此,接收控制器测试包含的不仅是密码学原语。它们使用真实发布的文件接收集成,并断言:
旧版或被更改的加密标记不会创建密钥或下载;
仅凭标记无法在认证密钥建立之前开始传输;
线上大小必须是显式的有效整数;
伪造的加密提议永远不会将明文转换为可下载文件;
未签名或修改过的提议无法替换已验证的下载;
被篡改的分块会取消传输,且不产生下载;
后续经过认证的重试会成功。
最后一条属性可防止一种粗糙的故障关闭实现:它对一个坏帧的响应是永久禁用文件传输。拒绝后的可用性是安全行为的一部分,因为永久性失败会诱使人们采用不安全的恢复变通方法。
浏览器密码学是异步的。用户可能在认证仍在解析时取消、重新加载、失去存储访问权限或重置对等方。孤立地等待每个干净步骤的测试会错过这些竞态。
会话维护一个纪元。接受公共消息会记录当前纪元;对等方重置会递增该纪元。如果认证在重置之后完成,操作会返回而不安装过期的密钥。
测试套件会故意启动公共消息接受,在完成前重置对等方,并断言待处理操作无法恢复旧状态。单独的用例涵盖:
使用匹配的已存储密钥对重新加载标签页;
当已存储的私钥与公钥材料不再匹配时重新生成;
在存储 API 抛出异常的情况下运行;
在有工作待处理时关闭会话;
在文件提供认证期间取消或重置。
这些并非罕见的浏览器琐事。它们检验的是经过认证的数据是否会跨越用户已经关闭的生命周期边界。
随机变异很容易被夸大。一个模糊测试器向解析器抛出 1,000 个畸形输入后打印成功,可能只证明了解析器抛出了 1,000 次异常。它可能没有注意到某次攻击被接受了、正常流量停止工作,或者某次运行无法复现。
审查包使用确定性计划种子来生成合成输入和变异。该种子不控制密码学熵。每次迭代执行五项负向检查:
篡改公钥认证器;
篡改文本信封;
篡改文件帧;
篡改文件提供字段或标签;
篡改配对片段。
在这些失败之间和之后,它会检查有效的文本、文件、提供、片段、回复和向前推进路径。该工具记录变异计划的 SHA-256 摘要,以便另一次运行可以确认它执行了相同的计划。
本文的一次全新默认运行报告:
{
"result": "fuzz-pass",
"seed": 1387,
"iterations": 256,
"rejected": 1280,
"roundTrips": 1024,
"planSha256": "297acd86f43795a7e1c13c1af6c7df43f1d454a4ca9333fc9f24cdda0e864a75"
}完整套件在同一次全新运行中通过了 31 项测试中的 31 项。这些计数是已发布用例和输入的证据,而非不存在漏洞的证据。
一个反假绿测试会故意削弱模糊测试器的密码学端点,并确认测试框架失败,而不是将已接受的攻击计为成功。当紧凑的汇总数字将被复制到文档中时,测试测试本身尤其有价值。
一个经过精美测试的重新实现可能无法说明用户实际收到的代码。因此,该包为完整的浏览器模块和安全相关的集成部分记录了源锚点、字节计数和 SHA-256 哈希。来源测试会拒绝已更改的模块字节、已更改的提取部分、缺失的边界和重复的边界。
这仍然不是独立审计,通过哈希校验也无法发现设计缺陷。它回答的是一个更窄但必要的问题:审查者和测试所面对的是预期的字节,还是已经偏离应用程序、经过手工清理的示例?
该软件包还提供可选的实时比较,仅限于两个固定的 HTTPS 资源。普通的密码学测试和回归测试本身不需要账户、服务器、安装软件包,也不需要除获取源代码之外的网络连接。它们使用 Node.js 20 或更新版本及内置模块,通过标准 Node 测试运行器运行。
git clone https://icyzip.com/open-source/icyzip-e2ee.git cd icyzip-e2ee npm test npm run fuzz
对于浏览器端 E2EE,我不再接受将“加密、解密、比较”作为测试的全部内容。一套有用的审查套件应使以下陈述可执行:
未认证的对等方密钥在密钥协商之前被拒绝;
角色、配对、版本和协议域不能被替换;
控制元数据在影响行为的任何地方都被加密或认证;
密文不能在消息、传输、块或序列之间移动;
加密标签不能将明文变为被接受的加密数据;
格式错误的已认证明文仍然无法通过模式验证;
取消、重新加载、存储失败和重置不能恢复过期状态;
每次拒绝攻击后都跟随一个仍然成功的诚实操作;
独立预言机检查重要的派生值;
来源检查将审查的字节与预期的集成连接起来;
模糊测试结果可复现,且测试框架经过针对假阳性的测试。
一套全部通过的测试套件不能证明不存在弱点,正如一个空的问题跟踪器也不能。它能做的是使特定的安全声明可被证伪。这才是有用的标准:命名攻击、编码攻击、要求拒绝,然后证明系统能够安全地继续运行。
IcyZip 的所有者和运营者。撰写关于浏览器端到端加密、可复现测试和诚实的 Web 安全权衡的文章。
转载声明:本文为转载整理,原文版权归原作者及原站点所有。
原文来源:Hacker Noon
原文作者:Richard Andresik;如有侵权,请联系管理员删除。