资讯详情

资讯详情

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_initdynamic_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算法 NIDEVP_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, /* cleanupctx 释放前回收底层会话 */ NULL, NULL, /* sign/verify老接口新代码基本不用 */ { EVP_PKEY_RSA }, /* required_pkey_type允许配套的公钥类型 */ MD5_CBLOCK, /* block_size分组长度 64 字节HMAC 会用到 */ sizeof(DEMOHSM_MD_CTX) /* ctx_sizeOpenSSL 按此大小分配 ctx-md_data */ };三个数字必须精确md_size写错会让上层拿到超长或截断的摘要block_size写错会直接算错 HMACctx_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返回 NULLopenssl 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_pathengine_id全部改成小写重新试。现象二ENGINE_by_id找到了但ENGINE_init返回 0。这说明结构在、设备不在。原因可能是init回调里打开设备失败驱动没装、权限不够、卡被别人占用也可能是init里返回了 0 但忘了推错误码导致日志一片空白。解决方法是先在init回调里把errno和底层返回码都打进错误栈再单独写个几十行的小程序只调ENGINE_init/ENGINE_finish把变量缩到最小。现象三程序退出时段错误或者跑几天内存持续上涨。这是引用计数没配平。ENGINE_by_id拿到的结构引用必须配一次ENGINE_freeENGINE_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里的分支顺序和自己手上的版本号核对一遍再往里填硬件调用能少走不少弯路。希望帮到你。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →