ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

OpenSSL Engine 机制解析:加密卡适配与摘要算法接入实战

OpenSSL Engine 机制解析:加密卡适配与摘要算法接入实战

简介:OPENSSL_ENGINE机制面向需要对接第三方软件密码库或硬件加密设备的OpenSSL开发者与安全工程师,重点解释Engine作为通用加密接口的定位、演进和实现方式,帮助读者理解应用如何透明调用外部加解密能力。资源包为1个doc文档,约44KB,内容精炼,适合作为Engine机制入门与二次开发查阅材料。文档从OpenSSL项目与Engine概念切入,说明其自0.9.6引入、0.9.7并入内核的版本变化,并梳理CryptoSwift、nCipher、Atalla、Nuron、UBSEC、Aep、SureWare、IBM 4758 CCA等内嵌设备支持,以及PKCS#11、CryptoAPI等扩展方向。实现部分围绕动态库加载、ENGINE_load_XXXX加载流程、ENGINE_set_xxx注册接口与加密函数指针替换展开,并结合rtl8651b示例说明名称、密码算法及控制函数的设置过程。已有1221人学习。读者可从中获得Engine架构脉络、内置引擎清单、加载注册函数的作用关系及示例代码拆解思路,便于定位加载失败、算法未生效等常见问题。

1. 从一次加密卡适配说起:OpenSSL Engine 换掉的是算法实现,不是调用接口

给某硬件厂商的加密卡做适配那次,最反直觉的一点是:业务代码一行没改,EVP_DigestUpdate还是原样调用,底下的摘要运算已经悄悄跑到了板卡上,吞吐翻了一倍,而多出来的动作不过是启动时调了一句ENGINE_set_default。这就是 OpenSSL Engine 机制在做的事——它在 0.9.6 版本以一个独立分支的形态出现,到 0.9.7 直接并进内核,目标只有一个:让 OpenSSL 能透明地使用第三方提供的软件加密库或者硬件加密设备,上层调用完全无感。它想解决的问题和 Windows 的 CSP 是同一类——把「用哪份算法实现」从库里抽出来,变成可插拔的,OpenSSL 也因此从一个加密库变成了一个通用的加密接口。适合做加密设备适配、自研算法接入、被某块加速卡绑住的开发者;只调 API 写业务的,可以先跳过。

2. ENGINE 骨架拆解:crypto/engine 目录里的结构、函数指针与注册表

2.1 一个 ENGINE 对象里到底装了什么

源码入口在openssl/crypto/engine/,核心声明在engine.h的struct engine_st里。把字段按用途归类,其实只有五组:身份(id、name)、算法方法表(RSA_METHOD、DSA_METHOD、DH_METHOD、RAND_METHOD等结构体指针)、动态取算法的回调(ciphers、digests两个函数指针)、生命周期(init、finish、destroy)、控制面(ctrl、cmd_defns、flags)。理解这五组划分,后面所有ENGINE_set_xxx就不会记混——每个 setter 只是往其中一组里塞一个指针。

这也是为什么原文说「具体的 RSA、DSA、DH、RAND、CIPHERS、DIGESTS 算法实现主要是这些函数实现的」:OpenSSL 本身不认识你的硬件,它只认识这些函数指针的结构。你把指针填对,它就认为你是一个合格的引擎。

setter 函数作用什么时候必须给
ENGINE_set_id/ENGINE_set_name引擎的简要名称与可读名必填,ENGINE_by_id靠 id 查找
ENGINE_set_RSA/_DSA/_DH/_RAND挂整套方法结构体走方法表替换路线时
ENGINE_set_ciphers/_digests挂按 NID 查算法的回调走按需分发路线时
ENGINE_set_init_function/_finish_function/_destroy_function生命周期钩子需要开关设备、申请会话时
ENGINE_set_ctrl_function/_cmd_defns控制命令与命令表需要运行时配置参数时
ENGINE_set_load_privkey_function/_load_pubkey_function从引擎侧加载密钥私钥存在板卡里、取不出来时

ENGINE_set_RSA和ENGINE_set_ciphers是两条不同的接入路线:前者一次性把整套 RSA 方法替换掉,粒度粗但改得少;后者按 NID 逐个回答「这个算法你有吗」,粒度细,适合只加速某一两种算法。我一般优先选后者,因为硬件卡往往只覆盖部分算法,方法表全替换等于把没实现的算法也接管了,很容易在后面翻车。

