OpenSSL 中的 SLH-DSA(SLH-DSA)状态哈希签名实现设计详解
【免费下载链接】opensslGeneral purpose TLS and crypto library项目地址: https://gitcode.com/GitHub_Trending/ope/openssl
导读
SLH-DSA(Stateless Hash-Based Digital Signature Algorithm,无状态基于哈希的数字签名算法)是 NIST FIPS 205 标准定义的抗量子签名方案,其安全性仅依赖于哈希函数的抗碰撞/抗原像特性,不依赖任何数论困难问题,因此被视为 RSA/ECDSA 在量子时代的后继者之一。本文以 OpenSSL 仓库中的设计文档 doc/designs/slh-dsa.md 为核心骨架,结合 crypto/slh_dsa 目录下的真实源码实现,系统讲解 SLH-DSA 的 12 组参数集、密钥内部结构、哈希函数分层、Pure/Pre-Hash 两种消息编码方式、EVP 签名 API 使用方式以及常量时间安全考量,帮助读者从"会用"到"懂原理"完整掌握 OpenSSL 中 SLH-DSA 的实现全貌。
一、概述:FIPS 205 与 OpenSSL 的落地方式
FIPS 205(Stateless Hash-Based Digital Signature Standard)明确定义了 SLH-DSA 的绝大部分需求,并为全部算法提供了完整伪代码。因此,OpenSSL 在实现时并不需要重新发明算法细节,而是聚焦于三件事:如何在 OpenSSL 的 Provider/EVP 架构下组织 12 组参数、如何复用哈希基础设施(EVP_MD、EVP_MAC、SHAKE)避免代码重复、如何将签名/验签流程接入既有的 EVP API 与命令行工具。
在 OpenSSL 中,SLH-DSA 的实现代码集中在:
- 底层算法:crypto/slh_dsa(含 slh_wots.c、slh_xmss.c、slh_hypertree.c、slh_fors.c、slh_hash.c、slh_adrs.c、slh_params.c、slh_dsa_key.c、slh_dsa_hash_ctx.c 等)
- Provider 层:providers/implementations/signature/slh_dsa_sig.c 与 providers/implementations/keymgmt/slh_dsa_kmgmt.c
- 测试:test/slh_dsa_test.c、test/recipes/30-test_slh_dsa.t
下文所有代码细节均可在上述文件中找到对应实现。
二、SLH-DSA 参数与函数:12 组参数集的统一建模
2.1 十二组参数集与命名
FIPS 205 第 11 节定义了12 组参数集。OpenSSL 的 key manager 与签名函数也一一对应:12 个 key manager + 12 个签名函数,命名形如SLH-DSA-SHA2-128s、SLH-DSA-SHAKE-128f。后缀s(slow/small)代表签名更小但速度更慢,f(fast)代表速度更快但签名更大。
这 12 组参数在 crypto/slh_dsa/slh_params.c 中以静态表形式定义:
| 参数集 | n | h | d | h' | a | k | m | 安全类别 | 公钥字节数 | 签名字节数 |
|---|---|---|---|---|---|---|---|---|---|---|
| SLH-DSA-SHA2-128s | 16 | 63 | 7 | 9 | 12 | 14 | 30 | 1 | 32 | 7856 |
| SLH-DSA-SHAKE-128s | 16 | 63 | 7 | 9 | 12 | 14 | 30 | 1 | 32 | 7856 |
| SLH-DSA-SHA2-128f | 16 | 66 | 22 | 3 | 6 | 33 | 34 | 1 | 32 | 17088 |
| SLH-DSA-SHAKE-128f | 16 | 66 | 22 | 3 | 6 | 33 | 34 | 1 | 32 | 17088 |
| SLH-DSA-SHA2-192s | 24 | 63 | 7 | 9 | 14 | 17 | 39 | 3 | 48 | 16224 |
| SLH-DSA-SHAKE-192s | 24 | 63 | 7 | 9 | 14 | 17 | 39 | 3 | 48 | 16224 |
| SLH-DSA-SHA2-192f | 24 | 66 | 22 | 3 | 8 | 33 | 42 | 3 | 48 | 35664 |
| SLH-DSA-SHAKE-192f | 24 | 66 | 22 | 3 | 8 | 33 | 42 | 3 | 48 | 35664 |
| SLH-DSA-SHA2-256s | 32 | 64 | 8 | 8 | 14 | 22 | 47 | 5 | 64 | 29792 |
| SLH-DSA-SHAKE-256s | 32 | 64 | 8 | 8 | 14 | 22 | 47 | 5 | 64 | 29792 |
| SLH-DSA-SHA2-256f | 32 | 68 | 17 | 4 | 9 | 35 | 49 | 5 | 64 | 49856 |
| SLH-DSA-SHAKE-256f | 32 | 68 | 17 | 4 | 9 | 35 | 49 | 5 | 64 | 49856 |
表中 n 为哈希输出长度(16/24/32 字节);h 为整棵超树总高度;d 为树分层数;h' = h/d 为单棵 Merkle 树高度;a 为 FORS 树高度;k 为 FORS 树数量;m 为 H_MSG() 输出长度。
lgw(w=16,log₂w=4)对所有参数集相同,因此源码中直接省略。这些常量在 slh_params.h 的SLH_DSA_PARAMS结构体中统一定义。
2.2 7 个哈希函数与 SHA2/SHAKE 分支
设计文档指出,SLH-DSA 内部共涉及7 个哈希函数:
- SHAKE 系参数集:非常简单,全部复用SHAKE-256 这一种 XOF(即使名字里带 "SHAKE-128" 也是如此);
- SHA2 系参数集:复杂得多,需要HMAC、MGF1 以及摘要算法的组合,且存在两组不同的函数集合(第 1 组用于安全类别 1,第 2 组用于安全类别 3 与 5,区别在于 SHA-256 与 SHA-512 的选择以及补零位数的上界)。
SHA2 中补零位数上界在两个头文件中体现:
- 安全类别 1:
OSSL_SLH_DSA_SHA2_NUM_ZEROS_H_AND_T_BOUND1= 64(见 slh_params.h); - 安全类别 3/5:
OSSL_SLH_DSA_SHA2_NUM_ZEROS_H_AND_T_BOUND2= 128(见 slh_params.c)。
2.3 ADRS 对象:两种编码长度
部分哈希函数需要一个ADRS(Address)对象来标识其在状态树中的地址:SHAKE 算法为32 字节,SHA2 算法因采用压缩格式为22 字节,因此两者的 ADRS 构造/设置函数是不同的。这一差异在 slh_adrs.c 与 slh_dsa_key.h 中以SLH_ADRS_FUNC函数指针组的形式抽象,密钥对象中持有对应参数集的 ADRS 函数指针。
2.4 核心抽象:SLH_DSA_KEY 与 SLH_DSA_HASH_CTX
设计文档的核心结论是:与其把代码重复 12 遍,不如把差异收进数据对象。因此:
SLH_DSA_KEY(定义于 crypto/slh_dsa/slh_dsa_key.h)保存:哈希函数指针、ADRS 函数指针、参数常量(params),以及预取的算法对象(EVP_MD *md、EVP_MD *md_sha512、EVP_MAC *hmac)。SLH_DSA_HASH_CTX(定义于 crypto/slh_dsa/slh_dsa_local.h)引用 key,并包含每次操作各自的哈希上下文对象:底层 SHAKE 对象shactx、预哈希了 PK.seed 的shactx_pkseed、工作区scratch以及 SHA2 系需要的EVP_MAC_CTX *hmac_ctx(用于 PRFmsg)。中间哈希状态集中在同一对象中,释放时统一擦除。
该SLH_DSA_HASH_CTX被传给几乎所有底层函数(WOTS+、XMSS、超树、FORS),并在 Provider 的签名上下文(PROV_SLH_DSA_CTX)中分配。可见 providers/implementations/signature/slh_dsa_sig.c 的PROV_SLH_DSA_CTX结构,其中包含key(不拥有所有权)、hash_ctx、context_string、add_random、msg_encode、deterministic等字段。
三、SLH-DSA 密钥结构:公钥与私钥的存储布局
3.1 文档给出的设计结构
设计文档给出了密钥结构的核心设计:
struct slh_dsa_key_st { /* The public key consists of a SEED and ROOT values each of size |n| */ uint8_t pub[SLH_DSA_MAX_KEYLEN]; /* The private key consists of a SEED and PRF values of size |n| */ uint8_t priv[SLH_DSA_MAX_KEYLEN]; size_t key_len; /* This value is set to 2 * n if there is a public key */ /* contains the algorithm name and constants such as |n| */ const SLH_DSA_PARAMS *params; int has_priv; /* Set to 1 if there is a private key component */ ... };要点如下:
- 私钥与公钥各包含 2 个大小为 n 的元素:公钥 =
PK.seed || PK.root,私钥 =SK.seed || SK.prf; - 不同算法的 n 不同(16/24/32),因此代码统一使用最大尺寸的缓冲区(密钥每部分最多 64 字节),即
SLH_DSA_MAX_KEYLEN; key_len与has_priv两个字段用于判断密钥是否已加载公/私钥元素;params通过算法名解析得到参数集(ossl_slh_dsa_params_get(),见 slh_params.c)。
3.2 实际源码布局
实际实现(slh_dsa_key.h)对布局做了一点演进:私钥缓冲区扩大为uint8_t priv[4 * SLH_DSA_MAX_N],四个 n 大小的槽位依次存放SK.seed、SK.prf、PK.seed、PK.root,pub指针初始为 NULL,加载密钥后指向&priv[n * 2]。宏定义清晰表达了这一布局:
#define SLH_DSA_SK_SEED(key) ((key)->priv) #define SLH_DSA_SK_PRF(key) ((key)->priv + (key)->params->n) #define SLH_DSA_PK_SEED(key) ((key)->priv + (key)->params->n * 2) #define SLH_DSA_PK_ROOT(key) ((key)->priv + (key)->params->n * 3)3.3 与 FIPS 205 的一个关键差异
设计文档特别强调:FIPS 205 中 SLH-DSA 私钥内含公钥,而 OpenSSL 将两者分开存储,因此要构成有效密钥必须始终存在公钥。加载时若只有私钥而没有公钥,则密钥不完整——这与 X25519 等可从私钥派生出公钥的算法不同,SLH-DSA 的公钥 ROOT 分量并不能直接从私钥元素推出,编码私钥时也必须携带公钥。
3.4 密钥生成流程
密钥生成过程先用DRBG产生私钥以及半个公钥(PK.seed),再依据这些值计算出公钥的root分量。设计文档特别指出:ACVP 测试场景下这些值通过OSSL_SIGNATURE_PARAM_TEST_ENTROPY(即 ENTROPY 参数)外部注入;并假定从数据(fromdata)加载时不会出现"只有一半公钥"的情况,若确有此类需求应走密钥生成操作。该参数与其余可设参数一并定义在 providers/implementations/signature/slh_dsa_sig.inc.in:
['OSSL_SIGNATURE_PARAM_CONTEXT_STRING', 'context', 'octet_string'], ['OSSL_SIGNATURE_PARAM_TEST_ENTROPY', 'entropy', 'octet_string'], ['OSSL_SIGNATURE_PARAM_DETERMINISTIC', 'det', 'int'], ['OSSL_SIGNATURE_PARAM_MESSAGE_ENCODING', 'msgenc', 'int'],四、Pure 与 Pre-Hash 签名生成:两种消息编码
4.1 Pure SLH-DSA(默认)
普通签名流程称为Pure SLH-DSA Signature Generation,消息在内部编码为:
0x00 || len(ctx) || ctx || message其中ctx为可选的上下文串,长度为 0x00..0xFF。设计文档说明,ACVP 测试要求支持不对消息做任何编码的能力,这一开关由可设置参数OSSL_SIGNATURE_PARAM_MESSAGE_ENCODING(msgenc)控制。在 slh_dsa_sig.c 中可见两种模式常量:
#define SLH_DSA_MESSAGE_ENCODE_RAW 0 /* 不对消息编码 */ #define SLH_DSA_MESSAGE_ENCODE_PURE 1 /* 默认:Pure 编码 */Provider 新建签名上下文时默认设置为SLH_DSA_MESSAGE_ENCODE_PURE(见 slh_dsa_sig.c)。
4.2 Pre-Hash SLH-DSA(暂不支持)
Pre-Hash 变体的消息编码为:
0x01 || len(ctx) || ctx || digest_OID || H(message)设计文档指出,该场景的适用情形是"编码后的消息由外部来源提供"。但由于它与 OpenSSL 现有 EVP API 的契合度不高,当前版本尚未支持Pre-Hash 变体。这是文档明确声明的现状,读者不应将其误认为已实现的功能。
4.3 签名时的随机化与确定性
签名上下文还支持两类与消息编码正交的行为:
- 随机化签名:非确定性模式下,每次签名调用会通过
RAND_priv_bytes_ex()生成 n 字节的临时随机数(见 slh_dsa_sig.c),用完立即用OPENSSL_cleanse()擦除; - 确定性签名:通过
OSSL_SIGNATURE_PARAM_DETERMINISTIC开启; - 熵注入:
OSSL_SIGNATURE_PARAM_TEST_ENTROPY注入的add_random长度必须严格等于 n,否则报错(见 slh_dsa_sig.c),这正对应 ACVP 测试需求。
五、签名 API:EVP 层接入方式
5.1 推荐的 one-shot API
由于 SLH-DSA 只需 one-shot 实现、且消息不做摘要,设计文档指定使用:
EVP_PKEY_sign_message_init(), EVP_PKEY_sign(), EVP_PKEY_verify_message_init(), EVP_PKEY_verify().在 Provider 侧,这对应OSSL_FUNC_SIGNATURE_SIGN_MESSAGE_INIT / SIGN / VERIFY_MESSAGE_INIT / VERIFY四个 dispatch 函数,均已在 slh_dsa_sig.c 的MAKE_SIGNATURE_FUNCTIONS宏中为全部 12 个算法名注册。
5.2 兼容层:Digest 系列 API 的限制
出于向后兼容,EVP_DigestSignInit_ex()、EVP_DigestSign()、EVP_DigestVerifyInit_ex()、EVP_DigestVerify()也可使用,但传入的mdname必须为 NULL,此时行为与上面的 one-shot API 等价;传入非 NULL 摘要会直接报错。这一约束在 Provider 层有明确实现(slh_dsa_sig.c):
if (mdname != NULL && mdname[0] != '\0') { ERR_raise_data(ERR_LIB_PROV, PROV_R_INVALID_DIGEST, "Explicit digest not supported for SLH-DSA operations"); return 0; }5.3 两个必须满足的 getter 契约
设计文档要求两处 getter 配合:
- key manager getter中
OSSL_PKEY_PARAM_MANDATORY_DIGEST必须返回空串"",向调用方表明"无需(也不接受)摘要"; - signature context getter中
OSSL_SIGNATURE_PARAM_ALGORITHM_ID返回 DER 编码的 AlgorithmIdentifier。后者在 slh_dsa_sig.c 通过WPACKET调用ossl_DER_w_algorithmIdentifier_SLH_DSA()生成。
5.4 命令行支持
由于上述 API 接入,OpenSSL 命令行工具(如openssl pkeyutl、openssl req等)天然支持 SLH-DSA 算法名(如SLH-DSA-SHA2-128s、SLH-DSA-SHAKE-128f)。相关测试脚本 test/recipes/30-test_slh_dsa.t 与 test/recipes/30-test_evp.t 覆盖了密钥生成、DER 编解码与签名验签路径。
六、缓冲区使用约定:PACKET 与 WPACKET 的分工
SLH-DSA 内部有大量大小为 n(16/24/32 之一)的缓冲区,用于密钥元素与哈希值。设计文档明确规定:
- 这类固定小缓冲区不使用 PACKET,直接以裸缓冲区传递;
- 输出方向(如签名生成)使用WPACKET顺序写入;
- 输入方向(如读取签名数据)使用PACKET顺序读取。
这一约定在底层函数签名中清晰可见,例如 slh_dsa_local.h 中 WOTS+ 的签名与验签函数分别以WPACKET *sig_wpkt和PACKET *sig_rpkt作为签名缓冲区接口;超树(ossl_slh_ht_sign/ossl_slh_ht_verify)与 FORS(ossl_slh_fors_sign/ossl_slh_fors_pk_from_sig)同样遵循此约定。
七、常量时间考虑:哈希签名方案的天然优势
设计文档指出,由于SLH-DSA 的安全性只依赖哈希函数,不涉及任何依赖秘密数据的分支(如大整数取模中的条件减法),因此预期不存在常量时间泄露问题。
源码中的少量if语句仅用于检测哈希操作的失败,这些错误会沿函数调用栈逐层向上传播(相关函数均以__owur标注返回值必须被检查)。文档明确说明:这些错误在正常情况下不应发生,因此不会影响算法安全。
此外,slh_dsa_local.h 中的设计也体现了秘密数据的处理纪律:中间哈希状态(可能由秘密派生的中间值)集中在scratch工作区中,随SLH_DSA_HASH_CTX释放时统一擦除,避免秘密残留在栈上散落各处。
八、小结:一份可追踪的实现蓝图
将设计文档与源码对照,可以得出 OpenSSL 中 SLH-DSA 实现的完整蓝图:
- 12 组参数集收敛为一张静态参数表(slh_params.c),配合
n/h/d/h'/a/k/m等常量驱动全部算法; - 7 个哈希函数通过 SHAKE-256(SHAKE 系)与 HMAC/MGF1/摘要(SHA2 系)两条路径实现,差异由 slh_hash.c 中的
SLH_HASH_FUNC抽象; - ADRS 的 32/22 字节差异由
SLH_ADRS_FUNC函数指针组封装; - 密钥采用
priv[4n]单缓冲区布局 +pub指针,强制"有公钥才有效"的不变量; - 签名/验签通过 one-shot EVP API 接入,Digest 系列 API 仅作兼容且强制
mdname=NULL; - 缓冲输出用 WPACKET、输入用 PACKET,固定小缓冲区直传;
- 安全上不依赖常量时间技巧,仅需传播哈希失败错误并统一擦除中间状态。
以上每一层都可以在 crypto/slh_dsa 与 providers/implementations/signature/slh_dsa_sig.c 中找到对应实现,读者可沿此路径深入阅读,也可通过 test/slh_dsa_test.c 观察密钥生成、fromdata 加载与签名验签的完整调用示例。
【免费下载链接】opensslGeneral purpose TLS and crypto library项目地址: https://gitcode.com/GitHub_Trending/ope/openssl
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考