3. OpenSSL密码框架与PQC接口架构

要在 OpenSSL 中开发后量子密码(PQC)能力,首先必须理解 OpenSSL 3.x 的框架设计。与 1.1.1 及更早版本相比,3.x 最大的结构性变化是引入了 Provider(提供者)机制,把算法实现从核心库中剥离为可动态加载的模块。这一变化直接决定了 PQC 算法应当"放在哪里、如何注册、如何被调用"。

一、三层抽象:应用层、EVP 层、Provider 层

OpenSSL 3.x 的调用栈可以概括为三层。最上层是应用程序,它只与 EVP_* 系列接口打交道;中间是 EVP 抽象层,负责把"算法名称 + 属性"解析为具体的实现;最下层是 Provider,真正持有算法代码。应用代码不再直接调用 RSA_public_encrypt 这类算法专属函数,而是统一走 EVP_PKEY_encrypt 等通用接口。

这种分层带来的关键收益是:新增一种算法时,只需实现一个 Provider 并注册算法,无需改动 EVP 层和上层应用。对 PQC 而言,这意味着 ML-KEM、ML-DSA、SLH-DSA 等新算法可以以 Provider 形式接入,而不破坏既有生态。

应用层(Application) 调用 EVP_PKEY_encrypt / EVP_DigestSign / EVP_PKEY_derive 等通用接口 不感知具体算法实现,仅通过算法名与属性串选择 EVP 抽象层(EVP Interface) EVP_PKEY_CTX 算法上下文与参数容器 EVP_MD / EVP_CIPHER 摘要与对称算法抽象 OSSL_PARAM 参数传递与属性查询 Provider 层(算法实现) default provider RSA / EC / AES / SHA-2 传统算法实现 fips provider 经 FIPS 140-3 验证 受限算法集合 pqc provider(新增) ML-KEM / ML-DSA SLH-DSA 等 PQC 算法 EVP 调用 算法名 + 属性解析 算法注册流程: OSSL_provider_init → 提供 query_operation 回调 → 按算法名返回 OSSL_DISPATCH 函数表 → EVP 层按需调用

二、Provider 机制的核心:OSSL_DISPATCH 与算法注册

一个 Provider 本质上是一个动态库,导出名为 OSSL_provider_init 的入口函数。OpenSSL 加载该库时调用此函数,Provider 在其中向核心库返回一张 OSSL_DISPATCH 函数表。这张表把"操作名"映射到具体函数指针,例如 OSSL_OP_KEYMGMT 对应密钥管理、OSSL_OP_SIGNATURE 对应签名、OSSL_OP_KEM 对应密钥封装。

以 PQC 的 ML-KEM 为例,Provider 需要注册两类操作:OSSL_OP_KEYMGMT(负责密钥生成、导入导出、参数检查)和 OSSL_OP_KEM(负责封装与解封装)。EVP 层在收到 EVP_PKEY_CTX_new_from_name(NULL, "ML-KEM-768", NULL) 时,会遍历已加载的 Provider,按名称匹配并取回对应的 dispatch 表。

/* Provider 入口函数骨架(示意) */
static const OSSL_DISPATCH ml_kem_keymgmt_functions[] = {
    { OSSL_FUNC_KEYMGMT_NEW,        (void (*)(void))ml_kem_new },
    { OSSL_FUNC_KEYMGMT_GEN_INIT,   (void (*)(void))ml_kem_gen_init },
    { OSSL_FUNC_KEYMGMT_GEN,        (void (*)(void))ml_kem_gen },
    { OSSL_FUNC_KEYMGMT_FREE,       (void (*)(void))ml_kem_free },
    { 0, NULL }
};

static const OSSL_DISPATCH ml_kem_kem_functions[] = {
    { OSSL_FUNC_KEM_NEWCTX,         (void (*)(void))ml_kem_encaps_init },
    { OSSL_FUNC_KEM_ENCAPSULATE,    (void (*)(void))ml_kem_encapsulate },
    { OSSL_FUNC_KEM_DECAPSULATE,    (void (*)(void))ml_kem_decapsulate },
    { 0, NULL }
};

