加密与安全:OpenSSL跨平台使用、AES/RSA封装、哈希算法

说实话,加密这块内容,很多C++开发者要么直接跳过,要么用的时候从网上拷一段代码就跑。我早年也这么干过,直到有一次在Linux上编译好好的代码,到了Windows上直接崩了——嗯,就是因为OpenSSL的库链接方式不一样。从那以后,我就老老实实把跨平台加密封装这件事给捋清楚了。

今天这一章,咱们就聊聊怎么在C++里把OpenSSL用顺手,把AES、RSA和哈希算法封装成跨平台的接口。说白了,就是让你写一次加密代码,Windows、Linux、macOS都能跑。

一、OpenSSL的跨平台坑与对策

OpenSSL本身是跨平台的,但它的编译和链接方式在不同系统上差异很大。我个人习惯的做法是:不要直接链接系统自带的OpenSSL,而是自己编译一个静态库,或者用vcpkg/conan来管理。

⚠️ 我曾经踩过的坑:在macOS上,系统自带的OpenSSL版本是0.9.8(老旧版本),而你的代码可能用了1.1.1的新API。链接的时候不会报错,但运行时会崩溃。所以,永远不要依赖系统自带的OpenSSL版本。

跨平台编译的推荐方案:

  • Windows:用vcpkg安装 openssl-windows,或者从源码编译(需要安装Perl和NASM)
  • Linux:用包管理器安装 libssl-dev(Ubuntu)或 openssl-devel(CentOS)
  • macOS:用Homebrew安装 openssl@3,然后通过 pkg-config 获取编译参数

下面是我常用的CMake配置片段,可以自动处理不同平台的OpenSSL路径:

find_package(OpenSSL REQUIRED)
if(OpenSSL_FOUND)
    include_directories(${OPENSSL_INCLUDE_DIR})
    target_link_libraries(my_project ${OPENSSL_LIBRARIES})
endif()

# 针对macOS的特殊处理
if(APPLE)
    set(OPENSSL_ROOT_DIR "/usr/local/opt/openssl@3")
endif()

二、AES对称加密封装

AES是目前最常用的对称加密算法。我一般用AES-256-GCM模式,因为它既加密又带认证,安全性比ECB/CBC高不少。

先看一个简单的AES加密封装类:

#include <openssl/evp.h>
#include <vector>
#include <string>

class AesEncryptor {
public:
    // 加密:返回密文(包含IV和tag)
    std::vector<unsigned char> encrypt(
        const std::vector<unsigned char>& plaintext,
        const std::vector<unsigned char>& key) {
        
        // 生成随机IV(12字节)
        std::vector<unsigned char> iv(12);
        RAND_bytes(iv.data(), iv.size());
        
        EVP_CIPHER_CTX* ctx = EVP_CIPHER_CTX_new();
        EVP_EncryptInit_ex(ctx, EVP_aes_256_gcm(), nullptr, 
                          key.data(), iv.data());
        
        std::vector<unsigned char> ciphertext(plaintext.size() + 16);
        int len = 0, ciphertext_len = 0;
        
        EVP_EncryptUpdate(ctx, ciphertext.data(), &len, 
                         plaintext.data(), plaintext.size());
        ciphertext_len = len;
        
        EVP_EncryptFinal_ex(ctx, ciphertext.data() + len, &len);
        ciphertext_len += len;
        
        // 获取认证标签
        std::vector<unsigned char> tag(16);
        EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_GET_TAG, 16, tag.data());
        
        EVP_CIPHER_CTX_free(ctx);
        
        // 返回格式:IV + 密文 + tag
        ciphertext.resize(ciphertext_len);
        ciphertext.insert(ciphertext.end(), iv.begin(), iv.end());
        ciphertext.insert(ciphertext.end(), tag.begin(), tag.end());
        
