简介:这份资源面向需要在 Windows 平台使用 C++ 完成 RSA 加解密开发的初中级程序员,针对网上示例多为 Linux 版、代码老旧且内容残缺导致难以调试的痛点,提供一套可直接编译运行的完整工程。压缩包共 12 个文件,约 450KB,包含 cpp 主程序源码、h 头文件、cbp 工程配置、depend 依赖记录以及编译产物 exe 与 o 目标文件等,配合 Code::Blocks 打开即可复现加密解密流程。资源围绕 openssl 的 RSA 算法与 base64 编码处理展开,工程中还带有 cscope 索引与 layout 布局文件,便于阅读和跳转代码。目前已有 2929 人学习下载,适合想快速跑通 Windows 下 OpenSSL RSA 示例、排查编译链接问题的开发者参考借鉴。
1. 从一段“加密后自己都解不开”的调试说起
前阵子帮一个做桌面工具的朋友排查问题,他的程序要把本地配置里的敏感字段加密后再落盘,用的是 C++ 调 OpenSSL 的 RSA 接口。现象很典型:加密能跑通,解密却返回空,日志里只有一句RSA_public_decrypt failed。翻他代码一看,公钥私钥用反了,还混着 PEM 和 DER 两种读法。这类问题在“openssl rsa c++ 加密解密”这个组合里几乎天天出现——接口不难,难的是密钥格式、填充模式、长度边界这些细节。这份 Windows 版的 RSA 加密解密工程,就是把这些坑一次性趟平的最小可跑样例:用 C++ 封装 OpenSSL 的 EVP 接口,完成密钥生成、公钥加密、私钥解密、私钥签名、公钥验签的完整闭环,编译环境是 Windows + Visual Studio,适合刚接触密码学落地、又不想被一堆抽象层绕晕的开发者直接拿来改。下面按“能干什么 → 怎么跑 → 怎么改”的顺序拆。
2. 工程结构与技术选型:为什么是 OpenSSL EVP 而不是裸 RSA 接口
2.1 目录与文件职责
这个工程刻意做小,核心就几个文件,职责切得很干净,方便你按需替换。常见做法是下面这种布局:
rsa_demo/ ├── include/ │ ├── rsa_util.h # 对外接口声明 │ └── base64_util.h # Base64 编解码(密文落盘/传输用) ├── src/ │ ├── rsa_util.cpp # RSA 加解密、签名验签实现 │ ├── base64_util.cpp # Base64 实现 │ └── main.cpp # 命令行入口,演示完整流程 ├── keys/ │ ├── public.pem # 公钥 │ └── private.pem # 私钥 ├── third_party/ │ └── openssl/ # Windows 下的 OpenSSL 库与头文件 └── CMakeLists.txt # 或 .vcxprojrsa_util是唯一需要你重点读的模块,main.cpp只是把接口串起来跑一遍。密钥放keys/而不是硬编码进源码,是为了后面换成动态加载时不用改代码结构。third_party/openssl单独放,是因为 Windows 上 OpenSSL 的编译产物和 Linux 差异大,隔离出来换版本最省事。
2.2 技术栈与选型理由
技术栈很朴素:C++11 及以上、OpenSSL 1.1.1 或 3.x、Windows 下用 MSVC 编译,构建工具 CMake 或直接 VS 工程都行。选 OpenSSL 而不是自己撸大数运算,理由不用多说;真正值得说的是为什么用 EVP 接口而不是RSA_public_encrypt这类低层接口。
低层RSA_*接口在 OpenSSL 3.x 里已经被标记为 deprecated,编译会刷一堆警告,而且填充模式要自己传参,稍不注意就用了不安全的RSA_PKCS1_PADDING裸模式。EVP 接口把填充、摘要、密钥都收进EVP_PKEY_CTX上下文里,换算法(比如以后想上 ECC)时调用方式几乎不变。代价是 API 稍微绕一点,但这份工程已经把绕的部分封装好了。
提示:如果你拿到的是老项目,里面全是
RSA_public_encrypt,别急着全改,先确认 OpenSSL 版本。1.1.1 下低层接口还能用,3.x 下建议逐步迁到 EVP,否则未来升级会翻车。
2.3 密钥格式:PEM 与 DER 的取舍
工程默认用 PEM(文本格式,带-----BEGIN PUBLIC KEY-----头尾),因为调试时能直接cat出来看,出问题好排查。DER 是二进制,体积小但肉眼不可读。读取时两者走不同函数:PEM 用PEM_read_bio_PUBKEY,DER 用d2i_PUBKEY。这一点在后面的代码里会体现,也是新手最容易混的地方——拿 PEM 文件去喂 DER 解析函数,返回 NULL,然后一脸懵。
3. 核心模块拆解:密钥生成到加解密验签的完整链路
3.1 密钥对生成与落盘
先有密钥才能谈加解密。下面这段生成 2048 位 RSA 密钥对并写成 PEM 文件的代码,是整个流程的起点:
#include <openssl/evp.h> #include <openssl/pem.h> #include <openssl/rsa.h> // 生成 RSA 密钥对并写入 PEM 文件 bool generate_rsa_keypair(const char* pub_path, const char* pri_path, int bits) { // 1. 创建 EVP_PKEY 上下文,指定 RSA 算法 EVP_PKEY_CTX* ctx = EVP_PKEY_CTX_new_id(EVP_PKEY_RSA, nullptr); if (!ctx) return false; EVP_PKEY* pkey = nullptr; bool ok = false; do { // 2. 初始化并设置密钥长度 if (EVP_PKEY_keygen_init(ctx) <= 0) break; if (EVP_PKEY_CTX_set_rsa_keygen_bits(ctx, bits) <= 0) break; // 3. 真正生成密钥 if (EVP_PKEY_keygen(ctx, &pkey) <= 0) break; // 4. 写公钥(PEM) FILE* fp = fopen(pub_path, "wb"); if (!fp) break; PEM_write_PUBKEY(fp, pkey); fclose(fp); // 5. 写私钥(PEM,不加密) fp = fopen(pri_path, "wb"); if (!fp) break; PEM_write_PrivateKey(fp, pkey, nullptr, nullptr, 0, nullptr, nullptr); fclose(fp); ok = true; } while (0); EVP_PKEY_free(pkey); EVP_PKEY_CTX_free(ctx); return ok; }逻辑说明:EVP_PKEY_CTX_new_id(EVP_PKEY_RSA, nullptr)创建一个专门给 RSA 用的上下文,第二个参数是引擎,传nullptr表示用默认实现。EVP_PKEY_keygen_bits设位数,2048 是当前兼顾安全和性能的常见选择,1024 已经不建议用于新项目。PEM_write_PrivateKey的第三个参数是加密回调,传nullptr表示私钥明文落盘——生产环境应该传密码回调加密私钥,这里为了演示简单先省掉,后面改造章节会说怎么补。
参数说明:bits建议 2048 或 4096;4096 生成慢、加解密也慢,桌面工具一般 2048 够用。pub_path/pri_path用绝对路径更稳,Windows 下相对路径受工作目录影响,VS 里调试时工作目录默认是工程目录,容易和预期不一致。
3.2 公钥加密:EVP 封装的正确姿势
加密用公钥,解密用私钥,这是 RSA 的基本方向。下面这段是公钥加密的核心:
#include <vector> #include <openssl/evp.h> #include <openssl/pem.h> // 用公钥加密,返回密文字节 std::vector<unsigned char> rsa_encrypt(const std::vector<unsigned char>& plain, const char* pub_path) { std::vector<unsigned char> cipher; BIO* bio = BIO_new_file(pub_path, "rb"); if (!bio) return cipher; EVP_PKEY* pkey = PEM_read_bio_PUBKEY(bio, nullptr, nullptr, nullptr); BIO_free(bio); if (!pkey) return cipher; EVP_PKEY_CTX* ctx = EVP_PKEY_CTX_new(pkey, nullptr); do { if (EVP_PKEY_encrypt_init(ctx) <= 0) break; // 关键:指定 OAEP 填充,比 PKCS1 更安全 if (EVP_PKEY_CTX_set_rsa_padding(ctx, RSA_PKCS1_OAEP_PADDING) <= 0) break; size_t outlen = 0; // 第一次调用只为拿输出长度 if (EVP_PKEY_encrypt(ctx, nullptr, &outlen, plain.data(), plain.size()) <= 0) break; cipher.resize(outlen); if (EVP_PKEY_encrypt(ctx, cipher.data(), &outlen, plain.data(), plain.size()) <= 0) { cipher.clear(); break; } cipher.resize(outlen); } while (0); EVP_PKEY_CTX_free(ctx); EVP_PKEY_free(pkey); return cipher; }逻辑说明:EVP 的加解密都是“两次调用”模式——第一次传nullptr拿输出缓冲区长度,第二次才真正写数据。这是新手最容易漏的一步,直接传固定长度缓冲区会失败或截断。RSA_PKCS1_OAEP_PADDING是重点,OAEP 带随机化,同样的明文每次加密结果不同,能防重放和部分选择密文攻击;老代码里常见的RSA_PKCS1_PADDING已经不被推荐。
参数说明:plain的长度有硬上限。2048 位密钥配 OAEP(SHA-1)时,单次最多加密 2048/8 - 2*20 - 2 = 214 字节。超过这个长度必须分块,或者更常见的做法——用 RSA 加密一个对称密钥(如 AES 密钥),再用对称密钥加密大文件。这份工程演示的是短数据场景,长数据改造思路在最后一章讲。
3.3 私钥解密与签名验签
解密是加密的镜像,把公钥换成私钥、encrypt换成decrypt即可,填充模式必须和加密端一致,否则解出来是乱码或直接失败:
std::vector<unsigned char> rsa_decrypt(const std::vector<unsigned char>& cipher, const char* pri_path) { std::vector<unsigned char> plain; BIO* bio = BIO_new_file(pri_path, "rb"); if (!bio) return plain; EVP_PKEY* pkey = PEM_read_bio_PrivateKey(bio, nullptr, nullptr, nullptr); BIO_free(bio); if (!pkey) return plain; EVP_PKEY_CTX* ctx = EVP_PKEY_CTX_new(pkey, nullptr); do { if (EVP_PKEY_decrypt_init(ctx) <= 0) break; if (EVP_PKEY_CTX_set_rsa_padding(ctx, RSA_PKCS1_OAEP_PADDING) <= 0) break; size_t outlen = 0; if (EVP_PKEY_decrypt(ctx, nullptr, &outlen, cipher.data(), cipher.size()) <= 0) break; plain.resize(outlen); if (EVP_PKEY_decrypt(ctx, plain.data(), &outlen, cipher.data(), cipher.size()) <= 0) { plain.clear(); break; } plain.resize(outlen); } while (0); EVP_PKEY_CTX_free(ctx); EVP_PKEY_free(pkey); return plain; }签名验签走的是另一条链路:私钥签名、公钥验签,用的是EVP_DigestSign系列。签名前要先对数据做摘要(如 SHA-256),RSA 只负责对摘要值做运算。常见做法是:
// 私钥签名:先 SHA-256 摘要,再用 RSA 私钥签 bool rsa_sign(const std::vector<unsigned char>& data, const char* pri_path, std::vector<unsigned char>& sig) { BIO* bio = BIO_new_file(pri_path, "rb"); EVP_PKEY* pkey = PEM_read_bio_PrivateKey(bio, nullptr, nullptr, nullptr); BIO_free(bio); if (!pkey) return false; EVP_MD_CTX* mctx = EVP_MD_CTX_new(); bool ok = false; do { if (EVP_DigestSignInit(mctx, nullptr, EVP_sha256(), nullptr, pkey) <= 0) break; size_t siglen = 0; if (EVP_DigestSign(mctx, nullptr, &siglen, data.data(), data.size()) <= 0) break; sig.resize(siglen); if (EVP_DigestSign(mctx, sig.data(), &siglen, data.data(), data.size()) <= 0) break; sig.resize(siglen); ok = true; } while (0); EVP_MD_CTX_free(mctx); EVP_PKEY_free(pkey); return ok; }验签把DigestSign换成DigestVerify,公钥读取换成PEM_read_bio_PUBKEY,返回值大于 0 表示验签通过。这里有个容易忽略的点:签名和加密虽然都用 RSA,但用途完全不同——加密保证机密性,签名保证完整性和身份,别把两者混用。工程里两条链路分开封装,就是为了避免这种混淆。
3.4 密文落盘:Base64 编解码
RSA 输出的是二进制,直接写文件或塞进 JSON 会出问题(JSON 不支持裸二进制,文本编辑器打开是乱码)。工程里用 Base64 转一道,落盘和传输都安全。OpenSSL 自带EVP_EncodeBlock/EVP_DecodeBlock,不用自己实现:
#include <openssl/evp.h> #include <string> std::string base64_encode(const std::vector<unsigned char>& in) { std::string out; out.resize(4 * ((in.size() + 2) / 3)); int len = EVP_EncodeBlock(reinterpret_cast<unsigned char*>(&out[0]), in.data(), static_cast<int>(in.size())); out.resize(len); return out; }逻辑说明:4 * ((n + 2) / 3)是 Base64 输出长度的标准算法,向上取整到 4 的倍数。EVP_EncodeBlock返回实际写入长度,再resize一次去掉多余空间。解码用EVP_DecodeBlock,注意它返回的长度包含填充的=,实际数据长度要自己减去尾部=的数量,这个坑后面避坑章节会细说。
4. Windows 下编译与运行:从零把工程跑起来
4.1 OpenSSL 库的准备
Windows 上最省事的路径是用 vcpkg:vcpkg install openssl:x64-windows,装完在 CMake 里find_package(OpenSSL REQUIRED)就能用。如果不想引入包管理器,就去 OpenSSL 官方下预编译的 Windows 二进制包,把头文件放third_party/openssl/include,库文件(libcrypto.lib、libssl.lib)放third_party/openssl/lib,运行时把对应的 DLL 拷到 exe 同目录。
注意:32 位和 64 位库不能混用。VS 工程默认可能是 Win32,而你下的是 x64 库,链接时会报一堆
LNK2019未解析符号,看着像代码问题,其实是架构不匹配。
CMake 配置大致如下:
cmake_minimum_required(VERSION 3.15) project(rsa_demo CXX) set(CMAKE_CXX_STANDARD 11) find_package(OpenSSL REQUIRED) add_executable(rsa_demo src/main.cpp src/rsa_util.cpp src/base64_util.cpp ) target_include_directories(rsa_demo PRIVATE include) target_link_libraries(rsa_demo PRIVATE OpenSSL::Crypto)只链OpenSSL::Crypto就够,RSA 相关接口都在 libcrypto 里,libssl 是 TLS 用的,这个工程用不上。
4.2 编译与首次运行
用 VS 打开 CMake 工程,或者命令行:
cmake -B build -A x64 cmake --build build --config Release-A x64明确指定 64 位,避免默认 Win32 和库不匹配。编译通过后,先跑密钥生成,再跑加解密演示。main.cpp里的流程一般是:检查keys/下有没有密钥,没有就生成;然后读一段测试明文,加密、Base64、打印,再 Base64 解码、解密、打印原文,最后做一次签名验签。
运行成功的标志是:加密后的 Base64 串每次都不一样(OAEP 随机化),但解密回来和原文完全一致;验签返回通过。如果解密出来是空,先看填充模式两端是否一致,再看密钥有没有用反——这两个占了新手问题的八成。
4.3 用命令行验证密钥是否可用
有时候代码没问题,是密钥文件本身坏了。用 OpenSSL 命令行快速验证:
openssl rsa -in keys/private.pem -check -noout openssl pkey -in keys/public.pem -pubin -text -noout第一条检查私钥完整性,输出RSA key ok才算正常;第二条打印公钥信息,能看到模数和指数。如果第一条报unable to load Private Key,多半是文件被当文本编辑过、换行符被改,或者根本不是 PEM 格式。这一步能帮你把“代码问题”和“密钥问题”快速分开,省下大量瞎猜时间。
5. 避坑与排查:RSA 落地时最容易翻车的五个点
5.1 解密返回空,日志只有一句失败
现象:加密正常,解密返回空 vector,EVP_PKEY_decrypt返回 0 或负数。原因通常有三个:公钥私钥用反、填充模式两端不一致、密文在 Base64 编解码时长度算错。解决顺序是先打印两端用的 padding 值确认一致,再确认加密用的是PEM_read_bio_PUBKEY、解密用的是PEM_read_bio_PrivateKey,最后检查 Base64 解码后的长度是否等于密钥字节数(2048 位密钥密文固定 256 字节)。这三步能定位绝大多数解密失败。
5.2 明文稍长就加密失败
现象:短字符串能加密,稍微长一点就失败。原因:RSA 单次加密长度有硬上限,2048 位配 OAEP 只有 214 字节。解决:短数据直接加密,长数据改用“RSA 加密 AES 密钥 + AES 加密数据”的混合模式。别试图把 RSA 分块当流密码用,分块 RSA 有安全隐患,而且实现复杂容易出错。
5.3 Base64 解码后尾部多出字节
现象:解密出来的明文末尾多几个\0或乱码。原因:EVP_DecodeBlock返回的长度包含填充字符,实际数据要减去尾部=的个数。解决:解码后统计字符串末尾=数量,从返回长度里减掉。这个坑很隐蔽,因为短数据有时碰巧对齐看不出来,长数据才暴露。
5.4 链接报 LNK2019 未解析符号
现象:编译通过,链接阶段一堆unresolved external symbol。原因:库架构不匹配(32 位工程链 64 位库)、漏链libcrypto.lib、或者 Debug/Release 库混用。解决:确认工程平台和库平台一致,target_link_libraries里带上OpenSSL::Crypto,Debug 配置用带d后缀的库(如果用的是手动配置的库目录)。
5.5 换机器后程序启动就崩
现象:开发机正常,拷到另一台机器双击就闪退。原因:OpenSSL 的 DLL 没跟着走,或者版本不匹配。解决:把libcrypto-3-x64.dll(版本号随 OpenSSL 版本变)拷到 exe 同目录,或者静态链接 OpenSSL(vcpkg 的openssl:x64-windows-static)。静态链接体积大但部署省心,桌面工具我一般选静态。
6. 二次开发:把 demo 改成能进生产的加密模块
拿到这份工程,多数人不会止步于跑通 demo,而是想把它塞进自己的项目。改造方向我按优先级排一下,都是实际项目里绕不开的。
第一件事是私钥加密存储。demo 里私钥明文落盘,生产环境不能这么干。PEM_write_PrivateKey的第三个参数传密码回调即可:
// 用密码加密私钥后再写盘 if (!PEM_write_PrivateKey(fp, pkey, EVP_aes_256_cbc(), (unsigned char*)password, strlen(password), nullptr, nullptr)) { // 处理失败 }密码别硬编码,从系统凭据管理或用户输入拿。Windows 下可以用 DPAPI 再包一层,但那是另一个话题,先把私钥本身加密这步做了。
第二件事是长数据走混合加密。把rsa_encrypt的定位改成“只加密 AES 密钥”,数据本身用EVP_EncryptUpdate走 AES-256-GCM。这样既保留 RSA 的密钥交换能力,又不受长度限制。改造时注意 GCM 的 tag 要一起存,解密时校验 tag 失败就说明数据被篡改。
第三件事是接口收敛与错误码。demo 里失败返回空 vector,调用方分不清是密钥错还是数据错。改成返回enum错误码,配合ERR_get_error()拿 OpenSSL 错误栈,日志里能直接看到具体原因。这一步做完,线上排查效率会明显不一样。
第四件事是密钥轮换。配置文件里存一个key_id,加密时把key_id和密文一起存,解密时按key_id找对应私钥。这样换密钥不用一次性重加密所有历史数据,老数据用老密钥解,新数据用新密钥。
验证改造是否成功,我一般跑三个用例:正常加解密往返、篡改密文后解密必须失败、换错密钥解密必须失败。三个都过,基本可以认为模块是可靠的。
说个自己的教训:早些年我觉得 RSA 接口就那几个函数,封装一层就完事,结果在一个项目里因为 Base64 解码长度没减=,排查了整整一个下午,最后发现是三个字节的\0在作怪。从那以后我每次封装加解密,都强制先写一个“加密→Base64→解码→解密→比对原文”的往返测试,跑通了再接业务逻辑。这个习惯帮我省下的时间,远比写测试花的多。希望这份拆解能帮你少走几步弯路,把工程拉下来跑一遍,再按上面的方向改,基本就能直接用了。
本文还有配套的精品资源,点击获取