简介:面向 Qt/C++ 开发者的 AES 加密解密示例工程,解决 Qt 框架不内置对称加密算法、需借助第三方库实现数据保护的问题。AES 作为广泛应用的对称加密标准,支持 128/192/256 位密钥,兼顾安全性与加解密速度;工程基于 Crypto++ 库演示 CBC 模式下的加密与解密流程,覆盖密钥与初始化向量准备、QByteArray 数据转换、流式加密器与解密的完整调用方式,适合希望在 Qt 项目中快速集成 AES 的初中级开发者。压缩包共 5 个文件,包含 AES 封装头文件与实现、主函数演示程序及 Qt 工程配置文件,结构清晰,可直接在 Qt Creator 中打开编译运行。已有 1469 人学习下载,10KB 的小体积下内容精炼、上手门槛低。通过阅读和修改示例代码,可进一步扩展出密钥生成、文件批量加密、安全存储等实用功能,为桌面或嵌入式应用增加可靠的数据保密能力。 做Qt客户端开发的人,迟早会遇到一个需求:把本地配置文件的敏感字段加密,或者把请求数据加密后再发给服务端。我手头的项目里,AES加密就帮了大忙——用户token、数据库连接信息、上报的业务数据,全都走了AES加密通道。今天这篇就专门聊一个问题:在Qt环境里,怎么把AES加密和解密这件事做得既稳妥又顺手。
先说清楚适用场景。如果你是做桌面客户端(Windows/Linux/macOS),需要在本地保存密码、密钥、接口凭证,或者在网络通信中传输敏感数据,那么AES对称加密是性价比最高的方案。文章适合有基础C++和Qt经验的开发者,完全没接触过密码学的朋友也能照着做,我会把分组模式、填充、IV这些容易绕晕的概念用大白话讲明白。
1. 方案选型:在Qt生态里做AES到底该用什么
1.1 为什么内置的QCryptographicHash不够用
很多刚入门的同学会想当然地打开Qt文档找Encrypt类,结果发现Qt只有QCryptographicHash,也就是MD5、SHA-1、SHA-256这些哈希算法。这里必须掰扯清楚:哈希不是加密,它是不可逆的摘要算法。你把"password123"扔进去,出来一串定长十六进制,但没有任何办法从这串字符还原出原文。AES是分组对称加密,用同一个密钥完成加密和解密,属于完全不同的两条技术路线。
所以结论很直接:Qt本身不带AES加解密模块,必须借助第三方库。选型范围主要落在OpenSSL、QCA(Qt Cryptographic Architecture)和Botan三者之间。
1.2 OpenSSL、QCA、Botan三选一怎么定
我最终选了OpenSSL,原因有三。
第一,跨平台能力极其成熟。OpenSSL在Windows、Linux、macOS、嵌入式平台都有预编译包,Qt项目在CMake或qmake里链接非常方便。第二,资料多、社区大,遇到奇怪报错基本都能搜到解决方案。第三,很多Qt发行版(尤其是Linux发行版)本身已经依赖OpenSSL,集成时几乎不增加额外体积。
QCA的优点是封装了更友好的Qt风格API,但底层还是可以换后端,而且文档质量一般,维护节奏偏慢。Botan编码风格干净、功能全,但国内讨论少,出了问题不容易找到人问。从"踩坑成本最低"的角度,OpenSSL是保守且正确的选择。
提示:如果你用的是Qt 5.15.2或Qt 6.x,官方安装包在Windows上通常已经带有OpenSSL动态库(libssl和libcrypto),但版本可能是1.1.x或3.x。代码里推荐统一用EVP接口(后面会写),它对底层版本差异的容忍度很高。
2. AES核心细节:不懂这些,写出来的代码全是坑
2.1 密钥长度与"AES key length"报错
热词里有一条我自己也被坑过的报错:invalid AES key length: 14 bytes。这个问题在Java、Python、C++里都常见,根子在于AES密钥长度必须是128位(16字节)、192位(24字节)或256位(32字节)之一。
很多人在配置里写了一个14字节的字符串,比如"mysecret",又或者写了个QString类型的密码,但QString在UTF-16编码下每个字符占2字节,长度对不上,于是OpenSSL直接拒绝。另一个常见低级错误是拿哈希结果的十六进制文本当密钥用,比如MD5输出32个十六进制字符,看起来像32字节,但实际是32个ASCII字符,还是32字节,可如果底层API当成16字节用就会错乱。
我的原则是:密钥统一按QByteArray处理,长度不足就补足,超长就压到目标长度。最省心的做法是直接用QCryptographicHash::hash(seed, QCryptographicHash::Sha256)生成32字节SHA-256散列作为AES-256密钥,既避免了长度问题,又保证了密钥的随机性。
2.2 分组模式怎么选:ECB/CBC/CTR/GCM
AES一次只能加密16字节的数据块,超过16字节就要用"工作模式"把多个分组串起来。不同模式的安全性和用途差别很大,别随手挑一个就用。
| 模式 | 是否需要IV | 并行处理 | 典型问题 |
|---|---|---|---|
| ECB | 不需要 | 支持 | 相同明文块生成相同密文块,有统计特征,不建议用 |
| CBC | 需要 | 解密可并行 | 最常用,但密文被篡改时仅影响当前块 |
| CTR | 需要 | 加解密都可并行 | 将块密码变成流密码,配合计数器 |
| GCM | 需要 | 可并行 | 同时提供认证,防篡改,推荐用于网络传输 |
我个人在客户端项目里的推荐顺序:网络传输用GCM,本地文件加密用CBC,CTR适合流式场景。ECB哪怕是Demo也不建议,因为它会泄露明文的结构信息——比如加密一张图片,ECB模式还能看出原始图案的轮廓。
CTR模式在热词里出现频率高,它的思路是把计数器加密后与明文异或,本质是构造密钥流。它的优势是加解密完全对称,实现简单,但注意CTR不提供完整性保护,攻击者翻转密文比特会直接影响明文对应比特。
2.3 填充方式:PKCS7到底在干什么
AES按16字节分组,如果明文不是16的倍数,最后一块不满,就要填充。PKCS7的规则很简单:缺几个字节就补几个值为"N"的字节,N等于缺的字节数。比如明文最后一块差5字节才满16字节,就补5个0x05。
所以解密完成后,必须把最后一个字节的值当作填充长度,删掉对应数量的尾字节。这一步骤一旦没做对,你会看到解密结果末尾出现一串\x05\x05\x05\x05\x05,或者干脆乱码。
还要特别提醒:如果明文恰好是16字节的整数倍,PKCS7仍然会填充整整16个字节的0x10。这样解密时才能确定地识别填充。很多初写者会自作聪明地在整块时不填充,反而会导致某些库解密失败。
2.4 IV与盐到底放哪里
CBC、CTR、GCM这些模式都需要一个初始向量IV,长度固定为16字节。IV的作用是让相同明文在不同会话中产生不同密文,避免重放攻击。很多客户端代码直接把IV固定写在代码里,比如0123456789abcdef,这在安全需求高的场景里是不可接受的。
正确的做法是:加密时每次生成随机IV,把IV拼接在密文头部一起保存或传输。解密时先取出前16字节做IV,再用后续字节做密文。随机IV的目的是"语义安全"——同样的明文每次加密得到的密文都不同,攻击者无法通过密文比对判断你发了什么。
至于热词里的"aes加密盐放后端",这个思路是对的。客户端不要把业务密钥硬编码在前端,更安全的做法是客户端只保存一个随机设备ID,真正的AES密钥由服务端下发或通过非对称加密协商。Js代码里逆出硬编码key的案例多到数不清,客户端的道理一样,反编译拿到硬编码密钥只是时间问题。
3. 实操:基于Qt + OpenSSL的AES加解密完整代码
3.1 环境准备与工程配置
我的开发环境是Qt 5.15.2 + MSVC2019 64位,OpenSSL用的是1.1.1w。如果你用的是MinGW,需要注意OpenSSL的二进制版本必须和编译器匹配,否则链接阶段一堆unresolved external symbol。
在.pro文件里添加:
INCLUDEPATH += $$PWD/openssl/include LIBS += -L$$PWD/openssl/lib -lcryptoCMake工程则这样写:
find_package(OpenSSL REQUIRED) target_link_libraries(your_target PRIVATE OpenSSL::Crypto)代码里统一包含EVP接口头文件。EVP是OpenSSL提供的高级加密封装接口,比直接调用AES_set_encrypt_key这类底层函数优雅得多,而且兼容OpenSSL 3.x。
#include <openssl/evp.h> #include <openssl/rand.h> #include <openssl/err.h> #include <QByteArray> #include <QCryptographicHash>3.2 核心类封装:AESCipher
下面这个类是我项目里一直在用的,同时支持CBC和GCM两个模式。密钥统一设为256位。
class AESCipher { public: enum class Mode { CBC, GCM }; static QByteArray encrypt(Mode mode, const QByteArray &key, const QByteArray &iv, const QByteArray &plain) { QByteArray cipher; if (mode == Mode::CBC) { cipher = cbcEncrypt(key, iv, plain); } else if (mode == Mode::GCM) { cipher = gcmEncrypt(key, iv, plain); } return cipher; } static QByteArray decrypt(Mode mode, const QByteArray &key, const QByteArray &iv, const QByteArray &cipher) { QByteArray plain; if (mode == Mode::CBC) { plain = cbcDecrypt(key, iv, cipher); } else if (mode == Mode::GCM) { plain = gcmDecrypt(key, iv, cipher); } return plain; } private: static QByteArray cbcEncrypt(const QByteArray &key, const QByteArray &iv, const QByteArray &plain) { QByteArray out(plain.size() + 16, Qt::Uninitialized); int outLen = 0; EVP_CIPHER_CTX *ctx = EVP_CIPHER_CTX_new(); EVP_EncryptInit_ex(ctx, EVP_aes_256_cbc(), nullptr, reinterpret_cast<const unsigned char *>(key.constData()), reinterpret_cast<const unsigned char *>(iv.constData())); EVP_EncryptUpdate(ctx, reinterpret_cast<unsigned char *>(out.data()), &outLen, reinterpret_cast<const unsigned char *>(plain.constData()), plain.size()); int finalLen = 0; EVP_EncryptFinal_ex(ctx, reinterpret_cast<unsigned char *>(out.data() + outLen), &finalLen); out.resize(outLen + finalLen); EVP_CIPHER_CTX_free(ctx); return out; } static QByteArray cbcDecrypt(const QByteArray &key, const QByteArray &iv, const QByteArray &cipher) { QByteArray out(cipher.size() + 16, Qt::Uninitialized); int outLen = 0; EVP_CIPHER_CTX *ctx = EVP_CIPHER_CTX_new(); EVP_DecryptInit_ex(ctx, EVP_aes_256_cbc(), nullptr, reinterpret_cast<const unsigned char *>(key.constData()), reinterpret_cast<const unsigned char *>(iv.constData())); EVP_DecryptUpdate(ctx, reinterpret_cast<unsigned char *>(out.data()), &outLen, reinterpret_cast<const unsigned char *>(cipher.constData()), cipher.size()); int finalLen = 0; EVP_DecryptFinal_ex(ctx, reinterpret_cast<unsigned char *>(out.data() + outLen), &finalLen); out.resize(outLen + finalLen); EVP_CIPHER_CTX_free(ctx); return out; } static QByteArray gcmEncrypt(const QByteArray &key, const QByteArray &iv, const QByteArray &plain) { QByteArray out(plain.size() + 16, Qt::Uninitialized); int outLen = 0; EVP_CIPHER_CTX *ctx = EVP_CIPHER_CTX_new(); EVP_EncryptInit_ex(ctx, EVP_aes_256_gcm(), nullptr, nullptr, nullptr); EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_SET_IVLEN, iv.size(), nullptr); EVP_EncryptInit_ex(ctx, nullptr, nullptr, reinterpret_cast<const unsigned char *>(key.constData()), reinterpret_cast<const unsigned char *>(iv.constData())); EVP_EncryptUpdate(ctx, reinterpret_cast<unsigned char *>(out.data()), &outLen, reinterpret_cast<const unsigned char *>(plain.constData()), plain.size()); QByteArray tag(16, Qt::Uninitialized); EVP_EncryptFinal_ex(ctx, reinterpret_cast<unsigned char *>(out.data() + outLen), &outLen); EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_GET_TAG, 16, tag.data()); EVP_CIPHER_CTX_free(ctx); return out.left(outLen) + tag; } static QByteArray gcmDecrypt(const QByteArray &key, const QByteArray &iv, const QByteArray &cipherWithTag) { QByteArray tag = cipherWithTag.right(16); QByteArray cipher = cipherWithTag.left(cipherWithTag.size() - 16); QByteArray out(cipher.size(), Qt::Uninitialized); int outLen = 0; EVP_CIPHER_CTX *ctx = EVP_CIPHER_CTX_new(); EVP_DecryptInit_ex(ctx, EVP_aes_256_gcm(), nullptr, nullptr, nullptr); EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_SET_IVLEN, iv.size(), nullptr); EVP_DecryptInit_ex(ctx, nullptr, nullptr, reinterpret_cast<const unsigned char *>(key.constData()), reinterpret_cast<const unsigned char *>(iv.constData())); EVP_DecryptUpdate(ctx, reinterpret_cast<unsigned char *>(out.data()), &outLen, reinterpret_cast<const unsigned char *>(cipher.constData()), cipher.size()); EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_SET_TAG, 16, tag.data()); int finalLen = 0; int ret = EVP_DecryptFinal_ex(ctx, reinterpret_cast<unsigned char *>(out.data() + outLen), &finalLen); EVP_CIPHER_CTX_free(ctx); if (ret <= 0) { return QByteArray(); // 认证失败,密文被篡改或密钥错误 } out.resize(outLen + finalLen); return out; } };3.3 调用示例:随机IV + Base64传输
密钥用SHA-256从种子字符串派生的方式,IV每次加密都随机生成,然后把"IV + 密文"整体做Base64,方便以文本形式保存或放到JSON里传输。
QByteArray deriveKey(const QString &seed) { return QCryptographicHash::hash(seed.toUtf8(), QCryptographicHash::Sha256); } QByteArray generateIV(int length = 16) { QByteArray iv(length, Qt::Uninitialized); RAND_bytes(reinterpret_cast<unsigned char *>(iv.data()), length); return iv; } QString encryptToString(const QString &plainText, const QByteArray &key) { QByteArray iv = generateIV(); QByteArray cipher = AESCipher::encrypt(AESCipher::Mode::CBC, key, iv, plainText.toUtf8()); return (iv + cipher).toBase64(); } QString decryptFromString(const QString &payload, const QByteArray &key) { QByteArray raw = QByteArray::fromBase64(payload.toUtf8()); QByteArray iv = raw.left(16); QByteArray cipher = raw.mid(16); QByteArray plain = AESCipher::decrypt(AESCipher::Mode::CBC, key, iv, cipher); return QString::fromUtf8(plain); }用起来很简单:
QByteArray key = deriveKey("我的项目密钥种子,不要写硬编码"); QString encrypted = encryptToString("hello world 你好,Qt AES", key); qDebug() << "encrypted:" << encrypted; QString decrypted = decryptFromString(encrypted, key); qDebug() << "decrypted:" << decrypted;输出里能看到密文每次都不一样,因为IV是随机生成的,这正是前面说的"语义安全"。
3.4 GCM模式下的认证标签
GCM模式比其他模式多一个16字节的认证标签(tag),用来验证密文在传输过程中有没有被篡改。调用时我把tag拼接在密文尾部一起传输,解密时先拆出尾部16字节做校验。
这个功能的实际意义是防篡改。CBC模式下攻击者虽然看不懂密文,但可以通过翻转密文比特来定向改变解密后的明文内容。GCM加上tag后,任何一位被修改都会导致解密失败并返回空结果,这在对接支付、授权等安全敏感接口时非常关键。
4. 常见问题与排查技巧实录
4.1 解密出来全是乱码
遇到乱码九成是明文编码和填充没对上,其余是IV或密钥不一致。
我建议排查顺序按下面这张表走:
| 症状 | 可能原因 | 排查方法 |
|---|---|---|
解密结果末尾有\x0f等符号 | PKCS7填充未被识别 | 检查解密函数是否将明文按QString::fromUtf8转换 |
| 整体乱码 | 密钥或IV不一致 | 打印key和iv的hex值逐字节比对 |
| 解密结果是空字符串 | GCM tag校验失败 | 确认密钥和IV相同,且tag保持16字节 |
| 只有中文乱码,英文正常 | 编码转换错误 | 加密前统一用toUtf8(),解密后用fromUtf8() |
最容易被忽视的一点是QString和QByteArray的转换。QString::toStdString()在Windows上可能输出的是本地代码页(GBK),而不再是UTF-8,导致加密后的字节流里中文部分前后不一致。统一走toUtf8()和fromUtf8()最省心。
4.2 invalid AES key length错误
这类的报错文字可能是Java的java.security.InvalidKeyException,也可能是OpenSSL的返回码负数,但原因都一样。遇到后不要慌,先做两步检查:
第一,打印key.size(),确认是16、24还是32。第二,确认传入EVP层的指针确实是裸字节,而不是QString的UTF-16编码。
qDebug() << "key size:" << key.size() << key.toHex();如果从配置文件里读取密钥,读取后立刻打印长度和hex,很多时候一眼就能看出问题:多了换行符、被转成了hex字符串、或者文件按UTF-16读写导致每个字符后跟一个\x00。
4.3 AES加密盐(IV/盐)放前端还是后端
能问出"aes加密盐放后端"说明你已经意识到盐不能随便暴露。单一AES密钥如果写死在客户端,破解者逆向拿到密钥只是时间问题。更稳妥的设计是:
- 客户端持有设备级随机数,不直接保存业务密钥;
- 登录或握手阶段通过RSA/ECDH协商出会话AES密钥;
- 每次加密使用随机IV,IV明文随密文传输;
- 服务端在解密后校验格式、时间戳、随机数,防止重放。
这个架构下,AES密钥的生命周期很短,即使某次会话密钥被截获,影响范围也被限制在当次会话内。虽然客户端开发多做一些工作,但对安全要求高的项目是必须的。
4.4 Qt版本与OpenSSL库的兼容坑
Windows下最常见的报错是qt.network.ssl: No TLS backend available,或者程序启动时提示no Qt platform plugin could be initialized,后者虽然看起来和OpenSSL无关,但有时是运行时找不到OpenSSL DLL,导致某些插件初始化异常。
解决方法是把libcrypto-1_1-x64.dll和libssl-1_1-x64.dll(OpenSSL 1.1)或libcrypto-3-x64.dll和libssl-3-x64.dll(OpenSSL 3)复制到exe所在目录。发布时用windeployqt打包,它通常不会自动带上OpenSSL,需要手动处理。
如果你在Linux下遇到undefined reference to EVP_CIPHER_CTX_new,多半是链接顺序问题。GCC的链接器对库顺序敏感,把-lcrypto放在源文件后面,或者CMake里用target_link_libraries把OpenSSL放在target后面,别放在target前面。
4.5 与Java后端互通时千万要注意的三个细节
如果你的Qt程序要和Java服务端对接,双方AES结果不一致通常出在三个地方:
一是AES默认参数差异。Java的AES默认是ECB/PKCS5Padding,OpenSSL的默认是CBC/PKCS7Padding,必须显式指定为AES/CBC/PKCS7Padding,并且方向一致。
二是Base64差异。Java的Base64有标准、URL安全、MIME多种变体,Qt的toBase64()也会输出+和/字符。如果传输走URL参数,记得用URL安全的Base64变体,Java端对应替换。
三是Java的PKCS5Padding和OpenSSL的PKCS7Padding其实完全相同。PKCS5是PKCS7在8字节分组下的子集,AES是16字节分组,两者实现没有区别,所以不要纠结这个名词,只要填充逻辑都是"缺N补N个N"就行。
写在最后的一个小技巧
在调试AES功能时,我习惯用一个"黄金测试向量"来验证代码是否正确,而不是直接用真实业务数据。比如固定密钥为32个0x01,IV为16个0x02,加密一段已知明文,比对输出是否和预期一致。这样可以快速区分是算法调用问题还是业务数据问题,省去大量查日志的时间。
AES本身是个成熟可靠的算法,实际项目中踩坑的点从来没在算法上,而是在密钥管理、编码转换、模式选型这些工程细节上。把上面这些细节处理干净,Qt里的AES加解密用起来就和调用字符串API一样顺手。
本文还有配套的精品资源,点击获取