- 角色
- 个人项目
- 状态
- 已发布
- 发布
- v1.11.0
- 关键结果
- 用 CI 泄漏回归门禁把关涉密路径。
- 技术栈
- Rust密码学no_std
cargo add gmcrypto-core
是什么
gm-crypto-rs 是一套纯 Rust 写的国密算法库,覆盖
中国商用密码杂凑(哈希)标准(GB/T 32905),输出 256 位摘要,在国密体系中大致对应 SHA-256 的角色。 哈希(GB/T 32905)、
中国商用密码公钥标准(GB/T 32918),基于椭圆曲线,用于数字签名、密钥交换和公钥加密。 公钥算法(GB/T 32918:签名 / 验签 /
加密 / 解密,v1.1 起还包括带密钥确认的密钥交换)和
中国商用密码分组密码标准(GB/T 32907),分组长 128 位、密钥长 128 位,用于批量对称加密。 分组密码(GB/T 32907)。
整套依赖都不碰标准库(不链接标准库的 Rust 构建,代码因而能运行在嵌入式、内核、WebAssembly 等受限环境中。它只能使用 core(标准库的子集);需要堆分配时才显式引入 alloc 并提供分配器。 + alloc),能编译到 wasm32-unknown-unknown,也实现了 RustCrypto 的
digest / mac / cipher trait,方便接入现有生态;从 v1.11 起,还可选适配 aead 0.6(SM4-GCM / SM4-CCM)。
从 v1.3 起,它还能解析 X.509 v3 证书并验证其 SM2-with-SM3 签名(GM/T 0015 profile,严格 DER,不做任何信任判定);对应的 C ABI 接口(在 gmcrypto-c crate 里)在 v1.4 跟上。v1.6 到 v1.9 把 TLCP(GB/T 38636)铺成了一套工具集:密钥派生(key schedule)与一条免密钥确认的 SM2 密钥交换路径(v1.6)、记录层的保护 / 解保护(v1.7)、证书对与证书链的签名验证(v1.8),
v1.9 起整套都能从 C 调用。
已发布的
gm-crypto-rs-demo
是面向使用者的示例与冒烟测试导览,覆盖散列、签名与验签、加解密、密钥交换和编码等代表性能力。它把依赖固定为
gmcrypto-core = "=1.11.0",使示例与本页证据快照对应同一版本。
要解决的问题
国密这几个算法,Rust 社区其实早有实现,写得好的那些,本来也都按常量时间设计——处理密钥的那段代码,跑多久跟密钥无关,免得别人靠测耗时反推出密钥。
难点在于,这种「常量时间」常常只在代码评审那一刻成立;之后随便一次重构、新加一条快路径、一次依赖升级,时序泄漏都可能悄悄溜回来,当场没人发现。这个库偏偏又卡在一堆容易让它松动的约束里:要逐字节对上 GB/T 标准、核心要能跑在 no_std / wasm 上、涉密路径在每次发版之间都得把关运行耗时不随密钥等秘密值变化的代码,这样攻击者就无法靠测量耗时来还原秘密。它是一个设计目标,而非保证——某些硬件仍可能泄漏。,还要通过一层 C ABI
把这些路径暴露出来,供 C、C++、Go、Zig、Python 调用。
所以它真正要回答的,不是「今天是不是常量时间」,而是「靠什么让每次提交之后它还是常量时间」。
约束与关键决策
持续验证,而不是一次性拍板。常量时间不该是评审会上签个字就算数的结论,而该是 CI 每次都重新核一遍的性质。所以每次 CI 都会用 dudect-bencher(时序侧信道检测工具)把 20 条涉密路径跑一遍;其中核心那一组带门禁,阈值是统计量 |τ| < 0.20——
一旦越界就直接让构建挂掉,不用等人去发现。要说清楚的是,它报的是「检测事件」:|τ| 低,只说明在当前的测量预算下没测到泄漏,并不能证明泄漏不存在(措辞沿用
dudect-bencher 自己的文档)。代价:每个
PR 都要付出这份测量开销;而且统计永远只能说「没测到」,证明不了「不存在」。
核心路径上门禁,其余先观察。不是每条路径都塞得进一个 PR 的时间预算,于是 harness 给它们分了层。
带门禁(不达标就让构建失败)的有 16 条:两条标量乘(定基与变基)、SM2 签名、解密与密钥交换、SM4 密钥扩展、SM4 加密(线性扫描和位切片 SIMD S-box 两条都算)、SM4-CTR、CBC 解密扇出(fanout)、单次 SM4-GCM / SM4-CCM 解密、缓冲式 SM4-GCM 解密、SM4-XTS 解密、加密 PKCS#8 解密,以及 v1.7 起新增的 TLCP CBC 记录层解保护(Lucky13 残余风险的约束)。
只做遥测的有 4 条:域求逆诊断、k-class 签名,以及 2026-06-17 起的
HMAC-SM3——挂在 nightly 上、用 0.55 的粗回归哨兵值监视,而不是 0.20 的门禁。共享 runner 上的类分割噪声让它们更紧的阈值频繁误报,所以逐个降级处理,理由也公开写明了。代价:遥测那部分只是监视、并不拦截,而且这套分层本身也得长期维护。
核心禁止 unsafe,SIMD 单独按需开。
gmcrypto-core 在 [lints] 表里禁掉了 unsafe
(unsafe_code = "forbid")——整个核心一行
unsafe 都不许有。可位切片
SIMD 的 S-box 偏偏离不开 unsafe,于是把它整段挪进单独的
gmcrypto-simd crate,用时再通过
sm4-bitsliced-simd 特性打开。代价:快路径不是默认值——你得自己去开 SIMD,否则开箱跑的是更慢的线性扫描 S-box。
算术按常量时间设计,但前提写在明处。涉密的大数运算搭在 subtle 和 crypto-bigint
之上,密钥材料一到 Drop 就由 zeroize 抹零。整体是奔着常量时间设计的,但有些地方确实做不到。代价:在那些「乘法耗时跟操作数有关」的 CPU 上(部分较老的 x86、部分嵌入式芯片),这些时序性质并不成立——这一条我明写在「它不是什么」里,没藏着掖着。
先发核心、再发 FFI——这一轮输出没变就不发版。每个密码模式都按「核心在 vN、FFI 在 vN+1」的节奏走:SM4-XTS 的核心在 v0.12、对应的 C ABI 在 v0.13;面向磁盘的多扇区原地(in-place)辅助函数在 v0.15、它的 FFI 在 v0.16;SM2 密钥交换在 v1.1、它的 C ABI 在 v1.2;X.509 与 SM2 的结合在 v1.3、它的 C ABI 在 v1.4。
要是某一轮算出来的结果一个字节都没变(比如 v0.14,那一轮只做了
cargo-fuzz 解析器模糊测试,零崩溃),就只作为保障性工作并入主干、不单独发版,所以 crates.io 上直接跳过了 0.14.0。同一条规矩后来放大到更大尺度:
0.17–0.23 这一段都没单独发版——主要是
v0.21–v0.23 的发版准备工作(冻结公开 API、把
crypto-bigint 从常开接口解耦、再做一轮多模型对抗式的 1.0 前复审)——
一起并进了刻意的 1.0.0 正式发布。代价:版本号因此留了个需要解释的缺口;而且多出一层 C ABI,每个模式都得在三个已发布的 crate 里测一遍、维护一遍,测试和维护面也随之翻倍。
证据
每一处输出都逐字节比对过:标准 KAT 测试向量、GmSSL 3.2.0
(互操作性——v1.10 起在 CI 里挂了任务,对照从源码构建的 oracle 跑)、以及 OpenSSL 3.x 的 SM4-XTS(可调模式,xts_standard=GB)。
上面那套泄漏回归门禁,每次提交的 CI 都会跑一遍;核心在 [lints]
表里禁掉了 unsafe(unsafe_code = "forbid")。v0.14 那一轮还加了一套
cargo-fuzz(libFuzzer)模糊测试,覆盖不可信输入上的解码 / 解密路径——
当时 16 个目标,到 v1.8 扩到 30 个,到 v1.11 是 33 个——挂在 nightly 上扫。
这些都能自己去核对:源码、CI 工作流、一种检测时序泄漏的统计方法:对两类输入分别计时,看两组耗时分布是否有差异。它在给定的测量预算下检测泄漏,但无法证明泄漏不存在。 harness 全是公开的;已发布的 gmcrypto-core crate、它的 docs.rs 文档、以及能跑的示例也都在。
其中三条性质,固定在 v1.11.0,每条都直接链到对应的源码行:
- 涉密数据的相等判断走库里的常量时间比较路径;链接源码能核对这一实现选择,下面的 dudect 结果仍是经验性证据,不是证明: cipher.rs:627 。
- 核心 crate 禁止 unsafe 代码: Cargo.toml:177 。
- 每个 PR 都跑 dudect 计时泄漏门禁: dudect-pr.yml:169 。
再补两条,专门针对「常量时间」这个说法——也是最容易嘴上说、最难证明的部分:
- 门禁不是摆设:专门放一个故意泄漏时序的
negative_control,dudect 必须把它揪出来,否则 CI 直接失败( timing_leaks.rs:167 )。 - 也说得老实:这套 harness 只能检测泄漏,并不能证明常量时间( README.md:76 )。
有四篇笔记展开证据边界:CI 门禁是怎么接线的、为什么值得展示的是负控制,而不是那排绿勾、把字节一致性当作互操作测试,以及把 unsafe 变成显式选择。
这张图把已发布测量值与当前策略放在一起:观测值来自一轮明确的运行,门槛与哨兵值是执行策略,不是证明。
dudect 报的是检测事件——|τ| 低只说明在给定预算下没测到泄漏,并不等于不存在。
| 目标 | 测的是什么 | |τ| | 门禁 | 状态 |
|---|---|---|---|---|
ct_sign |
SM2 签名,按私钥 d 分类 | 0.0044 | < 0.20 | 在门禁内 |
ct_sign_k_class |
SM2 签名,按随机数 k 的量级分类 | 0.0708 | < 0.55 (nightly 哨兵) | 在哨兵值内 |
ct_fn_invert |
Fn::invert 直接域求逆诊断 | 0.0071 | < 0.55 (nightly 哨兵) | 在哨兵值内 |
ct_fp_invert |
Fp::invert 直接域求逆诊断 | 0.0063 | < 0.55 (nightly 哨兵) | 在哨兵值内 |
negative_control |
negative_control——必须在这里故意失败(证明检测器不是瞎的) | > 1.0 | > 1.0 | 必须故意失败 |
crypto-bigint 0.6 的 ConstMontyForm::invert |
修复前(crypto-bigint 0.6) | ≈0.7 | < 0.20 | 超过门槛——已捕获 |
crypto-bigint 0.6 的 ConstMontyForm::invert |
修复后(升级到 0.7.3) | ≈0.006 | < 0.20 | 在门禁内 |
测量环境:v0.2 W0 harness,100K 采样(2026-05-12 之前的 runner)。k-class 签名和两个域求逆诊断后来转为遥测 + |τ| ≥ 0.55 的哨兵值。 来源:gm-crypto-rs SECURITY.md @ v1.2.0 ↗
版本线上有刻意留下的缺口:一轮没改任何输出字节的工作,不发版,直接合进主干。为什么,一篇笔记讲清。
- 当前版本
- v1.11.0,2026-08-01——SM4-GCM / SM4-CCM 的 RustCrypto
aead0.6 trait 适配(可选特性)。C ABI 仍是 104 个入口。 - 版本缺口
- 已发布的版本线上有四处缺口——v0.14、0.17–0.23、v1.5、v1.10——它们都没改动任何输出字节。
- 完整版本线
- 从 v0.6.0 起的每一轮 →
下一步
- 下一步
- v1.6 起步的 TLCP(GB/T 38636)路线已在 v1.9 收官;v1.11 又落地了长期搁置的 RustCrypto
aead0.6 trait 适配。目前没有下一条路线定到具体版本。继续搁置:AVX-512 的sbox_x64,以及流式 / 增量 CCM。另有一条是永久不做、而非暂缓:端点身份绑定——相当于 TLCP 里的主机名验证(hostname)——始终留给调用方。
它不是什么
- 不是 TLS / TLCP 协议栈——
tlcp这部分是一套原语工具集(密钥派生、记录层保护 / 解保护、链与证书对验证)。没有握手状态机、没有协商、也没有会话管理:协议要由调用方自己驱动,v1.9 的 C 示例就是这么做的。 - 不是端点身份认证——
verify_chain/verify_pair只建立证书结构上的信任。把验证过的证书链绑定到具体的对端身份(相当于 TLCP 里的主机名验证)永远是调用方的事。 - 不包括 SM9、ZUC、后量子算法。
- 不是 HSM / SDF / SKF 集成。
- 不是通过美国联邦信息处理标准(FIPS)或其他项目认证的密码模块。
- 在乘法耗时跟操作数有关的 CPU 上(部分较老的 x86、部分嵌入式),不是常量时间的。
个人项目。与 GmSSL、OpenSSL 做过逐字节一致性比对,但并不隶属于这两个项目,也未获它们或任何标准机构、厂商的背书或认证。