前言

最近在为 HarmonyOS 开发一个加密库,需要使用 OpenSSL 来实现 Blowfish 等经典加密算法。本以为是个很简单的任务,结果却遇到了一个让我抓狂的问题:相同的数据,每次加密的结果都不一样! 🤯

经过一段时间的排查,终于找到了问题的根源。这次踩坑经历让我深刻理解了 OpenSSL EVP API 的一些"潜规则",特此分享给各位,希望能帮大家避免同样的坑。

开发环境准备

在开始之前,强烈推荐收藏这些官方资源:


第一章:OpenSSL 编译篇 - 环境搭建之路

1.1 为什么选择 tpc_c_cplusplus?

华为官方提供的 tpc_c_cplusplus 仓库包含了大量常用 C/C++ 库的编译脚本,对于 HarmonyOS 的适配已经做得很完善了。我们直接使用它来编译 OpenSSL,可以省去大量的踩坑时间。

👉 OpenSSL 编译脚本传送门

1.2 编译脚本的修改要点

问题 1:默认脚本编译的是旧版本

官方脚本默认编译 OpenSSL_1_1_1u,但我需要使用 OpenSSL 3.5.0 的新特性,所以需要修改脚本中的版本号:

# 找到脚本中的 pkgver 变量,修改为:
pkgver=openssl-3.5.0

问题 2:需要使用 Blowfish 等 Legacy 算法

OpenSSL 3.x 将一些老旧的算法(如 Blowfish、DES、MD5 等)归入了 legacy provider,默认不启用。如果需要使用这些算法,必须在编译时开启:

# 在编译参数中添加
--enable-legacy

⚠️ 重要提示:默认脚本会生成静态库(no-shared),如果需要动态库,请删除该参数。

问题 3:Legacy Provider 的路径陷阱

这是一个巨坑!编译时,OpenSSL 会将 legacy.so 的路径硬编码到库文件中。这个路径是编译环境的路径,而不是目标设备的路径。

问题现象

运行时报错:Failed to load legacy provider
原因:OpenSSL 尝试从 /home/builder/openssl/lib/ossl-modules/legacy.so 加载
但实际设备上根本没有这个路径!

解决方案
在编译参数中添加 no-module 选项,将 legacy 直接编译进静态库:

# 完整的编译参数示例
./Configure \
    --prefix=/path/to/install \
    no-shared \
    enable-legacy \
    no-module \
    ...其他参数

这样,legacy 算法就会内嵌到 libcrypto.a 中,不需要额外的 .so 文件。

1.3 编译产物

编译完成后,你会得到:

  • libssl.a - SSL/TLS 协议实现
  • libcrypto.a - 加密算法实现(包含 legacy)
  • 头文件目录

将这些文件拷贝到你的 HarmonyOS 项目中即可使用。


第二章:使用篇 - 惊心动魄的 Bug 排查

2.1 问题浮现:加密结果不一致

一切准备就绪后,我写了一个简单的 Blowfish 加密测试:

// JavaScript 测试代码
const key = buffer.from("test1234").buffer;
const iv = buffer.from("test1234").buffer;
const input = buffer.from("test1234").buffer;

// 连续加密 3 次相同的数据
for (let i = 0; i < 3; i++) {
    let result = Crypto.Blowfish(input, key, iv, 0);
    console.log(`${i+1} 次:`, buffer.from(result).toString("base64"));
}

预期结果:三次输出应该完全一样
实际结果:💥

第 1 次: dr/Y4j4Of6o=
第 2 次: ZeMJJaEJcu0=  ❌ 完全不一样!
第 3 次: wsjcVOSUg9E=  ❌ 又变了!

看到这个结果,我的第一反应是:难道 Blowfish 内部有随机化?但这显然不合理,加密算法应该是确定性的啊!

2.2 排查过程:一步步缩小范围

第一步:检查输入数据

我首先怀疑是 JavaScript 端的数据有问题,于是在 C++ 端打印了所有输入参数:

