ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

AES文件加解密实战:C语言实现CBC/GCM模式与填充方案

AES文件加解密实战:C语言实现CBC/GCM模式与填充方案 简介这是一份使用C编写、实现AES高级加密标准文件加解密功能的源码面向信息安全初学者、密码学课程学生以及需要在项目中快速集成对称加密算法的开发人员。压缩包内共有1个文件为cpp源文件整体大小约4KB代码结构紧凑适合逐行阅读、调试与二次修改。已有243人学习可放在课程设计或算法实验中使用。源码覆盖AES的完整流程包含密钥扩展、初始轮、字节代换、行移位、列混淆、轮密钥加以及最后一轮和逆向解密操作同时给出了二进制文件读写、内存管理和错误处理的具体写法。学习者可以借此理解AES在真实文件加密场景中的落地步骤也能为后续完善加密工具或完成密码学实验报告提供有价值的起点。1. 从 aes.zip 到一套能落到文件上的 AES 加解密方案项目里捡到一个 aes.zip解开只有 aes.c、aes.h 和一段语焉不详的 README任务是把几十 MB 的配置文件加密后再分发——这时候最不该做的是立刻把它嵌进工程里。AES 算法本身只解决“16 字节进、16 字节出”的块变换文件加解密里真正的复杂度全在模式、填充、IV、密钥派生和文件格式上。你会遇到一连串绕不开的问题CBC 还是 CTRPKCS7 填充在满块时为什么还要补 16 字节salt 和 IV 要不要存进文件头以及为什么两次加密同一份文件结果不一样。这些都不是 AES 的数学问题而是工程决策。这篇的思路是先把 AES 的边界条件说清楚再给一套能在本地直接跑通的 C 语言实现最后落到交叉验证和排错上。适合要接手文件加密模块的 C 开发者也适合在嵌入式环境里实现离线加密的人。2. AES 模式与填充选型C 语言实现前先定死三个设计点2.1 为什么 ECB 模式在文件加解密里要避开ECB 是最直观的模式每个 16 字节明文块独立加密互不影响。但它有一个致命特征相同的明文块产生完全相同的密文块。文件场景里这个特征会被放大一个日志文件可能连续出现重复行一张 BMP 位图可能有大量同色像素ECB 会把这种重复结构直接映射到密文里攻击者不需要解密就能看出文件内容的大致轮廓。这不是理论风险是实际可复现的问题。把一张内容分块的图片用 ECB 加密后肉眼能从密文还原出原图的形状因为每个区块的重复模式被保留了下来。对于要长期存放、可能被多次拷贝的文件这不是一个“概率很低”的隐患而是结构性的信息泄露。文件加解密不是网络包那种短时传输场景密文会一直躺在磁盘上可被反复分析。所以我一般会把 ECB 直接排除不让它进入候选清单。它在某些单块加密场景比如加密一个密钥、加密一个 16 字节令牌里完全够用但文件不是单块甚至不是小数据量。选型第一步就是从“能用的模式”里去掉一个。2.2 CBC 与 CTR先只保留两个候选CBC 把每个明文块与前一个密文块异或后再加密解决了重复块泄露问题第一个块则与随机 IV 异或。CTR 是把计数器加密后与明文异或属于流式结构加解密完全对称。两者在文件加密里都是成熟方案但工程特性差别明显。维度AES-CBCAES-CTR填充需要可用 PKCS7不需要IV / Nonce16 字节随机 IV每次加密都要变12 或 16 字节 Nonce保证不重复加密并行性不可并行后块依赖前块密文可并行各块独立错误传播某字节损坏会影响后续两个块某字节损坏只影响对应明文字节认证能力无需另加 HMAC 或改用 GCM无同样需要额外认证注意表里的关键词CBC 的“错误传播”是双向的密文某个块损坏会波及当前块和下一个块但后续块不受影响CTR 则完全不传播。这看起来是 CTR 更优但 CTR 的 Nonce 管理更敏感Nonce 重复意味着两个文件流共享同一个密钥流会直接把异或结果暴露出来。所以选 CTR 时要有一个可靠的随机数源和文件级唯一性保证。这也是“aes 加密模式”里最容易困惑的点模式不是“哪个更强”而是“哪个约束你能满足”。CBC 要求随机 IVCTR 要求唯一 Nonce两者都不是拿来就能用。对绝大多数文件加密模块CBC 是最保守的起点因为它对随机数质量的要求相对宽容实现资料多出问题也好排查。第 3 章就先把它做完整GCM 的认证优势留到第 4 章专门展开。2.3 PKCS7 填充满块也要补 16 字节CBC 要求输入长度是 16 的倍数文件大小通常不满足所以必须填充。PKCS7 的规则是缺几个字节就填几填的每个字节值等于缺的字节数。如果明文本身正好是 16 的倍数按规则补 16 个 0x10如果差 1 个字节就补 1 个 0x01。size_t pkcs7_pad_len(size_t data_len, size_t block_size) { size_t rem data_len % block_size; return rem ? (block_size - rem) : block_size; }这是最容易写错的地方。新手经常在rem 0时返回 0导致文件尾部少写 16 字节。解密侧必须能区分“文件本来没有填充”和“填充了 16 个 0x10”靠的就是满块也填充这一约定。解密返回后要取出最后一个字节pad last_byte先验证它落在 1 到 16 区间再检查末尾连续pad个字节值都等于pad任何一个条件失败都不能当成正常解密结果。这个校验放在EVP_DecryptFinal_ex之外单独做会比较稳。OpenSSL 对填充错误返回 0但错误信息并不总是精确到“哪个字节的填充坏了”自己写一遍校验能更早定位问题。填充校验还牵扯到文件截断如果文件在传输中被截断末尾 16 字节大概率对不上此时解密函数会直接失败这也是 CBC 比 CTR 多出来的一层“意外完整性检查”虽然不能替代认证但至少能挡住一部分偶然损坏。2.4 独立 AES 实现还是 OpenSSL EVP按运行环境选拿到一个 aes.zip 时先别急着把里面的 aes.c 编进工程。要区分两种常见做法。桌面端 Linux、Windows 环境直接使用 OpenSSL 的 EVP 接口嵌入式、RTOS 或无标准库环境下才用独立 AES 实现。独立实现要重点检查三件事支持哪些密钥长度AES-128/192/256 是否都齐加解密表是否占内存Te/Td 表大约需要 4KB 左右有没有配套的已知测试向量。没有测试向量的 AES 源码等于一堆未验证的常量风险极高。OpenSSL EVP 则把这些都封装好了模式、填充、GCM 标签都能在一个上下文里处理而且 OpenSSL 1.1.1 之后的实现普遍带 AES-NI 硬件加速路径性能和代码量都优于手写。也常有人拿 Twofish、ChaCha20 来和 AES 比较。Twofish 在今天的使用面非常有限ChaCha20 在没有 AES 指令的 MCU 上表现不错但桌面 CPU 几乎都带 AES 指令集AES-GCM 在多数场景仍然是最省事的默认项。选型不是找“数学上最强的算法”而是找“当前硬件和工具链维护成本最低的算法”这条规则对文件加密同样成立。3. 用 C 语言跑通 AES-256-CBC 文件加密从命令到 EVP 代码3.1 先用 openssl 命令行验证整个流程动手写 C 之前我一般先用 openssl 命令行把加解密链路完整跑一遍。这样后面写代码时每一步输出都能和命令行结果对拍排错范围会小很多。printf hello aes from c\n plain.txt openssl enc -aes-256-cbc -pbkdf2 -pass pass:blog2024 \ -out cipher.bin plain.txt openssl enc -aes-256-cbc -d -pbkdf2 -pass pass:blog2024 \ -in cipher.bin -out check.txt cmp plain.txt check.txt echo verified这里的-pbkdf2是从口令派生密钥的标准做法迭代次数按 OpenSSL 默认执行-pass pass:blog2024直接给口令另一种方式是用-K传 32 字节十六进制密钥、用-iv传 16 字节 IV后面交叉验证 C 程序时会用到。命令行默认会生成随机 salt所以同一口令两次加密出的文件完全不同这也是标题里“每次加密结果都不一样”的直接答案加密结果的不同来自随机 salt 和随机 IV而不是 AES 本身。后面写 C 程序时我会把 salt 和 IV 都写进文件头让程序在任何平台上都能脱离命令行独立完成解密。命令行这一层只是验证概念和作为参考实现不参与最终产品的文件格式。3.2 文件头设计magic、salt、iv 与版本号文件头和密文要存在同一个文件里否则解密端拿不到 salt 和 IV。我常用的文件头结构是这样typedef struct { uint32_t magic; /* 固定魔数例如 0xAE531001 */ uint32_t version; /* 格式版本第 1 版 */ unsigned char salt[16]; /* PBKDF2 使用的随机盐 */ unsigned char iv[16]; /* AES-CBC 使用的初始向量 */ } aes_file_header_t; /* 共 40 字节 */加 magic 是为了让解密程序能快速判断“这文件是不是我们能认的格式”version 是为了将来从 CBC 升级到 GCM 或改填充方式时还能识别旧文件。salt 是给口令派生用的要与 IV 分开保存两者用途不同。写入时直接用fwrite把结构体按二进制写进文件然后用RAND_bytes分别生成 salt 和 ivRAND_bytes返回 1 才算成功失败时加密应立即终止。文件头这一段必须用固定大小、固定字段顺序。跨架构时要注意 uint32_t 的字节序x86 和 ARM 基本都是小端但显式按字节写入更稳妥static void write_u32(FILE *fp, uint32_t v) { unsigned char b[4]; b[0] (unsigned char)(v 24); b[1] (unsigned char)(v 16); b[2] (unsigned char)(v 8); b[3] (unsigned char)v; fwrite(b, 1, 4, fp); }明文文件本身不要保存任何长度信息密文长度可以由文件大小减文件头后反推填充校验会负责处理尾部对齐问题。有了文件头解密流程就是“读文件头、用 salt 派生 key、用 iv 初始化上下文、读密文体、解密、校验填充”。3.3 最小化的 EVP 加密函数下面是文件加密的核心函数密钥 32 字节对应 AES-256IV 16 字节由上层从文件头读出#include openssl/evp.h #include stdio.h int aes_cbc_encrypt_file(FILE *in, FILE *out, const unsigned char key[32], const unsigned char iv[16]) { EVP_CIPHER_CTX *ctx NULL; unsigned char inbuf[4096]; unsigned char outbuf[4096 EVP_MAX_BLOCK_LENGTH]; size_t rn; int elen 0, tlen 0; ctx EVP_CIPHER_CTX_new(); if (ctx NULL) return -1; if (1 ! EVP_EncryptInit_ex(ctx, EVP_aes_256_cbc(), NULL, key, iv)) goto err; while ((rn fread(inbuf, 1, sizeof(inbuf), in)) 0) { if (1 ! EVP_EncryptUpdate(ctx, outbuf, elen, inbuf, (int)rn)) goto err; fwrite(outbuf, 1, (size_t)elen, out); } if (1 ! EVP_EncryptFinal_ex(ctx, outbuf, tlen)) goto err; fwrite(outbuf, 1, (size_t)tlen, out); EVP_CIPHER_CTX_free(ctx); return 0; err: EVP_CIPHER_CTX_free(ctx); return -1; }EVP_EncryptUpdate可以分批接收明文每批次输出长度不会超过输入长度加一个块的大小所以outbuf要比inbuf多留EVP_MAX_BLOCK_LENGTH字节。fread返回的rn是 size_t传给 EVP 时强转 int 并保证缓冲上限为 4096不会溢出。最后一次EVP_EncryptFinal_ex负责输出最后的 PKCS7 填充块这步不能省否则解密端会报填充错误。解密函数与之对称唯一的差异是EVP_DecryptInit_ex和EVP_DecryptFinal_ex循环体的EVP_DecryptUpdate用法完全一样。所有 EVP 函数的返回值都要检查返回 1 才表示成功0 表示参数错误或状态不对这是 C 语言调用 OpenSSL 最常见的坑。3.4 从口令生成 key 和 ivPBKDF2 的接入方式直接使用 32 字节 key 调试没问题产品里一般由用户口令派生。OpenSSL 提供PKCS5_PBKDF2_HMAC调用一次就能同时生成 key 和 iv#include openssl/evp.h unsigned char key[32]; unsigned char iv[16]; if (1 ! PKCS5_PBKDF2_HMAC(pass, (int)pass_len, salt, 16, 120000, /* 迭代次数 */ EVP_sha256(), 48, key)) { /* 32 字节 key 16 字节 iv */ /* 派生失败终止流程 */ } memcpy(iv, key 32, 16);两个参数要说明。迭代次数 120000 是 2024 年左右的常见下界能在普通 CPU 上跑到 0.1 秒级别调试时可以临时降到 1000 加速但发布前必须调回。EVP_sha256()是伪随机函数不要换成EVP_md5()。派生结果的 48 字节里前 32 字节给 AES-256 当 key后 16 字节给 CBC 当 iv。这个派生关系要严格固定解密端用同一套盐和口令才能得到同一把 key。salt 必须从RAND_bytes生成并写入文件头不能在这段代码里写死同一口令加密不同文件时要保证 salt 不同否则攻击者可以用预计算表加速猜测。文件头里的 salt 不是秘密它只负责防止多文件之间共享同一派生结果。4. 从 CBC 到 GCM文件加解密的分块、认证与性能取舍4.1 4KB 缓冲与 EVP 分批输出的配合第 3 章的示例用 4KB 缓冲循环读文件这个尺寸不是随便选的。4KB 和内存页大小对齐文件系统一次读取能命中页缓存开到 64KB 能减少系统调用次数但对加解密速率影响有限在 NVMe 和高性能 CPU 上瓶颈通常在 OpenSSL 内部的内存拷贝。缓冲区调大时outbuf始终要加EVP_MAX_BLOCK_LENGTH这个上界在 EVP 接口里是硬保证。每次EVP_EncryptUpdate的输出长度等于(输入长度 / 16) * 16不足 16 字节的余量会留在 OpenSSL 内部上下文中等待下一批输入或 Final 时合并。因此只要不关闭上下文边界任意切分都不会丢数据。这也是 EVP 比直接调底层AES_encrypt更适合文件处理的原因底层函数要求整块输入而文件读取不可能保证每次都落在 16 字节边界上。在嵌入式环境里缓冲区大小要结合内存预算。一个 4KB 输入缓冲加 4KB 输出缓冲加上下文结构大约是 9KB 内存对 Flash 只有几十 KB 的 MCU 是可接受的。如果内存更紧张可以降到 512 字节加密速度会慢一些但正确性不受影响EVP 分批处理接口对上界的要求不随缓冲尺寸改变。4.2 GCM 模式为文件末尾加 16 字节 tagCBC 只提供机密性不提供完整性。密文把第 3 个块的第 5 字节翻转一下解密后只有对应块损坏程序未必能发现。文件加密如果没有防篡改需求可以接受但如果配置文件被恶意替换会导致业务逻辑出错。GCM 在 CTR 基础上加了 GHASH 认证一次加密同时输出密文和认证标签EVP_CIPHER_CTX *ctx EVP_CIPHER_CTX_new(); unsigned char iv12[12]; unsigned char tag[16]; unsigned char outbuf[4096 EVP_MAX_BLOCK_LENGTH]; int elen 0, tlen 0; EVP_EncryptInit_ex(ctx, EVP_aes_256_gcm(), NULL, NULL, NULL); EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_AEAD_SET_IVLEN, 12, NULL); RAND_bytes(iv12, sizeof(iv12)); EVP_EncryptInit_ex(ctx, NULL, NULL, key, iv12); /* 循环读文件调 EVP_EncryptUpdate写 outbuf和 CBC 一致 */ EVP_EncryptFinal_ex(ctx, outbuf, tlen); fwrite(outbuf, 1, (size_t)tlen, out); EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_AEAD_GET_TAG, 16, tag); fwrite(tag, 1, sizeof(tag), out); /* tag 追加到文件末尾 */GCM 的 IV 固定 12 字节是推荐值不要再用 16 字节。标签长度用 16 字节这是安全性与文件大小开销的常见折中。解密时先读文件末尾 16 字节拿到 tag同样用EVP_CTRL_AEAD_SET_TAG设置进去正常解完文件后调EVP_DecryptFinal_ex返回值是 1 表示认证通过0 表示密文被篡改或标签不匹配。这一步带出来的错误信息比 CBC 的填充错误更清楚能直接回答“文件是不是被动过”。GCM 和 CBC 的工程对比可以这样收拢项目AES-256-CBCAES-256-GCMIV 长度16 字节12 字节填充必须 PKCS7不需要认证需要额外 HMAC自带 16 字节 tag并行加密不支持支持文件额外开销无16 字节 tag推荐的迁移路径是旧格式 CBC header 结构不动新格式用 GCM 文件头里加一个 mode 字段。这样两种格式能共存解密代码按 version 分流不用一次性推倒重来。4.3 CTR 分块并行与多线程加解密的前置条件如果文件到了 GB 级单线程 AES-GCM 在支持 AES-NI 的 CPU 上已经能跑几百 MB/s通常问题不大。但有些场景要求“边读边算”或者用多线程榨干多核性能CTR 和 GCM 的分块独立性就有了用武之地。分块并行加密需要把文件切成 N 个独立段每段用同一个 key、不同的 nonce/counter 区间最后按偏移写回。块边界必须落在 16 字节整数倍上每段起始位置重新初始化一个 EVP 上下文。这里有个容易出错的地方OpenSSL 的 EVP 上下文一旦开始处理数据就不能再改 IV 或计数器所以每个分片必须EVP_CIPHER_CTX_new()独立创建。CBC 加密不具备这个条件因为后块依赖前块的密文。如果只能在 CBC 和并行之间选一个退化方案是先用 CTR 并行加密全文件再对结果做一次 HMAC。这就是把机密性和完整性拆开处理代码量比直接上 GCM 多但多线程改造灵活得多。大多数文件加密需求用不到这个复杂度先单线程跑通、用 profile 数据说话才是合理的节奏。4.4 嵌入式场景的内存预算与指针安全性在 MCU 上没有 OpenSSL 可用时只能回到第 2.4 节说的独立 AES 实现。独立的 CBC 代码要把三块东西分配清楚AES 密钥扩展后的轮密钥我一般放在上下文中而不是每次加密都重算Te/Td 查找表总共约 4KB加解密缓冲可以是文件系统页大小的整数倍。三者加起来会超过 8KB对内存紧张的设备要提前规划不能在运行时才malloc。指针安全的核心不是 AES 本身而是长度检查。解密入口处必须先验证文件头 magic、文件总长度是否大于 40 字节文件头加一个块再调用解密循环否则恶意构造的 20 字节文件会让EVP_DecryptUpdate读到越界数据或产生截断错误。常见的做法是把文件头解析函数和长度校验函数独立出来加解密入口统一调用不要把长度检查散落在业务代码里。这类细节在嵌入式 C 语言项目里比算法选型更容易引起线上事故。5. 验证加解密结果与定位三个典型失败5.1 用 openssl 命令交叉验证 C 程序的输出C 程序写完后最可靠的自检是让 OpenSSL 命令行来解密自己产出的文件。为了能对接测试模式下建议让程序打印 key 和 iv 的十六进制或者直接从文件头里读出来再转成 hexdd ifcipher.bin ofbody.bin bs1 skip40 statusnone openssl enc -aes-256-cbc -d \ -K 00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff \ -iv deadbeefdeadbeefdeadbeefdeadbeef \ -in body.bin -out restore.bin cmp plain.txt restore.bin echo passskip40跳过的正是文件头字段4 字节 magic、4 字节 version、16 字节 salt、16 字节 iv。-K参数去掉0x前缀必须是 64 个十六进制字符-iv是 32 个十六进制字符。由于固定了 key 和 ivopenssl 解密时不再走 PBKDF2直接进入 AES-CBC 解密流程。如果这里cmp通过说明 C 程序的文件头长度、字节序、加密模式、填充机制全部正确。如果程序是从口令派生 key 的还要加测一组把文件头里的 salt 打印出来用openssl enc -d -pbkdf2 -pass pass:pwd -S salthex命令行解密验证 PBKDF2 派生结果和 C 程序一致。这个测试能排除 key 派生环节的偏差。5.2 三个典型失败现象的定位顺序现象最可能原因验证方法解密前几块乱码文件尾部正常IV 或 key 不匹配打印文件头的 iv对比 OpenSSL 用的值EVP_DecryptFinal_ex返回 0密文被截断、填充被改动或文件格式损坏检查文件总长度减文件头后是否为 16 的倍数C 程序每次结果不同但都不能被外部解密salt/iv 未写入文件头或写入位置错位用xxd cipher.bin查看前 40 字节核对字段顺序第一个现象往往是调试模式里 key 写错了。IV 错误只会让第一块完全错、第二块错一半后面从第三个块开始恢复正常这个特征可以拿来快速判断 IV 没对齐。第二个现象要先看文件尺寸不看报错日志——不是 16 的倍数时填充必然失败此时要回到写入端检查EVP_EncryptFinal_ex是否有输出。第三个现象几乎都是自定义文件头解析问题建议先用固定 key/iv 模式调试让每次密文完全一致再引入随机 salt。在 CI 里把这组交叉验证固化成脚本加解密函数任何一次改动都重新跑一遍比人工对比省事得多。把EVP_DecryptFinal_ex和填充校验的返回值当成测试断言再配合密文长度校验放在调用之前绝大多数文件加解密故障都能在十分钟内定位到具体环节。本文还有配套的精品资源点击获取
返回列表