2.2 加载一个引擎的标准动作:new、set、add、free 四步

原文给了一个ENGINE_load_rtl8651b的样例,把这段抽象出来就是所有引擎加载函数共用的骨架。下面用一个虚构的demohsm引擎重写一遍,逻辑和原文完全一致,只是去掉了具体厂商痕迹:

/* 每个 ENGINE_load_xxx 都是一个独立的加载入口,由应用或配置显式调用 */ void ENGINE_load_demohsm(void) { ENGINE *e = ENGINE_new(); /* 分配一个空引擎,字段全 NULL */ if (e == NULL) return; /* 第二段:把身份和各类函数指针一次性填进去,任何一个失败都要回滚 */ if (!ENGINE_set_id(e, "demohsm") || !ENGINE_set_name(e, "Demo HSM engine") || !ENGINE_set_digests(e, demohsm_digests) || /* 摘要算法按 NID 分发 */ !ENGINE_set_ciphers(e, demohsm_ciphers) || /* 对称算法按 NID 分发 */ !ENGINE_set_ctrl_function(e, demohsm_ctrl) || /* 运行时控制命令 */ !ENGINE_set_cmd_defns(e, demohsm_cmd_defns) || /* 控制命令表 */ !ENGINE_set_init_function(e, demohsm_init) || !ENGINE_set_finish_function(e, demohsm_finish)) { ENGINE_free(e); /* 填错就释放,别泄漏 */ return; } /* 第三段:先入表,再决定要不要抢默认实现,顺序别反 */ if (!ENGINE_add(e)) { ENGINE_free(e); return; } ENGINE_set_default(e, ENGINE_METHOD_DIGESTS); ENGINE_free(e); /* 引用计数减一,表里还留着一份 */ ERR_clear_error(); /* 清掉探测过程中的噪声错误 */ }

这段代码有三个参数值得单独说。ENGINE_set_id的字符串是大小写敏感的,惯例全小写,因为它会出现在命令行、配置文件的engine_id字段以及ENGINE_by_id的入参里,写成DemoHSM和demohsm是两个不同的引擎。ENGINE_add是把引擎挂进全局链表,挂进去之后ENGINE_by_id才找得到它,这一步失败通常意味着同 id 的引擎已经注册过了。最后的ENGINE_free不是销毁,而是把ENGINE_new带来的那一份引用还回去,引擎本体由全局链表持有;漏掉这一句就是典型的内存泄漏,多调一次则是提前析构。

原文里那段if(!ENGINE_set_default(engine, ENGINE_METHOD_DIGESTS)) ENGINE_add(engine);我建议改掉。它的语义是「设为默认失败才入表」,可读性差,而且一旦set_default成功就跳过add,引擎在表里反而查不到,命令行openssl engine -t也看不见。稳妥的写法就是上面这样:无论如何先进表,再对需要接管的算法类别调set_default,两者的成败分开判断、分别打日志。

2.3 引擎的「找回」机制:ENGINE_by_id、ENGINE_init 与引用计数

引擎注册完之后,应用侧并不会直接拿着ENGINE *到处传,标准姿势是按 id 找回来:

ENGINE *e = ENGINE_by_id("demohsm"); /* 从全局链表里按 id 查 */ if (e == NULL) { /* 找不到:要么没调 ENGINE_load_xxx,要么 id 拼错,要么动态库没被扫到 */ ERR_print_errors_fp(stderr); return -1; } if (!ENGINE_init(e)) { /* 功能引用计数 0 -> 1,触发 init 回调 */ ENGINE_free(e); /* 找回来了但设备打不开,同样要还引用 */ return -1; } /* ... 在这里使用引擎 ... */ ENGINE_finish(e); /* 功能引用计数 -1,归零时触发 finish 回调 */ ENGINE_free(e); /* 还掉 by_id 拿到的那一份结构引用 */