LOGI("key(hex): %s", keyHex.c_str());     // 7465737431323334 ✅
LOGI("iv(hex): %s", ivHex.c_str());       // 7465737431323334 ✅
LOGI("input(hex): %s", inputHex.c_str()); // 7465737431323334 ✅

输入完全一致,排除数据问题。

第二步:检查 IV 是否被修改

CBC 模式会修改内部的 IV 状态,我怀疑 OpenSSL 可能修改了传入的 IV 缓冲区:

// 在加密前后打印 IV
LOGI("iv before: %s", ivHex.c_str());
// ... 执行加密 ...
LOGI("iv after: %s", ivHex.c_str());

结果:IV 完全没有被修改!排除 IV 污染。

第三步:自检测试 - 真相大白

我在初始化时添加了一个自检函数,直接用 OpenSSL API 连续加密 3 次:

// 在 globalInit 中添加自检
for (int i = 0; i < 3; i++) {
    EVP_CIPHER_CTX *ctx = EVP_CIPHER_CTX_new();
    EVP_EncryptInit_ex(ctx, EVP_bf_cbc(), nullptr, nullptr, nullptr);
    EVP_EncryptInit_ex(ctx, nullptr, nullptr, test_key, test_iv);
    // ... 执行加密 ...
    LOGI("Self-test iteration %d: %s", i+1, hex);
    EVP_CIPHER_CTX_free(ctx);
}

第一次自检结果

Self-test iteration 1: 7cceb15d1bdcc1b9  ❌
Self-test iteration 2: a5d2d8bd8d3cdbba  ❌ 不一致
Self-test iteration 3: 942e789dbfc867e8  ❌ 不一致

问题复现了!说明是 OpenSSL 使用方式的问题,与 JavaScript 无关。

2.3 真凶现身:Blowfish 的密钥长度陷阱

经过仔细查阅 OpenSSL 文档和源码,我发现了问题的根源:

Blowfish 的特殊性

Blowfish 是一个可变密钥长度的算法:

  • 支持密钥长度:1 到 56 字节(非常灵活)
  • 块大小:固定 8 字节
  • IV 长度:固定 8 字节

而 AES 等现代算法的密钥长度是固定的(AES-128 就是 16 字节,AES-256 就是 32 字节)。

问题代码分析

我的原始代码:

EVP_CIPHER_CTX *ctx = EVP_CIPHER_CTX_new();

// 第一步:设置 cipher
EVP_EncryptInit_ex(ctx, EVP_bf_cbc(), nullptr, nullptr, nullptr);

// ❌ 问题:直接设置 8 字节的 key,但没有告诉 OpenSSL 密钥长度
EVP_EncryptInit_ex(ctx, nullptr, nullptr, key, iv);

问题根源

  1. 第一次调用 EVP_EncryptInit_ex 时,OpenSSL 使用 Blowfish 的默认密钥长度 16 字节
  2. Context 内部预期接收 16 字节的密钥
  3. 第二次调用时传入了 8 字节密钥,长度不匹配
  4. OpenSSL 不知道该怎么处理,就会产生未定义行为
    • 可能用内存中的随机数据填充剩余的 8 字节
    • 可能用之前的状态数据
    • 每次执行时使用的数据都不一样,导致输出不一致

正确的做法

关键:显式设置密钥长度!

EVP_CIPHER_CTX *ctx = EVP_CIPHER_CTX_new();

// 第一步:设置 cipher(不设置 key/IV)
EVP_EncryptInit_ex(ctx, EVP_bf_cbc(), nullptr, nullptr, nullptr);

// ✅ 第二步:显式设置密钥长度(关键的一步!)
EVP_CIPHER_CTX_set_key_length(ctx, 8);  // 告诉 OpenSSL:我要用 8 字节密钥

// 第三步:设置 key 和 IV
EVP_EncryptInit_ex(ctx, nullptr, nullptr, key, iv);

// 第四步:设置填充模式
EVP_CIPHER_CTX_set_padding(ctx, 0);

