hpke-ng:更快、更小、更安全的 Rust HPKE 实现

Lobsters Hottest 工具

摘要

Symbolic 发布了 hpke-ng,这是一个新的 Rust 实现的 HPKE(RFC 9180),旨在通过避免现有库(如 hpke-rs)中的错误和抽象来提供更好的性能和安全性。

<p><a href="https://lobste.rs/s/fap65c/hpke_ng_faster_smaller_harder_hpke_for">评论</a></p>
查看原文
查看缓存全文

缓存时间: 2026/05/08 10:56

# "hpke-ng:更快、更小、更安全的 Rust HPKE 库" 来源:https://symbolic.software/blog/2026-05-08-hpke-ng/ 今天,我们发布了 `hpke-ng`(https://github.com/symbolicsoft/hpke-ng),这是一个完全重新实现的 Rust HPKE(RFC 9180)(https://www.rfc-editor.org/rfc/rfc9180.html) 库。它采用 Apache-2.0 OR MIT 许可证发布,您现在可以通过 `cargo add hpke-ng` 进行安装。在 44 项与 `hpke-rs`(https://github.com/cryspen/hpke-rs)——当前部署最广泛的 Rust HPKE 库——的直接对比基准测试中,hpke-ng 获胜 16 项,持平 25 项,仅输 3 项;获胜项集中在 encap/decap 路径、open 路径以及 ChaCha20 设置路径上。 `cargo bench · 44 项头对头基准测试` - hpke-ng 更快(>3% 差异) - 在 criterion 噪声带(±3%)之内 - hpke-rs 更快 两个库底层都调用了相同的 RustCrypto 原语 crate,因此加密本身是相同的。两者都通过了完整的 RFC 9180 已知答案测试集。在我们差分测试的每个密码套件上,两者产生的线路输出字节完全一致。获胜之处在于框架、单态化、分配行为以及分发——而非底层的加密数学。这正是关键所在:数学是一个已经解决的问题,而库的周边工程仍有改进空间。25 项持平也告诉您一些信息:在 AEAD 主导的行中,两个库都收敛到底层原语的速度,hpke-ng 在无额外开销的情况下达到了该上限。 ## 我们为何要构建另一个 HPKE 库 今年早些时候,我们发现并向 hpke-rs 报告了两个安全漏洞。第一个是**缺失了 RFC 9180 §7.1.4 的零共享秘密检查**(https://github.com/cryspen/hpke-rs/pull/117):低阶或身份公钥会迫使 X25519 共享秘密全为零,此后密钥调度的其余部分变得确定且可预测,任何知道静态接收方密钥的人都能预测。修复方法是对零进行一次比较,这是 RFC 9180 明确要求的;但该检查缺失了。第二个是 **`u32` 序列计数器在发布构建中静默回绕**(https://github.com/cryspen/hpke-rs/pull/118),导致在 2^32 条消息后重复使用 nonce。AEAD 中的 nonce 重用是灾难性的——对于 AES-GCM 会泄露认证密钥;对于 ChaCha20-Poly1305 会通过 XOR 泄露明文——而回绕发生在类型系统之下、发布构建之中,且没有任何诊断信息。这两个问题现在都已修复。 我们在二月份(https://symbolic.software/blog/2026-02-05-cryspen/)记录了我们不断在以“高保证”为宣传的库中发现的更广泛的错误模式。那段经历——加上通过一个并非为从结构上杜绝此类错误而构建的库来集成 HPKE 的日常摩擦——促使我们考虑重写。有三个摩擦点反复出现。 **提供者抽象。** hpke-rs 的结构是一个基于 `HpkeCrypto` trait 的泛型库,带有两个后端实现——RustCrypto 和 libcrux——以单独的 crate 形式提供。该抽象是实实在在的工程,并且服务于一个实际目的:它允许部署在不触及调用点的情况下替换底层加密栈。代价是每次原语调用都要通过 trait 分发,每个 `Hpke` 值都携带一个 320 字节的实例结构体(其中大部分是 256 字节的 ChaCha20 PRNG 状态),并且工作空间是四个 crate 而非一个。 **结构体拥有的 PRNG。** `Hpke::new` 构造并存储一个 PRNG。该 PRNG 在操作间重复使用,这在功能上是没问题的,但会造成一个微妙的别名风险:克隆一个 `Hpke` 并不会克隆 PRNG 状态(根据 rustdoc 说明),因此一个粗心的克隆可能会以在调用点不可见的方式重置随机性。这是一种隐患,其损害在最终爆发前都是不可见的。 **将必须的模式参数设为 `Option<&[u8]>`。** `hpke.seal(&pk, info, aad, pt, None, None, None)` 是典型的 Base 模式调用。三个 `None` 分别是 PSK、PSK ID 和发送方静态密钥——这些在 Auth 或 AuthPsk 模式中都是必需的。单一的 `seal` 方法通过将模式特定参数设为可选来接受所有模式,这意味着类型系统无法告诉您,您已经在提供 PSK 的情况下构建了一个 Base 模式调用。 这些并非灾难性问题。它们是那种您直到构建了替代方案才会注意到的、持续存在的小成本。 ## 形态变化:枚举分发变为类型状态 hpke-ng 与 hpke-rs 之间最大的设计差异在于密码套件由谁携带。在 hpke-rs 中,密码套件是四个运行时枚举(`Mode`、`KemAlgorithm`、`KdfAlgorithm`、`AeadAlgorithm`),在 `Hpke::new` 时构造。在 hpke-ng 中,密码套件本身即是类型——`Hpke`——而结构体主体是 `PhantomData<(K, F, A)>`。运行时零字节;一切在调用点由编译器解析。 典型 seal · "加密一条消息" hpke-rs:7 个参数 · 3× `Option<&[u8]>` ``` let mut hpke = Hpke::::new( Mode::Base, KemAlgorithm::DhKem25519, KdfAlgorithm::HkdfSha256, AeadAlgorithm::ChaCha20Poly1305, ); let kp = hpke.generate_key_pair()?; let (sk, pk) = kp.into_keys(); let (enc, ct) = hpke.seal( &pk, info, aad, pt, None, None, None, // psk, psk_id, sk_s )?; ``` hpke-ng:5 个参数 · 零占位符 ``` type Suite = Hpke< DhKemX25519HkdfSha256, HkdfSha256, ChaCha20Poly1305, >; let mut os = OsRng; let mut rng = os.unwrap_mut(); let (sk, pk) = DhKemX25519HkdfSha256::generate(&mut rng)?; let (enc, ct) = Suite::seal_base( &mut rng, &pk, info, aad, pt, )?; ``` 可见的后果是调用点。不可见的后果是编译器可以排除的内容: | 操作 | hpke-rs | hpke-ng | | --- | --- | --- | | `Hpke::::seal_auth(...)` | 运行时 `Error::UnsupportedKemOperation` | 编译时 trait 约束 `K: AuthKem` 不满足 | | `Hpke::<_, _, ExportOnly>::seal_base(...)` | 运行时 `HpkeError::InvalidConfig` | 编译时 `ExportOnly` 上没有 `seal_base` | | 为私钥指定错误的 `KemAlgorithm` | 运行时在 `setup_*` 处出现不匹配错误 | 编译时密钥类型带有 KEM 标签 | | Base 模式调用中传递 `Some(psk)` 参数 | 运行时 `HpkeError::UnnecessaryPsk` | 编译时 `seal_base` 没有 PSK 参数 | 每一行在 hpke-rs 中都是运行时错误,在 hpke-ng 中则是编译器诊断。`X-Wing/seal_auth` 行是最清晰的例子:X-Wing 是一个 KEM,但它不是 Diffie-Hellman KEM,因此它没有认证封装的概念。在 hpke-rs 中,在配置了 X-Wing 的 `Hpke` 上调用 `seal_auth` 会在运行时返回 `Error::UnsupportedKemOperation`。在 hpke-ng 中,`seal_auth` 上的 trait 约束要求 `K: AuthKem`,而 `XWingDraft06` 并未实现 `AuthKem`——因此该调用无法编译。其他行也遵循相同的模式。 这并非理论上的。我们在生产代码审查中见过这四种形式的每一种——通常是一个过时的 `match` 分支捕获运行时错误并将其转换为通用的 500 错误。将它们作为编译期错误暴露出来,完全删除了一类代码路径。 ## 功能对等 在深入之前:显而易见的怀疑是 *砍掉了什么?* 答案是刻意砍掉了一件事,而正是这件事在其他所有地方带来了收益。 | | hpke-rs | hpke-ng | | --- | --- | --- | | DH KEM(X25519、X448、P-256、P-384、P-521、secp256k1) | ✓ 全部 6 种 | ✓ 全部 6 种 | | 后量子 KEM(X-Wing draft-06、ML-KEM-768、ML-KEM-1024) | ✓ 全部 3 种 | ✓ 全部 3 种 | | KDF(HKDF-SHA-256 / -384 / -512) | ✓ 全部 3 种 | ✓ 全部 3 种 | | AEAD(AES-128-GCM、AES-256-GCM、ChaCha20-Poly1305、ExportOnly) | ✓ 全部 4 种 | ✓ 全部 4 种 | | 模式(Base、Psk、Auth、AuthPsk) | ✓ 全部 4 种 | ✓ 全部 4 种 | | RFC 9180 KAT 合规性(完整向量集) | ✓ 通过 | ✓ 通过 | | 类型化 `HpkeError` 变体 | 11 | 14 | | 编译期拒绝的操作类别 | 0 | 4 | | 可插拔加密提供者(RustCrypto / libcrux 后端) | ✓ 两者 | — 仅 RustCrypto | hpke-ng 支持 hpke-rs 的 RustCrypto 提供者所支持的每个密码套件——六个基于 DH 的 KEM、三个后量子 KEM、三个 KDF、四个 AEAD(包括 ExportOnly)、所有四种 HPKE 模式——并且针对两个库都通过了完整的 RFC 9180 KAT 向量集。有 14 个类型化 `HpkeError` 变体而不是 11 个,以及四个操作类别是编译时错误而非运行时错误。在影响您的应用可以表达什么的所有维度上,hpke-ng 都是一个超集。 hpke-rs 拥有而 hpke-ng 刻意不拥有的那一件事是:可插拔加密提供者。hpke-rs 提供了一个 `HpkeCrypto` trait,带有两个后端实现(RustCrypto 和 libcrux);部署可以在编译时替换其中一个。hpke-ng 只提供一个提供者(RustCrypto),并移除了抽象。这就是支撑权衡的关键所在。这就是为什么 `Hpke<...>` 是零大小,为什么 `Context::seal` 是单态化而非分发,为什么工作空间是一个 crate,以及为什么典型的调用点是五个参数而不是七个。 我们认为,该权衡中的 libcrux 部分并不是一个值得追求的功能。我们在二月份对 Cryspen 的“形式化验证”库进行的审计(https://symbolic.software/blog/2026-02-05-cryspen/)发现了 libcrux-ml-dsa 中未公开的静默加密失败(平台相关的 SHA-3 损坏,已修补但未发布公共通报)、Ed25519 中减少熵的预哈希钳制,以及 libcrux-psq 的 AES-GCM 解密路径中的拒绝服务 panic。Cryspen 的公开回应(https://symbolic.software/blog/2026-02-12-cryspen-response/)承认了这些发现,但没有撤回附加到该库的“最高保证”营销语言;我们的后续分析(https://symbolic.software/blog/2026-02-17-cryspen-mldsa/)以及一篇独立的 eprint 论文(https://eprint.iacr.org/2026/192)随后发现了 libcrux-ml-dsa 中更多的规范偏离。吸引用户使用 libcrux 的“形式化验证”框架,根据我们所收集的证据,并未描述您所获得的工程质量。如果您无论如何都在使用 RustCrypto——这正是 hpke-ng 的默认做法,也是大多数 hpke-rs 部署的实际做法——那么提供者抽象就是在为一个您最好避免的后端支付租金。 ## 速度 完整的基准测试协议在 hpke-ng 仓库的 `cargo bench --features comparative` 中:criterion 框架,样本量 40-60,2-3 秒测量窗口,`RUSTFLAGS="-C target-cpu=native"`,`lto = "thin"`,`codegen-units = 1`。Apple Silicon M 系列,macOS。以下数字是两次独立基准运行的中位数;我们会提前告诉您哪里获胜以及哪里持平。 获胜集中在三个地方:**KEM encap/decap 路径**、**单次 open 路径**以及 **ChaCha20 设置路径**。这些是 hpke-rs 的每次调用开销——trait 分发、分配器压力、每个原语的枚举匹配——在底层加密之上增加了可测量的框架成本的行。 从 X25519 上的 KEM 操作开始: #### KEM 操作 · X25519 hpke-rs vs hpke-ng 越低越好。条形图按行归一化为 `max(rs, ng)`。 整个数据集中的最大差异就在此图表中。`encap` 和 `decap` 在 hpke-ng 上快了 21-22%——而这些并不是框架上的胜利,而是结构的实际结果:hpke-ng 的带标签 KDF 路径在具体 KDF 类型上是单态化的,因此包装原始 Diffie-Hellman 的 `LabeledExtract` + `LabeledExpand` 调用链会编译为直接调用。hpke-rs 则通过其枚举分发的提供者 trait 路由每个调用。 `generate` 行则相反(+7%,围绕相同的 `x25519-dalek::StaticSecret` 构造函数的单次调用管道);我们将其保留在图表中是因为隐藏它是不诚实的,而且这是一个每次密钥对仅调用一次的操作,不会出现在任何热路径上。 设置是每次构建发送方或接收方上下文时支付的组合固定成本:KEM 操作 + 密钥调度 + Context 分配。这些胜利在 ChaCha20 上是一致的: #### 设置路径 · 发送方 / 接收方 / PSK hpke-rs vs hpke-ng - X25519+ChaCha20 发送方 (Base) −12% - X25519+ChaCha20 接收方 (Base) −8% - X25519+ChaCha20 发送方 (PSK) −11% - K256+ChaCha20 发送方 (Base) −8% P-256+AES-128 和 X25519+AES-128 的设置在同一套件中在 ±3% 内持平。 单次 open 路径——`setup_receiver` + 用于一条消息的 `Context::open`——是 hpke-ng 跨越有效载荷大小最一致获胜的地方。跨越四个数量级的六行,每一行都是 hpke-ng: #### 单次 open · X25519 + HKDF-SHA-256 + ChaCha20-Poly1305 hpke-rs vs hpke-ng 越低越好。两次独立运行的中位数。 AES-128-GCM seal 扫描显示一个更小、更均匀的模式:hpke-ng 在小型和中型有效载荷上始终领先 4-6%,然后随着 AEAD 原语开始主导每次调用成本而收敛到持平: #### 单次 seal · X25519 + HKDF-SHA-256 + AES-128-GCM hpke-rs vs hpke-ng 从 4 KiB 向上,AES-NI 原语成本占主导,框架差异消失。X25519+ChaCha20 seal 扫描——同一形状但使用不同的 AEAD——在所有有效载荷大小上都持平。这不是 hpke-ng 的损失;这是两个库完全以相同方式调用的原语的图表,一旦消息超过几百字节,框架开销就会被摊销掉。 套件中最具诊断性的单个基准测试是设置后 `Context::seal` 在该频谱的两个端点上的表现: - Context::seal · 64 B 框架主导区域:253 ns(hpke-rs),225 ns(hpke-ng)−11% · 仅框架,相同加密 - Context::seal · 16 KiB 原语主导区域:24.49 μs(hpke-rs),24.36 μs(hpke-ng)持平 · 在原语上限处 这是我们在开发过程中反复查看的图表。在 64 字节处,框架占主导,hpke-ng 快 11%:225 ns 对比 253 ns。hpke-ng 的 `Context::seal` 内部的框架路径是一个固定大小的 12 字节栈数组用于 nonce、一个 XOR 循环以及一个直接 AEAD 调用——没有分配。hpke-rs 为每次 nonce 计算分配一个新的 `Vec`。在 16 KiB 处,AEAD 原语占主导,两个库收敛到相同的墙上时间。它们共享相同的原语 crate,因此这是上限:此硬件上 `ChaCha20Poly1305::encrypt_in_place_detached` 的墙上时钟成本,两个库都无法低于这一点,因为它们都调用相同的代码。hpke-ng 恰好匹配它——没有剩余的开销可移除,框架与标准允许的一样薄。 ## 内存 size-of 比较是数据集中最清晰的可视化: - `sizeof(Hpke)`:hpke-rs 320 B,hpke-ng 0 B(零大小) - `sizeof(Context)`:hpke-rs 400 B,hpke-ng 80 B(−80%) `hpke-ng::Hpke` 是 `PhantomData<(K, F, A)>`。没有运行时存在;它占用零字节,`cargo expand` 确认编译器将其完全优化掉。`hpke-rs::Hpke` 携带一个 256 字节的 ChaCha20 PRNG 加上四个枚举判别值和填充。 `Context` 是更有趣的比较,因为两个库都携带实际状态——AEAD 密钥、基础 nonce、导出器机密以及序列号。hpke-ng 是 80 字节,而 hpke-rs 是 400 字节。320 字节的差距主要是每个 Context 的 PRNG(hpke-rs 的 `Context` 为任何未来的 encap 操作保留)加上提供者的 trait 对象开销。 实际影响:一个持有数千个长期 HPKE 上下文的应用程序——一个具有持久客户端会话的服务器、一个中继、MLS 组状态——仅从这一种类型即可节省 320 字节的常驻内存。绝对数值上不算大,但每个永久状态字节都是工作集的一个字节,而工作集决定了缓存行为。 ## 更小 #### 项目表面积 | | hpke-rs | hpke-ng | | --- | --- | --- | | 终端用户二进制(剥离、发布) | −30% | | | 项目总代码(cloc) | −19% | | | 面向用户的 Cargo 特性 | −36% | | 展开说明: **终端用户二进制,−30%。** 一个最小应用——生成密钥、封印消息、解封回来,十行 Rust——使用 `RUSTFLAGS="-C target-cpu=native"`、`lto="thin"`、`codegen-units=1`、`strip="symbols"` 编译。hpke-ng 仅为 392 KB;hpke-rs 则为 561 KB。168 KB 在嵌入式目标、WASM 捆绑包或 CDN 提供的二进制文件中并非微不足道。 **库代码,基本持平。** hpke-n

相似文章