这里有两套计数,特别容易搞混。ENGINE_free管的是结构体本身,ENGINE_new加一、ENGINE_by_id加一;ENGINE_init/ENGINE_finish管的是「功能是否可用」,计数从 0 变 1 的那一刻才真正调用init回调去打开设备、申请会话。一个引擎可以被多个线程同时ENGINE_init,计数到 N,最后一个ENGINE_finish才关设备——这也是硬件设备适配里必须靠它来避免重复打开的原因。

除了按 id 精确查找,还有批量入口:ENGINE_get_first/ENGINE_get_next遍历注册表,ENGINE_register_all_complete()把所有已加载引擎支持的全部算法一次性挂到 EVP 的默认实现上。后一个函数在调试时很方便,在生产里我一般不用,因为它把「哪个引擎接管了哪个算法」这件事变得不可控,出了问题是真没日志可查。

3. 生效链路:ENGINE_set_default 怎么把 EVP 的算法指针换掉

3.1 EVP 层与 ENGINE 的连接点:藏在 EVP_MD 里的那个 engine 字段

加载只是把引擎放进了注册表,真正让业务代码「无感切换」的是 EVP 层。EVP_DigestInit_ex、EVP_EncryptInit_ex这些函数都有两个版本:一个内部自己查表,一个允许多传一个ENGINE *参数强制指定。前者走的就是引擎注册表。

EVP_MD_CTX *ctx = EVP_MD_CTX_new(); /* 写法一:不指定引擎,走注册表,谁注册了 MD5 就用谁 */ EVP_DigestInit_ex(ctx, EVP_md5(), NULL); /* 写法二:强制指定引擎,绕开注册顺序带来的不确定性 */ ENGINE *e = ENGINE_by_id("demohsm"); ENGINE_init(e); EVP_DigestInit_ex(ctx, EVP_md5(), e); /* 第三个参数就是引擎 */

两种写法的差别在排查阶段非常关键。写法一依赖全局状态,多个引擎抢同一个 NID 时,后注册的会覆盖先注册的,你根本不知道最后是谁在跑;写法二把依赖显式化,用来做对照实验最省事——同一份数据,NULL跑一遍、e跑一遍,耗时差多少一目了然。想看当前某个算法到底挂在谁身上,可以用ENGINE_get_digest_engine(NID_md5)直接问,返回非 NULL 说明有引擎接管了,返回 NULL 就是纯软件实现。

3.2 ENGINE_set_default 的九个标志位与注册优先级

ENGINE_set_default(ENGINE *e, int flag)的第二个参数决定接管范围,常用值如下:

标志位覆盖范围典型场景
ENGINE_METHOD_ALL该引擎支持的全部算法(默认值)一体化加密卡,什么都想接管
ENGINE_METHOD_RSA仅公钥 RSA只加速签名验签
ENGINE_METHOD_DSA仅 DSA老系统兼容
ENGINE_METHOD_DH仅 DH 密钥交换握手加速
ENGINE_METHOD_RAND仅随机数需要硬件真随机源
ENGINE_METHOD_CIPHERS仅对称加解密批量数据加解密卸载
ENGINE_METHOD_DIGESTS仅摘要算法大文件哈希卸载

选ALL看着省事,实际是把引擎声明支持的每一类都推上默认位置,包括那些只写了空壳、实际返回失败的算法,结果是原本跑得好好的软件实现被一个半成品顶掉。我一般的做法是按类别显式指定,只在确实全部实现且验证过的情况下才用ALL。同类别内部还有更细的注册函数:ENGINE_register_digests、ENGINE_register_ciphers只往注册表挂、不改默认实现,ENGINE_set_default_digests则连默认实现一起替换——调试阶段先用 register 系列观察,确认没问题再切成 set_default,能省掉不少回滚成本。

3.3 动态引擎:不改一行代码把引擎挂上去

如果引擎已经编译成了独立的.so,最省事的接入方式是用动态引擎加配置文件,连ENGINE_load_xxx都不用自己调。OpenSSL 自带一个 id 为dynamic的引擎,专门负责dlopen别的引擎库。

# openssl.cnf 片段:让 OpenSSL 启动时自动加载 demohsm openssl_conf = openssl_init [openssl_init] engines = engine_section [engine_section] demohsm = demohsm_section [demohsm_section] engine_id = demohsm dynamic_path = /usr/local/lib/engines/demohsm.so # 引擎动态库的绝对路径 default_algorithms = DIGESTS # 等价于 ENGINE_METHOD_DIGESTS init = 1 # 配置解析完就 ENGINE_init

