OpenSSL XOF 可扩展输出函数设计解析:从单次 squeeze 到 EVP_DigestSqueeze 多段输出
【免费下载链接】opensslGeneral purpose TLS and crypto library项目地址: https://gitcode.com/GitHub_Trending/ope/openssl
导读
本文基于 OpenSSL 仓库中的 XOF 设计文档,系统讲解可扩展输出函数(Extendable Output Function,XOF)在 OpenSSL 中的演进过程:从最初只支持"一次 squeeze 拿到全部输出",到通过新增EVP_DigestSqueeze()API 支持任意多次分段输出。你将看到海绵构造(Sponge Construction)中 absorb / squeeze 的语义、EVP 层的 API 设计权衡(三套候选方案对比)、底层SHA3_squeeze()的四种改造思路,以及这些设计在 crypto/evp/digest.c、crypto/sha/sha3.c、crypto/sha/keccak1600.c 等源码中的真实落地形态,可直接用于理解与使用 SHAKE、cSHAKE 等 XOF 算法。
一、XOF 的定义与最小接口契约
1.1 什么是 XOF
可扩展输出函数(XOF)被定义为:作用在消息上的可变长度哈希函数,其输出可以被扩展到任意期望的长度。与固定输出的 SHA-2、SHA-3 不同,XOF 的输出长度不再受摘要长度限制,调用方可以按需"挤"出任意多的字节。
OpenSSL 目前涉及的 XOF 包括:
- SHAKE128 / SHAKE256(FIPS 202),通过 providers/implementations/digests/sha3_prov.c 中
IMPLEMENT_SHAKE_functions(128/256)注册; - cSHAKE 家族(NIST SP 800-185),以
cshake_keccak_128/256的形式注册,是 KMAC、TupleHash 等算法的底层; - 以及同样采用海绵结构的 Keccak 变体。
1.2 最小接口伪代码
设计文档给出一个 XOF 至少需要支持的调用序列:
xof = xof.new(); xof.absorb(bytes1); xof.absorb(bytes2); xof.finalize(); out1 = xof.squeeze(10); out2 = xof.squeeze(1000);这段伪代码定义了三个核心动词:absorb(吸收输入)、finalize(结束吸收)与squeeze(挤压输出),且输出可以分多次、按不同长度取出。
1.3 四条基本规则
文档为上述接口明确了四条约束:
- absorb 可以被多次调用:输入数据不必一次性全部喂入;
- finalize 结束 absorb 过程:其本质是补上填充字节(padding)并做最后一次 absorb;一旦 finalize 完成,除非发生 reset,否则不得再调用 absorb;
- finalize 可以合并到第一次 squeeze 操作中完成:这意味着"显式 finalize"并非强制要求;
- squeeze 可以被多次调用:这正是本文讨论的核心设计难点。
二、OpenSSL 的现状:只支持单次 squeeze
设计文档明确指出:OpenSSL 当前(设计时)的 XOF 实现只支持对 squeeze 的单一调用。这个假设同时存在于两个层面:
- 高层 API:
EVP_DigestFinalXOF(); - 底层运算:
SHA3_squeeze()(存在通用 C 实现,以及面向不同平台的手写汇编版本,见 crypto/sha/asm/ 目录下十余个keccak1600-*.pl文件)。
该现状带来的直接后果是:调用方无法像标准海绵模型那样"边用边挤、按需取用",必须在一次调用中指定完整的输出长度。
2.1 高层 API 的单次限制从何而来
查看 crypto/evp/digest.c 中EVP_DigestFinalXOF()的实现,可以看到它通过一个标志位来约束调用次数:
- 若上下文中已置位
EVP_MD_CTX_FLAG_FINALISED(定义于 include/crypto/evp.h,值为0x0800),则直接报EVP_R_FINAL_ERROR; - 正常路径下,它会通过
OSSL_PARAM_construct_size_t(OSSL_DIGEST_PARAM_XOFLEN, &size)把期望的输出长度以参数形式传给 provider,再调用ctx->digest->dfinal()一次性产出全部输出,最后置位EVP_MD_CTX_FLAG_FINALISED。
源码注释还透露了一段历史:该函数最初写好后曾附带一个 reset 动作,但后来被认为不正确而移除。这也解释了为什么它被明确定义为one shot operation(一次性操作)。
2.2 底层 squeeze 的单次限制
通用 C 版本的SHA3_squeeze()位于 crypto/sha/keccak1600.c:
void SHA3_squeeze(uint64_t A[5][5], unsigned char *out, size_t len, size_t r, int next)其设计缺陷在于:除非请求的输出长度是r(速率 rate,即每轮 Keccak-f[1600] 之后可输出的字节数)的整数倍,否则函数没有能力记录"当前处于状态 A 中的哪个位置"——下次再调用时无从接续。同时,由于最初只服务于一次性输出场景,函数末尾刻意避免了多做一次KeccakF1600()置换。
2.3 设计时需要权衡的约束
文档列出设计变更必须满足的三点:
- 是否需要一个新 API,以及变更对既有应用的影响面;
- 变更对共享同一底层代码的其他函数(例如 SHAKE 与 SHA3 共用海绵核心)影响应尽量小;
- 旧 provider 兼容性:未更新支持该变更的旧 provider,在使用新 core 发起多次 squeeze 时应返回错误,而不是静默给出错误数据。
三、API 层设计讨论:三套候选方案
围绕"如何让 squeeze 支持多次调用",文档给出了三套 API 层方案,并逐一分析利弊。
3.1 Proposal 1:改造 EVP_DigestFinalXOF() 支持多次调用
直接修改EVP_DigestFinalXOF(ctx, out, outlen),使其可被多次调用,甚至可以提供一个别名EVP_DigestSqueeze()。核心改动只是删除那个标志位检查。
- 优点:不需要新增 API。
- 缺点:"Final(最终/结束)"这个语义名字被多次调用,显得很奇怪——命名与实际行为相悖。
3.2 Proposal 2(最终采纳):保留一次性函数,新增 EVP_DigestSqueeze()
保持EVP_DigestFinalXOF()为一次性函数不变,新增专门处理多段输出的 API:
EVP_DigestSqueeze(ctx, out, outlen)- 优点:
- 名字更贴切(squeeze 正是海绵模型的术语);
- 既有函数完全不受影响,多段输出所需的特殊逻辑不需要侵入旧代码路径;
- 既有 API 行为保持一致,向后兼容;
- 文档提到至少一个其他工具库采用了同样的"保留 Final + 新增 Squeeze"策略。
- 缺点:
- 多了一个 API;
- 两个 API 之间的交互关系必须被明确文档化;
- 交互约束是互斥的:
EVP_DigestFinalXOF()之后再调用EVP_DigestSqueeze()会失败(因为 Final 已声明"不再有输出可取");反之,先EVP_DigestSqueeze()再调用EVP_DigestFinalXOF()同样失败。
3.3 Proposal 3:创建全新类型 EVP_XOF_MD
彻底引入一个独立的EVP_XOF_MD类型,其接口主要由 Init、Absorb、Squeeze 组成,并可将DigestXOF标记为废弃。
- 优点:XOF 操作被完全独立出来,接口语义纯净。
- 缺点:
- 后量子(Post-Quantum)签名目前依赖
EVP_MD对象来承载 XOF,引入新类型会连带复杂化签名 API——这是否决此方案的现实原因; - 会造成
EVP_MD代码的重复(尽管届时 legacy/engine 代码已全部移除)。
- 后量子(Post-Quantum)签名目前依赖
3.4 最终选择的落地证据
Proposal 2 在仓库中得到了完整落地:
- 声明:include/openssl/evp.h 中
EVP_DigestFinalXOF()与EVP_DigestSqueeze()相邻声明; - 实现:crypto/evp/digest.c。
EVP_DigestSqueeze()刻意不做 FINALISED 标志检查,可多次调用;其关键分支是:
if (ctx->digest->dsqueeze == NULL) { ERR_raise(ERR_LIB_EVP, EVP_R_METHOD_NOT_SUPPORTED); return 0; } return ctx->digest->dsqueeze(ctx->algctx, md, &size, size);这正是文档中"旧 provider 应产生错误"要求的实现——provider 通过提供dsqueeze函数指针来表示自己支持多段输出;不支持的旧 provider 返回EVP_R_METHOD_NOT_SUPPORTED。EVP_DigestFinalXOF()中0x0800标志位检查依旧保留,两种 API 的互斥约束与 Proposal 2 描述完全一致。
四、API 命名之争:Squeeze 还是 Extract?
文档单列一节讨论新 API 的命名。背景是:目前 OpenSSL 使用的 XOF 都是海绵构造(sponge construction),其术语天然就是 absorb 与 squeeze;但未来会出现非海绵构造的 XOF,例如 BLAKE2。
候选名有两个:
EVP_DigestSqueeze—— 与海绵模型术语一致,最终选定;EVP_DigestExtract—— 被否决,理由是extract 与 expand 是 HKDF 的既有术语(HKDF-Extract / HKDF-Expand),沿用会造成概念混淆。
五、围绕 XOF 的其余 API 设计
除 squeeze 之外,文档还明确了 Init、Absorb、Finalize、Reset、状态拷贝五类操作的形态,均与海绵模型一一对应。
5.1 Init:普通初始化
XOF 摘要的初始化与普通摘要完全相同,文档给出 SHAKE256 的示例:
md = EVP_MD_fetch(libctx, "SHAKE256", propq); ctx = EVP_MD_CTX_new(); EVP_DigestInit_ex2(ctx, md, NULL);5.2 Absorb:多次 EVP_DigestUpdate
吸收阶段通过多次EVP_DigestUpdate(ctx, in, inlen)完成。文档记录了一个讨论过的提案:是否提供别名函数
EVP_DigestAbsorb(ctx, in, inlen);共识是不需要——直接复用EVP_DigestUpdate即可,语义已经足够清晰。
5.3 Finalize:并入第一次 squeeze
无需显式 finalize,填充与最终 absorb 被合并到第一次 squeeze 操作中完成(对应规则第 3 条)。
5.4 Reset:重新初始化
重置上下文只需再次调用:
EVP_DigestInit_ex2(ctx, NULL, NULL);底层对应 crypto/sha/sha3.c 的ossl_sha3_reset():清零状态矩阵A、清空缓冲计数bufsz,并将xof_state置回XOF_STATE_INIT。
5.5 State Copy:状态拷贝
海绵状态可以整体复制,以便在同一份输入上派生多条独立输出流:
EVP_MD_CTX_copy_ex(ctx, newctx);这在确定性派生(如从同一消息生成多个独立密钥/随机流)场景下非常实用。provider 层的对应实现是 sha3_prov.c 的keccak_dupctx(),直接按字节拷贝整个KECCAK1600_CTX。
六、底层 squeeze 改造:四种方案的技术权衡
解决了 API 层之后,更关键的是底层SHA3_squeeze()如何在多次调用时保持正确性。文档给出四种方案,它们的取舍最终塑造了今天的实现。
6.1 方案一:为状态 A 增加位置追踪参数
修改SHA3_squeeze(),增加一个输入/输出参数用于记录状态 A 内部的当前读取位置。
- 优点:C 代码改动最小(仅多传一个参数),且没有额外的缓冲结果内存拷贝;
- 缺点:
- 参考 C 实现中包含大量
if分支,逻辑复杂; - 该逻辑还必须在每种汇编实现中重写,而不同架构下状态 A 的内部格式(如比特去交错 BitDeinterleave 的处理)各不相同,汇编改动量大且彼此不同;
- 通用 SHA3 路径若不复制代码会变慢。
- 参考 C 实现中包含大量
6.2 方案二:保留 SHA3_squeeze(),在 final 层缓冲调用
不修改SHA3_squeeze()本体,在更高层(final)把多次输出请求缓冲拼接。
- 优点:改动主要集中在 C 代码;
- 缺点:
- 由于
SHA3_squeeze()的一次性本质,缓冲层仍需直接调用KeccakF1600(); - 这要求把汇编版
KeccakF1600()暴露为公共接口——而它本不打算暴露,因为状态 A 的内部格式在不同平台架构上可能不同; - 内部缓冲区的清理时机(何时清空)难以界定。
- 由于
6.3 方案三:一次性挤出全部输出再丢弃前缀
对原始吸收数据执行一次完整 squeeze,然后丢弃输出缓冲的前半部分。
- 优点:实现最简单;
- 缺点:极度低效——每次小请求都要重算整个海绵,且本质上只是权宜之计(hack),并非真正的解决方案。
6.4 方案四(最终采纳):给 SHA3_squeeze() 增加布尔参数
在方案二思路上修正:轻微修改SHA3_squeeze(),传入一个布尔值,用于在多段调用时正确控制KeccakF1600()的触发时机。
- 优点:
- C 实现相当简单;
- 状态数据继续作为不透明 blob(opaque)保存,无需暴露内部格式;
- 当
outlen较大时,SHA3_squeeze()可以直接使用调用方的输出缓冲,减少中间拷贝。
- 缺点:
- 需要小幅修改汇编代码,以传递该布尔值并正确处理
KeccakF1600()调用; - 对不足一个块(
r字节)的"零头"输出,需要使用memcpy将部分结果暂存于缓冲。
- 需要小幅修改汇编代码,以传递该布尔值并正确处理
七、最终实现:两层配合如何落地
7.1 底层:next 参数与状态机
方案四的产物就是 crypto/sha/keccak1600.c 中带注释的SHA3_squeeze():
/* * SHA3_squeeze may be called after SHA3_absorb to generate |out| hash value of * |len| bytes. * If multiple SHA3_squeeze calls are required the output length |len| must be a * multiple of the blocksize, with |next| being 0 on the first call and 1 on * subsequent calls. It is the callers responsibility to buffer the results. * When only a single call to SHA3_squeeze() is required, |len| can be any size * and |next| must be 0. */要点:
- 多次调用时,
len必须是块大小的整数倍,next首次为 0、后续为 1; - 调用方(更高层)负责缓冲不足一个块的剩余字节;
- 核心逻辑
if (next) KeccakF1600(A);解决了"多次调用之间需要推进海绵置换"的问题——这正是文档方案四描述的布尔参数。
7.2 中层:C 通用版的多段 squeeze 缓冲策略
crypto/sha/sha3.c 的ossl_shake_squeeze_default()实现了"缓冲 + 分块"策略,其注释明确说明思路:不去大改SHA3_squeeze()的汇编,而是利用其既有约束——每次只请求块大小的整数倍;小于一个块的请求则申请一个块并缓存剩余部分。其执行步骤为:
- 首次调用时完成吸收收尾:补 10*1 填充(
ctx->buf[num] = ctx->pad; ctx->buf[bsz - 1] |= 0x80;),执行最后一次SHA3_absorb(),并将next置 0; - 消耗上次 squeeze 缓存的零头字节(
ctx->buf尾部); - 整块部分直接写入输出缓冲:
SHA3_squeeze(ctx->A, out, len, bsz, next); - 零头部分:再多 squeeze 一个块进内部缓冲,拷出所需字节,剩余部分留待下次(
ctx->bufsz = bsz - outlen)。
这一设计完美呼应了文档方案二"缓冲调用"与方案四"布尔参数"的结合——C 层缓冲 + 底层布尔参数,两层各司其职。
7.3 状态机:XOF_STATE 四态流转
整个流程由 include/internal/sha3.h 定义的四个状态驱动:
#define XOF_STATE_INIT 0 #define XOF_STATE_ABSORB 1 #define XOF_STATE_FINAL 2 #define XOF_STATE_SQUEEZE 3ossl_sha3_absorb()仅在 INIT 或 ABSORB 态接受输入,吸收满块后转入 ABSORB;ossl_sha3_final()在 FINAL 或 SQUEEZE 态拒绝调用;ossl_sha3_squeeze()在 FINAL 态拒绝调用,成功 squeeze 后置为 SQUEEZE 态。
状态机保证了文档四条规则在底层被强制执行:finalize 后不可 absorb、squeeze 可重复但不可回退。
7.4 provider 层:dsqueeze 分发
sha3_prov.c 的shake_squeeze()是 provider 侧的处理函数,通过OSSL_FUNC_DIGEST_SQUEEZE分发项(见PROV_FUNC_SHAKE_DIGEST宏,注册于同一文件的 490-500 行)接入EVP_DigestSqueeze()的dsqueeze调用链。注意其执行前提if (ctx->meth.squeeze == NULL) return 0;——平台方法表(PROV_SHA3_METHOD)未提供 squeeze 能力的算法(如普通 SHA3-256)在这里直接拒绝。
值得注意的还有平台差异:SHAKE 的 provider 实现在 sha3_prov.c(S390X 路径)与 crypto/sha/sha3.c(S390X 路径)各有一份shake_squeeze_s390x,它们直接借助 s390x 的klmd指令完成多段输出;x86_64、ARM 等平台则走通用 C 缓冲路径或各自的SHA3_squeeze汇编(如 keccak1600-x86_64.pl 中带next参数的SHA3_squeeze符号)。这正是文档反复强调的"不同架构汇编格式不同"的真实写照。
八、测试与验证
仓库测试层面对该设计有直接覆盖:
- test/evp_test.c 支持以
XOF = yes关键字声明测试向量,通过EVP_DigestFinalXOF()校验; - 同一文件 test/evp_test.c 中通过
OSSL_PARAM_construct_size_t(OSSL_DIGEST_PARAM_XOFLEN, ...)构造参数,印证了EVP_DigestFinalXOF()用参数传递输出长度的实现细节; - test/evp_test.c 对
EVP_DigestFinalXOF()输出与预期向量逐字节比对,任何多段 squeeze 引发的状态机错误都会在这里暴露。
结合 doc/man7/EVP_DigestInit.pod 与 doc/man3/EVP_DigestFinalXOF.pod 等手册页,读者可进一步获得完整的参数表(如OSSL_DIGEST_PARAM_XOFLEN)与调用约束说明。
九、设计决策小结
| 决策点 | 结论 | 落地位置 |
|---|---|---|
| API 方案 | 保留一次性EVP_DigestFinalXOF(),新增EVP_DigestSqueeze() | crypto/evp/digest.c |
| API 命名 | Squeeze而非Extract(避开 HKDF 术语冲突) | include/openssl/evp.h |
| Absorb 别名 | 不新增EVP_DigestAbsorb(),沿用EVP_DigestUpdate | crypto/evp/digest.c |
| 底层方案 | SHA3_squeeze()增加next布尔参数 + C 层块缓冲 | crypto/sha/keccak1600.c、crypto/sha/sha3.c |
| 旧 provider 兼容 | 未提供dsqueeze的 provider 返回EVP_R_METHOD_NOT_SUPPORTED | crypto/evp/digest.c |
| 状态约束 | 四态状态机强制 absorb/finalize/squeeze 时序 | include/internal/sha3.h |
这套设计的关键启示在于:对外 API 的语义纯净与向后兼容,通过对内增加一个布尔参数和一层缓冲逻辑实现——既没有破坏既有一次性调用者的行为,也没有暴露平台相关的海绵状态内部格式,同时为未来非海绵构造的 XOF(如 BLAKE2)预留了命名空间。对于需要使用 SHAKE 派生任意长度密钥流、或者在后量子签名方案中按需挤压输出字节的开发者,EVP_DigestSqueeze()与配套的状态拷贝、重置 API 构成了一个完整且可组合的编程模型。
【免费下载链接】opensslGeneral purpose TLS and crypto library项目地址: https://gitcode.com/GitHub_Trending/ope/openssl
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考