第4章 C语言加密核心库实现:OpenSSL/国密库封装与常用加密函数编写
这一章,我们来聊聊加密核心库的封装。坦白讲,这事儿看起来简单,做起来坑不少。
我去年做过一个金融支付的项目,底层用 OpenSSL,上层对接了十几种语言的 SDK。核心问题只有一个——C 层怎么封装才能让所有调用方都舒服。
我的答案是:分层 + 上下文隔离 + 统一错误码。你想想看,调用方不在乎你用哪个算法库,他们只在乎函数名好不好记、参数传什么、返回值怎么判断。所以我们做的封装,本质上是把 OpenSSL 和国密库的细节藏起来,只暴露一套干净、可重入的 C 接口。
OpenSSL 高级封装:三个关键层
我个人习惯把封装拆成三层:上下文管理层、算法枚举层、统一调用接口层。
上下文管理层最容易被忽视。OpenSSL 的函数很多需要先初始化上下文,比如 EVP_CIPHER_CTX、EVP_MD_CTX、EVP_PKEY_CTX。每个上下文生命周期不同,销毁方式也不同。
enc_ctx_t 结构体,用一个枚举类型标记当前上下文属于哪种算法族。销毁时根据类型走不同的释放路径。绝不裸调 EVP_CIPHER_CTX_free。
算法枚举层更简单。我定义了一组宏或枚举:
typedef enum {
ENC_ALGO_AES_128_CBC,
ENC_ALGO_AES_256_GCM,
ENC_ALGO_SM4_CBC,
ENC_ALGO_SM4_GCM,
ENC_ALGO_RSA_2048,
ENC_ALGO_SM2,
ENC_ALGO_SM3,
ENC_ALGO_SHA256
} enc_algo_t;
然后统一调用接口长这样:
int enc_init(enc_ctx_t *ctx, enc_algo_t algo,
const uint8_t *key, size_t key_len,
const uint8_t *iv, size_t iv_len);
int enc_update(enc_ctx_t *ctx,
const uint8_t *in, size_t in_len,
uint8_t *out, size_t *out_len);
int enc_final(enc_ctx_t *ctx,
uint8_t *out, size_t *out_len);
int enc_cleanup(enc_ctx_t *ctx);
嗯,这里要注意。enc_update 和 enc_final 的 out_len 必须传入传出,因为每次加密输出的长度可能不等于输入长度(尤其 GCM 模式会附加 tag)。我在这上面踩过一次坑,用固定长度接收,结果内存越界。
国密 SM 系列封装:与 OpenSSL 对齐
国密库(比如 GmSSL、OpenSSL 1.1.x 之后也支持 SM 系列)的接口命名和 OpenSSL 不太一样。SM2 有独立的签名/验签函数,SM3 和 SHA256 虽然都是哈希但参数结构不同。
我的封装思路是:在统一接口层内部做路由。比如:
- 如果 algo 是 ENC_ALGO_SM4_CBC,底层走 SM4 接口。
- 如果 algo 是 ENC_ALGO_AES_256_CBC,底层走 EVP 接口。
- 如果 algo 是 ENC_ALGO_SM2,走 SM2 签名/验签接口。
- 如果 algo 是 ENC_ALGO_SM3,走 SM3 哈希接口。
路由表封装在一个结构体数组里:
typedef struct {
enc_algo_t algo;
const char *algo_name;
int (*init_fn)(enc_ctx_t *ctx,
const uint8_t *key,
size_t key_len,
const uint8_t *iv,
size_t iv_len);
int (*update_fn)(enc_ctx_t *ctx,
const uint8_t *in,
size_t in_len,
uint8_t *out,
size_t *out_len);
int (*final_fn)(enc_ctx_t *ctx,
uint8_t *out,
size_t *out_len);
int (*cleanup_fn)(enc_ctx_t *ctx);
} enc_algo_dispatch_t;
这样新增一个算法,只需要加一行路由表,不需要改调用方的代码。
enc_sm2_sign_raw() 函数,内部做 DER 解析。这种「格式适配」放在 C 层最合适。
常用加密函数编写:对称、非对称、哈希
我挑三个最常用的例子来说明。
| 函数功能 | 输入参数 | 输出参数 | 底层调用 |
|---|---|---|---|
| 对称加密 | key, iv, plaintext, plainlen | ciphertext, cipherlen, tag | EVP_EncryptInit/Update/Final |
| 哈希摘要 | data, datalen | digest, digestlen | EVP_DigestInit/Update/Final |
| SM2 签名 | 私钥, data, datalen | signature, siglen | SM2_sign 或 EVP_DigestSign |
| RSA 公钥加密 | 公钥, plaintext, plainlen | ciphertext, cipherlen | EVP_PKEY_encrypt |
写对称加密函数时,有一个细节:加密和解密是两个独立的上下文。虽然 OpenSSL 允许复用,但我在项目中遇到过并发问题——两个线程共用一个上下文,数据直接乱掉。
所以我现在的做法是:
int enc_sym_encrypt(const uint8_t *key, size_t key_len,
const uint8_t *iv, size_t iv_len,
const uint8_t *plain, size_t plain_len,
uint8_t *cipher, size_t *cipher_len,
uint8_t *tag, size_t *tag_len) {
enc_ctx_t ctx;
int ret = ENC_OK;
ret = enc_init(&ctx, ENC_ALGO_AES_256_GCM, key, key_len, iv, iv_len);
if (ret != ENC_OK) return ret;
ret = enc_update(&ctx, plain, plain_len, cipher, cipher_len);
if (ret != ENC_OK) goto cleanup;
ret = enc_final(&ctx, cipher + *cipher_len, cipher_len);
if (ret != ENC_OK) goto cleanup;
// 从上下文中提取 tag
memcpy(tag, ctx.gcm_tag, *tag_len);
cleanup:
enc_cleanup(&ctx);
return ret;
}
if (ctx->internal) { EVP_CIPHER_CTX_free(ctx->internal); ctx->internal = NULL; } 这种防御式写法。
哈希函数写起来简单,但有一个点要强调:流式调用与一次性调用的区别。大多数 SDK 封装会提供一次性哈希,但上层如果做大文件分片校验,必须暴露 update/final 模式。
int enc_hash(const uint8_t *data, size_t data_len,
enc_algo_t algo,
uint8_t *digest, size_t *digest_len) {
enc_ctx_t ctx;
int ret = enc_init(&ctx, algo, NULL, 0, NULL, 0);
if (ret != ENC_OK) return ret;
ret = enc_update(&ctx, data, data_len, NULL, NULL);
if (ret != ENC_OK) goto cleanup;
ret = enc_final(&ctx, digest, digest_len);
cleanup:
enc_cleanup(&ctx);
return ret;
}
为什么 update 传 NULL?因为哈希模式下 update 不产生输出,结果只在 final 时出来。这个设计跟对称加密保持一致,调用方不用记两套模式。
常见避坑点
我列几个实际项目中踩过的坑:
- 对称加密的上下文不能复用。一个 enc_ctx_t 只能做一次加密或解密,做完必须 cleanup。想复用就重新 init。
- 密钥派生(KDF)别在封装层里做。有些上层 SDK 直接从密码字符串加密,但 C 层只接受二进制 key。派生逻辑留给上层,或者单独提供一个
enc_kdf()函数。 - 错误码统一。OpenSSL 返回 1 成功、0 失败、-1 异常,但有些国密库返回正数成功、负数失败。我强制在封装层 map 成一套
ENC_SUCCESS、ENC_ERR_INIT、ENC_ERR_PARAM、ENC_ERR_MEMORY、ENC_ERR_UNKNOWN。 - 线程安全。OpenSSL 1.1.x 以上默认线程安全,但 1.0.x 需要自己加锁。如果你封装时既要兼容老版本,又要给多语言 SDK 用,最好在 init 阶段调用
OPENSSL_thread_setup()。
最后画一张图,概括整个 C 语言加密核心库封装的结构:
这张图的逻辑很直接:最上层是各种语言的 SDK 调用,中间是我们写的统一封装接口,最下面是 OpenSSL 和国密库。所有调用都经过算法枚举路由,实现了对上层透明。
做到这一步,C 语言加密核心库的封装就算成型了。接下来的章节会在此基础上,进一步展开各个语言的 SDK 适配细节。