int OSSL_provider_init(const OSSL_CORE_HANDLE *handle,
                       const OSSL_DISPATCH *in,
                       const OSSL_DISPATCH **out,
                       void **provctx)
{
    /* 初始化上下文,返回本 Provider 的操作表 */
    *out = provider_dispatch_table;
    return 1;
}

注意 OSSL_DISPATCH 数组必须以 { 0, NULL } 结尾,这是 OpenSSL 识别表结束的约定。每个条目由功能编号(如 OSSL_FUNC_KEM_ENCAPSULATE)和函数指针组成,函数签名必须与头文件中声明的原型严格一致,否则会在运行时出现未定义行为。

三、EVP 接口抽象层:算法无关的调用方式

EVP 层的价值在于"一次编写,算法可换"。以密钥封装为例,无论底层是 RSA-KEM 还是 ML-KEM,应用侧代码结构完全一致:

EVP_PKEY_CTX *ctx = EVP_PKEY_CTX_new_from_name(NULL, "ML-KEM-768", NULL);
EVP_PKEY_keygen_init(ctx);
EVP_PKEY *pkey = NULL;
EVP_PKEY_keygen(ctx, &pkey);

/* 封装:生成共享密钥与密文 */
unsigned char *ct = NULL, *ss = NULL;
size_t ctlen = 0, sslen = 0;
EVP_PKEY_encapsulate_init(ctx, NULL);
EVP_PKEY_encapsulate(ctx, NULL, &ctlen, NULL, &sslen);   /* 先查询长度 */
ct = OPENSSL_malloc(ctlen);
ss = OPENSSL_malloc(sslen);
EVP_PKEY_encapsulate(ctx, ct, &ctlen, ss, &sslen);

这里的关键是"两次调用"模式:第一次传 NULL 缓冲区获取所需长度,第二次才真正写入。这是 EVP 接口处理变长输出的通用约定,PQC 的密文和共享密钥长度因算法而异(例如 ML-KEM-768 的密文为 1088 字节),必须遵循此模式,不能硬编码缓冲区大小。

四、PQC 在 OpenSSL 中的代码组织与调用链

在 OpenSSL 源码树中,PQC 相关代码通常组织在 providers/ 目录下独立的子目录中,例如 providers/pqc/,内部再按算法族拆分:ml_kem/、ml_dsa/、slh_dsa/。每个算法目录包含密钥管理实现、操作实现和 dispatch 表定义;公共的编码、参数解析逻辑放在 providers/common/ 下复用。

完整的调用链可以这样追踪:应用调用 EVP_PKEY_encapsulate → EVP 层通过 EVP_PKEY_CTX 找到已绑定的 Provider 与算法 → 查 OSSL_DISPATCH 表定位 OSSL_FUNC_KEM_ENCAPSULATE → 进入 Provider 的 ml_kem_encapsulate → 调用底层 PQC 实现库(如 liboqs 或自研实现)完成封装运算 → 结果经 EVP 层返回应用。理解这条链路,是定位 PQC 接口问题的基础:若 EVP_PKEY_CTX_new_from_name 返回 NULL,问题通常在 Provider 未加载或算法名不匹配;若封装阶段失败,则应检查 dispatch 表注册与函数签名。

调试建议:设置环境变量 OPENSSL_MODULES 指向自定义 Provider 所在目录,并用 openssl list -providers -verbose 确认 Provider 是否成功加载、算法是否出现在列表中。这是排查"算法找不到"类问题的第一步。

五、属性查询与算法选择

EVP 层选择算法时不只看名称,还看属性(property)。属性串如 "provider=pqc" 或 "fips=yes" 用于在多个 Provider 提供同名算法时做筛选。PQC 开发中常见的做法是给算法打上自定义属性,便于应用显式指定使用后量子实现,避免与传统算法混淆。属性查询通过 OSSL_PARAM 数组传递,Provider 在 query_operation 回调中声明自己支持的属性,EVP 层据此完成匹配。