// 第五步:执行加密
EVP_EncryptUpdate(ctx, out, &outl, input, inl);
EVP_EncryptFinal_ex(ctx, out + outl, &outl);

2.4 修复验证:完美!

添加 EVP_CIPHER_CTX_set_key_length 后,重新编译运行:

Self-test iteration 1: 76bfd8e23e0e7faa  ✅
Self-test iteration 2: 76bfd8e23e0e7faa  ✅ 一致!
Self-test iteration 3: 76bfd8e23e0e7faa  ✅ 一致!

用户测试:
第 1 次: dr/Y4j4Of6o=  ✅
第 2 次: dr/Y4j4Of6o=  ✅ 完美一致
第 3 次: dr/Y4j4Of6o=  ✅ 完美一致

问题彻底解决!🎉

2.5 额外发现:Provider 的正确加载方式

在排查过程中,我还发现了 OpenSSL 3.x 的 Provider 加载最佳实践:

❌ 错误做法

bool globalInit() {
    // 每次都加载,但不保存指针
    if (!OSSL_PROVIDER_load(nullptr, "legacy")) {
        LOGE("Failed to load legacy");
    }
}

问题:Provider 可能被意外卸载,导致算法不可用。

✅ 正确做法

// 使用静态变量保存 provider 指针
static OSSL_PROVIDER *default_provider = nullptr;
static OSSL_PROVIDER *legacy_provider = nullptr;
static std::once_flag init_flag;

bool globalInit() {
    std::call_once(init_flag, []() {
        // 先加载 default,再加载 legacy
        default_provider = OSSL_PROVIDER_load(nullptr, "default");
        legacy_provider = OSSL_PROVIDER_load(nullptr, "legacy");
        
        if (!legacy_provider) {
            throw std::runtime_error("Failed to load legacy provider");
        }
        
        LOGI("OpenSSL providers loaded successfully");
    });
    return true;
}

要点

  1. 使用 static 变量保存 provider 指针,确保生命周期
  2. 使用 std::call_once 确保只初始化一次
  3. 同时加载 defaultlegacy provider

第三章:经验总结与最佳实践

3.1 完整的 Blowfish 加密代码模板