        return ciphertext;
    }
    
    // 解密:从密文中提取IV和tag
    std::vector<unsigned char> decrypt(
        const std::vector<unsigned char>& ciphertext,
        const std::vector<unsigned char>& key) {
        
        // 解析IV、密文、tag
        auto iv_start = ciphertext.end() - 28;
        auto tag_start = ciphertext.end() - 16;
        
        std::vector<unsigned char> iv(iv_start, iv_start + 12);
        std::vector<unsigned char> tag(tag_start, ciphertext.end());
        std::vector<unsigned char> actual_cipher(ciphertext.begin(), iv_start);
        
        EVP_CIPHER_CTX* ctx = EVP_CIPHER_CTX_new();
        EVP_DecryptInit_ex(ctx, EVP_aes_256_gcm(), nullptr, 
                          key.data(), iv.data());
        
        std::vector<unsigned char> plaintext(actual_cipher.size());
        int len = 0, plaintext_len = 0;
        
        EVP_DecryptUpdate(ctx, plaintext.data(), &len, 
                         actual_cipher.data(), actual_cipher.size());
        plaintext_len = len;
        
        // 设置期望的tag
        EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_SET_TAG, 16, tag.data());
        
        // 验证并完成解密
        int ret = EVP_DecryptFinal_ex(ctx, plaintext.data() + len, &len);
        if (ret <= 0) {
            EVP_CIPHER_CTX_free(ctx);
            throw std::runtime_error("解密失败:数据可能被篡改");
        }
        plaintext_len += len;
        
        EVP_CIPHER_CTX_free(ctx);
        plaintext.resize(plaintext_len);
        return plaintext;
    }
};
💡 我的经验:IV(初始化向量)一定要随机生成,并且每次加密都不同。我曾经见过有人把IV写死在代码里,结果相同的明文加密出相同的密文——那跟没加密有什么区别?

三、RSA非对称加密封装

RSA适合加密小数据(比如AES的密钥),或者做数字签名。我一般用RSA-2048,密钥长度和性能之间比较平衡。

下面是一个RSA密钥对生成和加密的封装:

#include <openssl/rsa.h>
#include <openssl/pem.h>

class RsaEncryptor {
public:
    // 生成密钥对,返回PEM格式的字符串
    static std::pair<std::string, std::string> generateKeyPair() {
        EVP_PKEY* pkey = EVP_PKEY_new();
        EVP_PKEY_CTX* ctx = EVP_PKEY_CTX_new_id(EVP_PKEY_RSA, nullptr);
        
        EVP_PKEY_keygen_init(ctx);
        EVP_PKEY_CTX_set_rsa_keygen_bits(ctx, 2048);
        EVP_PKEY_keygen(ctx, &pkey);
        
        // 提取公钥和私钥(PEM格式)
        BIO* bio_priv = BIO_new(BIO_s_mem());
        BIO* bio_pub = BIO_new(BIO_s_mem());
        
        PEM_write_bio_PrivateKey(bio_priv, pkey, nullptr, nullptr, 0, nullptr, nullptr);
        PEM_write_bio_PUBKEY(bio_pub, pkey);
        
        std::string priv_key = bioToString(bio_priv);
        std::string pub_key = bioToString(bio_pub);
        
        BIO_free_all(bio_priv);
        BIO_free_all(bio_pub);
        EVP_PKEY_CTX_free(ctx);
        EVP_PKEY_free(pkey);
        
        return {priv_key, pub_key};
    }
    
    // 使用公钥加密
    static std::vector<unsigned char> encrypt(
        const std::vector<unsigned char>& data,
        const std::string& pubKeyPem) {
        
        BIO* bio = BIO_new_mem_buf(pubKeyPem.data(), pubKeyPem.size());
        EVP_PKEY* pkey = PEM_read_bio_PUBKEY(bio, nullptr, nullptr, nullptr);
        BIO_free(bio);
        
        EVP_PKEY_CTX* ctx = EVP_PKEY_CTX_new(pkey, nullptr);
        EVP_PKEY_encrypt_init(ctx);
        
        size_t outlen = 0;
        EVP_PKEY_encrypt(ctx, nullptr, &outlen, data.data(), data.size());
        
        std::vector<unsigned char> encrypted(outlen);
        EVP_PKEY_encrypt(ctx, encrypted.data(), &outlen, data.data(), data.size());
        encrypted.resize(outlen);
        
        EVP_PKEY_CTX_free(ctx);
        EVP_PKEY_free(pkey);
        
        return encrypted;
    }
    
private:
    static std::string bioToString(BIO* bio) {
        char* data = nullptr;
        long len = BIO_get_mem_data(bio, &data);
        return std::string(data, len);
    }
};
⚠️ 注意:RSA加密的数据长度有限制。对于2048位的密钥,一次最多加密245字节(2048/8 - 11)。如果要加密大文件,正确的做法是:用RSA加密AES密钥,然后用AES加密文件内容。这叫「混合加密」,实际项目中99%都是这么干的。

四、哈希算法封装

哈希算法我常用SHA-256和SHA-3。OpenSSL的EVP接口对哈希的支持非常统一,封装起来很简洁:

#include <openssl/evp.h>

class HashUtil {
public:
    // 计算SHA-256哈希
    static std::vector<unsigned char> sha256(
        const std::vector<unsigned char>& data) {
        
        EVP_MD_CTX* ctx = EVP_MD_CTX_new();
        EVP_DigestInit_ex(ctx, EVP_sha256(), nullptr);
        EVP_DigestUpdate(ctx, data.data(), data.size());
        
        std::vector<unsigned char> hash(EVP_MAX_MD_SIZE);
        unsigned int hash_len = 0;
        EVP_DigestFinal_ex(ctx, hash.data(), &hash_len);
        
        EVP_MD_CTX_free(ctx);
        hash.resize(hash_len);
        return hash;
    }
    
    // 计算文件的哈希(适合大文件)
    static std::vector<unsigned char> sha256File(const std::string& filepath) {
        FILE* fp = fopen(filepath.c_str(), "rb");
        if (!fp) throw std::runtime_error("无法打开文件");
        
        EVP_MD_CTX* ctx = EVP_MD_CTX_new();
        EVP_DigestInit_ex(ctx, EVP_sha256(), nullptr);
        
        unsigned char buffer[8192];
        size_t bytes_read = 0;
        while ((bytes_read = fread(buffer, 1, sizeof(buffer), fp)) > 0) {
            EVP_DigestUpdate(ctx, buffer, bytes_read);
        }
        
        std::vector<unsigned char> hash(EVP_MAX_MD_SIZE);
        unsigned int hash_len = 0;
        EVP_DigestFinal_ex(ctx, hash.data(), &hash_len);
        
        EVP_MD_CTX_free(ctx);
        fclose(fp);
        hash.resize(hash_len);
        return hash;
    }
    
    // 将哈希转为十六进制字符串(方便显示)
    static std::string toHex(const std::vector<unsigned char>& hash) {
        std::string hex;
        const char* hex_chars = "0123456789abcdef";
        for (auto byte : hash) {
            hex += hex_chars[(byte >> 4) & 0x0F];
            hex += hex_chars[byte & 0x0F];
        }
        return hex;
    }
};
💡 避坑指南:计算文件哈希时,千万别一次性把整个文件读到内存里。我见过有人用 mmap 映射一个10GB的文件然后算哈希——结果内存直接爆了。用分块读取的方式,缓冲区设成8KB或16KB就够用了。

五、知识体系总览

下面这张图把本章的核心知识点串起来了,你可以看到加密、解密、签名、验证之间的关系:

加密与安全知识体系 对称加密 (AES) 非对称加密 (RSA) 哈希算法 (SHA-256) GCM 模式 CBC 模式 加密/解密 数字签名 文件校验 密码存储 OpenSSL EVP 接口(跨平台统一API) Windows (vcpkg) | Linux (apt/yum) | macOS (Homebrew) 底层:BIO / PEM / 内存管理 / 错误处理 密钥生成 | 格式转换 | 安全擦除 | 异常安全

六、跨平台封装的几个关键点

把上面这些封装成跨平台库,我总结了几个要点:

要点 说明 我的建议
内存管理 OpenSSL大量使用C风格的内存分配,容易泄漏 用RAII包装所有OpenSSL对象,比如 std::unique_ptr 配合自定义删除器
错误处理 OpenSSL的错误码很分散,不同函数返回方式不同 统一封装成异常,用 ERR_get_error() 获取详细错误信息
线程安全 OpenSSL 1.1.0+ 默认线程安全,但旧版本需要自己加锁 强制要求OpenSSL版本 ≥ 1.1.0,否则编译时报错
密钥安全 私钥在内存中可能被转储 使用完密钥后,用 OPENSSL_cleanse() 擦除内存,不要用 memset

🔑 核心总结:

  • AES用GCM模式,自带认证,安全性高
  • RSA只用来加密小数据或签名,大文件用混合加密
  • 哈希用SHA-256,分块计算大文件
  • 所有OpenSSL对象用RAII管理,避免内存泄漏
  • 跨平台编译用CMake + vcpkg/conan,不要依赖系统自带版本

嗯,加密这块内容其实不难,但细节特别多。我当年第一次封装RSA时,忘了处理PEM格式的换行符,结果在Windows上解析公钥一直失败——折腾了两天才发现是换行符的问题。所以,写加密代码时,一定要在多个平台上做完整的单元测试,尤其是密钥的导入导出环节。


公众号:蓝海资料掘金营,微信deep3321