dynamic_path指向的库必须导出ENGINE_load_demohsm这个符号,engine_id必须和库里ENGINE_set_id填的字符串一模一样,这两处对不上是最常见的失败原因。default_algorithms的取值可以是ALL、RSA、DIGESTS这类类别名,也可以是逗号分隔的具体算法名。

配置写好后先用命令行验证,别急着改业务代码:

# 列出引擎、检查可用性、打印它能提供的算法 openssl engine -t -c demohsm # 看动态引擎是否被识别,以及它加载了哪些子引擎 openssl engine -t -c dynamic

-t会真实调用init和finish,输出里带[ available ]才算设备真的能打开;-c列出该引擎声称支持的算法清单。这一步不通过就去查代码,纯属浪费时间。

4. 摘要算法接入实战:EVP_MD 结构逐字段拆解和三个回调的写法

4.1 一个 EVP_MD 声明,每个字段都要对得上

原文用一个 MD5 的EVP_MD静态结构体做示例,这是整个接入里最容易踩坑的地方——字段顺序在不同小版本之间有过调整,照抄网上的代码很可能编译不过或者运行时段错误。下面按 1.0.x 的布局重写一份,并逐字段标注含义:

/* 虚构示例:把 MD5 交给 demohsm 引擎实现 */ static const EVP_MD demohsm_md5 = { NID_md5, /* type:算法 NID,EVP_get_digestbynid 靠它匹配 */ NID_md5WithRSAEncryption, /* pkey_type:配套签名算法 NID,证书签名时用 */ MD5_DIGEST_LENGTH, /* md_size:摘要输出长度,16 字节 */ 0, /* flags:部分版本有此字段,照你手上的头文件对 */ demohsm_digest_init, /* init:每次 EVP_DigestInit_ex 调一次 */ demohsm_digest_update, /* update:可被多次调用,流式喂数据 */ demohsm_digest_final, /* final:输出摘要,之后可重新 init 复用 ctx */ NULL, /* copy:不支持 EVP_MD_CTX_copy 语义就留空 */ NULL, /* cleanup:ctx 释放前回收底层会话 */ NULL, NULL, /* sign/verify:老接口,新代码基本不用 */ { EVP_PKEY_RSA }, /* required_pkey_type:允许配套的公钥类型 */ MD5_CBLOCK, /* block_size:分组长度 64 字节,HMAC 会用到 */ sizeof(DEMOHSM_MD_CTX) /* ctx_size:OpenSSL 按此大小分配 ctx->md_data */ };

三个数字必须精确:md_size写错会让上层拿到超长或截断的摘要,block_size写错会直接算错 HMAC,ctx_size写小了是堆溢出、写大了是浪费但不报错——后者更阴,因为功能看起来完全正常,只是每次 init 都多分配一块内存。copy字段留空意味着EVP_MD_CTX_copy对这个算法不可用,如果业务里用了「先复制上下文再分别 update」的写法,就得老老实实实现一个深拷贝回调,把底层会话句柄也复制一份。

4.2 init / update / final 三个回调与上下文管理

ctx_size决定了 OpenSSL 给你留多大的私有空间,指针通过ctx->md_data拿到,你不需要自己 malloc,也不该自己 free:

/* 引擎私有的摘要上下文,大小必须与 EVP_MD 里的 ctx_size 一致 */ typedef struct { void *handle; /* 板卡会话句柄 */ unsigned char cache[64];/* 不足一个分组时的暂存 */ size_t used; /* 暂存区已用字节数 */ } DEMOHSM_MD_CTX; static int demohsm_digest_init(EVP_MD_CTX *ctx) { DEMOHSM_MD_CTX *c = ctx->md_data; /* 由 OpenSSL 按 ctx_size 分配好 */ c->used = 0; c->handle = demohsm_dev_open(); /* 每次 init 开一个会话 */ if (c->handle == NULL) { /* 关键:把设备错误推进 OpenSSL 错误栈,否则上层只看到一句 EVP 失败 */ DEMOHSMerr(DEMOHSM_F_DIGEST_INIT, DEMOHSM_R_DEV_OPEN); return 0; } return demohsm_dev_reset(c->handle); } static int demohsm_digest_update(EVP_MD_CTX *ctx, const void *data, size_t count) { DEMOHSM_MD_CTX *c = ctx->md_data; if (count == 0) return 1; /* 空数据是合法输入,别当错误 */ return demohsm_dev_feed(c->handle, data, count); } static int demohsm_digest_final(EVP_MD_CTX *ctx, unsigned char *md) { DEMOHSM_MD_CTX *c = ctx->md_data; int ret = demohsm_dev_done(c->handle, md); /* 必须正好写出 MD5_DIGEST_LENGTH 字节 */ demohsm_dev_close(c->handle); /* 会话一定要在这里关,不然设备句柄泄漏 */ c->handle = NULL; return ret; }