napi_value Encrypt(napi_env env, napi_callback_info info) {
    // 1. 获取参数
    size_t argc = 5;
    napi_value args[5] = {nullptr};
    napi_get_cb_info(env, info, &argc, args, nullptr, nullptr);
    
    // 2. 初始化 OpenSSL
    globalInit();  // 加载 providers
    
    // 3. 创建 context
    EVP_CIPHER_CTX *ctx = EVP_CIPHER_CTX_new();
    if (!ctx) {
        napi_throw_error(env, nullptr, "Failed to create context");
        return nullptr;
    }
    
    // 4. 解析参数
    std::string algName = get_string(env, args[0]);
    size_t keyLen = 0, ivLen = 0;
    unsigned char* keyPtr = get_arraybuffer(env, args[1], &keyLen);
    unsigned char* ivPtr = get_arraybuffer(env, args[2], &ivLen);
    int padding = get_int32(env, args[3]);
    size_t inLen = 0;
    unsigned char* inputPtr = get_arraybuffer(env, args[4], &inLen);
    
    // 5. 复制数据(确保内存安全)
    unsigned char* key = new unsigned char[keyLen];
    unsigned char* iv = new unsigned char[ivLen];
    unsigned char* input = new unsigned char[inLen];
    memcpy_s(key, keyLen, keyPtr, keyLen);
    memcpy_s(iv, ivLen, ivPtr, ivLen);
    memcpy_s(input, inLen, inputPtr, inLen);
    
    // 6. 设置 cipher(重点步骤)
    int ret = EVP_EncryptInit_ex(ctx, EVP_bf_cbc(), nullptr, nullptr, nullptr);
    if (ret != 1) goto error;
    
    // ⭐ 7. 设置密钥长度(Blowfish 必需!)
    ret = EVP_CIPHER_CTX_set_key_length(ctx, keyLen);
    if (ret != 1) {
        LOGW("Failed to set key length, using default");
    }
    
    // 8. 设置 key 和 IV
    ret = EVP_EncryptInit_ex(ctx, nullptr, nullptr, key, iv);
    if (ret != 1) goto error;
    
    // 9. 设置填充模式
    EVP_CIPHER_CTX_set_padding(ctx, padding);
    
    // 10. 验证块大小(无填充时必须对齐)
    int blockSize = EVP_CIPHER_CTX_block_size(ctx);
    if (padding == 0 && (inLen % blockSize != 0)) {
        napi_throw_error(env, nullptr, "Input length must be multiple of block size");
        goto error;
    }
    
    // 11. 分配输出缓冲区
    size_t maxOutLen = inLen + blockSize;
    unsigned char* out = new unsigned char[maxOutLen];
    memset(out, 0, maxOutLen);
    
    // 12. 执行加密
    int outl = 0, cipherLen = 0;
    ret = EVP_EncryptUpdate(ctx, out, &outl, input, inLen);
    if (ret != 1) goto error;
    cipherLen += outl;
    
    ret = EVP_EncryptFinal_ex(ctx, out + outl, &outl);
    if (ret != 1) goto error;
    cipherLen += outl;
    
    // 13. 清理资源
    EVP_CIPHER_CTX_free(ctx);
    delete[] key;
    delete[] iv;
    delete[] input;
    
    // 14. 创建返回值
    napi_value arraybuffer;
    void* buffer_data = nullptr;
    napi_create_arraybuffer(env, cipherLen, &buffer_data, &arraybuffer);
    memcpy_s(buffer_data, cipherLen, out, cipherLen);
    delete[] out;
    
    return arraybuffer;

error:
    // 错误处理:清理所有资源
    EVP_CIPHER_CTX_free(ctx);
    delete[] key;
    delete[] iv;
    delete[] input;
    napi_throw_error(env, nullptr, "Encryption failed");
    return nullptr;
}

3.2 EVP API 标准调用顺序

对于可变密钥长度算法(Blowfish、RC2、CAST5 等):

1. EVP_CIPHER_CTX_new()
2. EVP_EncryptInit_ex(ctx, cipher, engine, NULL, NULL)
3. ⭐ EVP_CIPHER_CTX_set_key_length(ctx, key_len)  ← 必需!
4. EVP_EncryptInit_ex(ctx, NULL, NULL, key, iv)
5. EVP_CIPHER_CTX_set_padding(ctx, padding)
6. EVP_EncryptUpdate() / EVP_EncryptFinal_ex()
7. EVP_CIPHER_CTX_free()

对于固定密钥长度算法(AES、DES 等):

1. EVP_CIPHER_CTX_new()
2. EVP_EncryptInit_ex(ctx, cipher, engine, key, iv)  ← 可以一次设置
3. EVP_CIPHER_CTX_set_padding(ctx, padding)
4. EVP_EncryptUpdate() / EVP_EncryptFinal_ex()
5. EVP_CIPHER_CTX_free()

3.3 需要特别注意的算法

以下算法支持可变密钥长度,必须显式设置密钥长度

算法 密钥长度范围 OpenSSL 默认长度
Blowfish 1-56 字节 16 字节 (128 位)
RC2 1-128 字节 16 字节 (128 位)
RC4 1-256 字节 16 字节 (128 位)
CAST5 5-16 字节 16 字节 (128 位)

总结

这次排查经历让我学到了:

  1. OpenSSL 文档虽然详细,但很多"潜规则"需要实践才能发现 📖
  2. 可变密钥长度算法需要额外小心,必须显式设置密钥长度 🔑
  3. 不要想当然,即使是"简单"的加密操作也可能有坑 ⚠️

希望这篇文章能帮助到正在开发 HarmonyOS/openssl的同学,少走弯路!

如果你也遇到了类似的问题,欢迎在评论区交流~ 🙌


参考资料


项目地址crypto-openharmony

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