第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;

这样新增一个算法,只需要加一行路由表,不需要改调用方的代码。

实用技巧:SM2 的签名输出是 DER 编码的,而很多上层 SDK 需要原始 r||s 格式。我在封装层里加了一个 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;
}
避坑指南:我曾经在 enc_final 之后忘记 cleanup,导致内存泄漏。而且 OpenSSL 1.0.2 和 1.1.x 的上下文结构体内部布局不同。所以 cleanup 函数最好写成 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 里面。

常见避坑点

我列几个实际项目中踩过的坑:

  • 对称加密的上下文不能复用。一个 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()。
再次强调:不要在封装层里隐藏错误细节。比如 SM2 签名失败,到底是密钥格式错误还是数据长度超限?通过枚举错误码区分,不要在封装层里「吞掉」错误信息。上层 SDK 调试时最怕的就是返回一个「未知错误」。

最后画一张图,概括整个 C 语言加密核心库封装的结构:

C 语言加密核心库封装结构 应用层(多语言 SDK 调用) 统一封装接口层 上下文管理 算法枚举路由 错误码 OpenSSL 库 AES / RSA / ECC SHA256 / EVP 接口 国密库(SM 系列) SM2 / SM3 / SM4 GmSSL 或 OpenSSL SM 扩展 说明: 封装层通过算法枚举路由,决定底层调用 OpenSSL 还是国密库。 上下文管理统一生命周期,错误码映射确保上层无需感知底层差异。

这张图的逻辑很直接:最上层是各种语言的 SDK 调用,中间是我们写的统一封装接口,最下面是 OpenSSL 和国密库。所有调用都经过算法枚举路由,实现了对上层透明。

做到这一步,C 语言加密核心库的封装就算成型了。接下来的章节会在此基础上,进一步展开各个语言的 SDK 适配细节。