回调返回值约定是统一的:成功返回 1,失败返回 0。init里返回 0 之前务必往错误栈里推一条引擎自己的错误码,否则上层ERR_print_errors_fp打出来是空的,排查时就是纯黑匣子。final成功后上下文应当可以被再次init复用,这也是为什么关闭会话的动作放在final里而不是cleanup里——如果你的板卡不支持一次会话算多份数据,那就在init里关旧开新,别指望 OpenSSL 帮你兜底。

4.3 用 ENGINE_set_digests 把算法表交出去

结构体准备好了,还得让引擎知道「按这个 NID 我应该返回哪个 EVP_MD」,这就是digests回调的职责:

/* nid 为 0 时表示「列出你支持的全部算法」,否则查询单个 */ static int demohsm_digests(ENGINE *e, const EVP_MD **md, int *nids, int nid) { if (md == NULL) return 1; /* 只查询能力,不取具体算法 */ switch (nid) { case NID_md5: *md = &demohsm_md5; /* 必须是静态常量的地址,别返回栈对象 */ return 1; default: return 0; /* 明确告诉 OpenSSL:这个算法我不接 */ } } /* 注册时挂上去 */ ENGINE_set_digests(e, demohsm_digests);

return 0的语义是「我没有这个算法」,OpenSSL 会继续回退到软件实现,这是安全网;return 1但*md留空才是真错误,会让上层拿到空指针。所以要克制,只对你确实实现并测试过的 NID 返回 1。挂载完成后,EVP_get_digestbynid(NID_md5)的查找顺序是:先查注册表里被引擎接管的位置,没有才回落到内置表;一旦命中引擎,返回的那个EVP_MD上就带着引擎指针,后续EVP_DigestInit_ex自动把调用转进你的回调里。

5. 避坑与排查:引擎加载成功但算法没生效的 5 种现场

5.1 加载阶段的三类翻车

现象一:ENGINE_by_id返回 NULL,openssl engine -t也看不到自己的引擎。原因通常有两层:静态加载的话,很可能压根没调ENGINE_load_xxx,或者调了但中间的ENGINE_add因为 id 重复失败了;动态加载的话,多半是dynamic_path指错、engine_id与库里ENGINE_set_id的字符串不一致、或者.so没有导出ENGINE_load_<id>这个符号。解决方法是先用nm -D demohsm.so | grep ENGINE_load确认符号导出,再用绝对路径配dynamic_path,engine_id全部改成小写重新试。

现象二:ENGINE_by_id找到了,但ENGINE_init返回 0。这说明结构在、设备不在。原因可能是init回调里打开设备失败(驱动没装、权限不够、卡被别人占用),也可能是init里返回了 0 但忘了推错误码,导致日志一片空白。解决方法是先在init回调里把errno和底层返回码都打进错误栈,再单独写个几十行的小程序只调ENGINE_init/ENGINE_finish,把变量缩到最小。

现象三:程序退出时段错误,或者跑几天内存持续上涨。这是引用计数没配平。ENGINE_by_id拿到的结构引用必须配一次ENGINE_free,ENGINE_init拿到的功能引用必须配一次ENGINE_finish,少配对是泄漏,多配对是提前析构、后续访问野指针。血泪经验是:把by_id和init的成败都写进同一段清理逻辑里,用goto收敛成一条退出路径,别在每个分支里各写一遍释放代码。

5.2 生效阶段的两种翻车

现象四:加载、初始化全成功,但性能一点没变,板上也没有流量。大概率是没真正接管到默认实现。可能是ENGINE_set_default的 flag 只写了ENGINE_METHOD_CIPHERS,而你测的是摘要;也可能是另一个后加载的引擎把同一个 NID 覆盖掉了;还有一种隐蔽情况是业务代码在EVP_DigestInit_ex里显式传了NULL之外的引擎指针,绕开了注册表。排查手法很直接:ENGINE_get_digest_engine(NID_md5)打一行日志看返回是不是你的引擎,再用openssl engine -t -c确认算法清单里真有这一项,最后在update回调里加一行计数打印——回调没被调用,问题就肯定在路由而不在实现。

现象五:从 1.0.x 升到 1.1.x 之后编译报「EVP_MD 是不完整类型」。原因是从 1.1.0 起EVP_MD变成了不透明结构,不能再靠静态初始化列表去填字段,得改用构造函数逐项设置。改写方式如下:

/* 1.1.0 之后的写法:构造 + 设置 + 释放,三步走 */ EVP_MD *md = EVP_MD_meth_new(NID_md5, NID_md5WithRSAEncryption); if (md == NULL) return NULL; EVP_MD_meth_set_result_size(md, MD5_DIGEST_LENGTH); EVP_MD_meth_set_input_blocksize(md, MD5_CBLOCK); EVP_MD_meth_set_app_datasize(md, sizeof(DEMOHSM_MD_CTX)); EVP_MD_meth_set_init(md, demohsm_digest_init); EVP_MD_meth_set_update(md, demohsm_digest_update); EVP_MD_meth_set_final(md, demohsm_digest_final); /* 用完记得 EVP_MD_meth_free(md),这个对象是你自己持有的 */

版本差异这一项没法靠猜。我的习惯是在引擎源码里用#if OPENSSL_VERSION_NUMBER < 0x10100000L把两套实现都留着,编译期二选一,比升级时临时改代码稳得多。

6. 二次开发:把 ENGINE 从一次性代码改成可插拔模块

第一次写引擎适配时,我把ENGINE_load_xxx直接塞进了业务程序的启动流程里,结果是每换一块卡就要重新编译整个工程,联调时改一行日志都得走一遍发布。后来改成独立动态库加配置加载,才好受起来。

改造的核心就一条:业务侧只认ENGINE_by_id和算法 id,其余全部下沉。引擎编译成demohsm.so,导出唯一的ENGINE_load_demohsm,把设备路径、会话数、超时这些可变项做成 ctrl 命令,通过配置文件传进去,而不是写死在代码里。

/* 引擎侧的配置命令表:新增参数只改这张表,业务代码零改动 */ static const ENGINE_CMD_DEFN demohsm_cmd_defns[] = { { DEMOHSM_CMD_DEV_PATH, "DEV_PATH", "device node path, e.g. /dev/demohsm0", ENGINE_CMD_FLAG_STRING }, { DEMOHSM_CMD_MAX_SESS, "MAX_SESS", "max concurrent sessions, default 16", ENGINE_CMD_FLAG_NUMERIC }, { 0, NULL, NULL, 0 } /* 必须以全零项结尾,否则会越界读 */ };

配置命令通过ENGINE_ctrl_cmd_string(e, "DEV_PATH", "/dev/demohsm0", 0)传入,ctrl回调里按cmd号分支处理即可。这样同一份.so能跑在测试机和产线上,差别只在.conf文件。

验证手段我固定成三件套,缺一不可:命令行跑openssl engine -t -c demohsm确认可用性和算法清单;代码里打ENGINE_get_digest_engine(NID_md5)断言,确认路由确实指向自己的引擎而不是被别的引擎顶掉;最后是对同一份 512MB 数据分别用NULL和引擎指针跑EVP_DigestInit_ex,比耗时并校验两边摘要完全一致。三条都过了才允许合入。第三件套尤其别省,摘要算错但长度对的情况,光看返回值是发现不了的。

从那以后我每次接新引擎,都强制先跑一遍openssl engine -t -c再动业务代码,哪怕是别人已经调通的库。这份资源里那几个示例引擎骨架可以直接编译成.so拿来对照,拿到之后建议先把ENGINE_load_xxx里的分支顺序和自己手上的版本号核对一遍,再往里填硬件调用,能少走不少弯路。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进