Delphi XE2 AES加密实战:ECB/CBC模式与PKCS5Padding填充详解 简介一套基于Delphi XE2实现的标准AES加解密源码支持ECB与CBC模式、PKCS5Padding填充密钥长度覆盖128/192/256位并自动以0补齐返回结果兼容Base64编码。面向需要与Java后端做加密联调的Delphi开发者可直接对照在线AES工具验证互通性。压缩包共33个文件约4.4MB主要包括Delphi单元文件pas/dcu、工程文件dproj/dpr、窗体界面dfm及可执行示例exe另附一份Java测试类AESTest.java用于跨语言比对整体结构清晰便于快速定位算法核心。资源已有2167人学习使用。源码在原作者基础上完善了Base64输出支持并处理了XE及以上版本与Delphi7的差异适合在Delphi XE2及更高版本中集成复用也可作为学习标准AES算法及Delphi-Java加密互通的参考样例。1. 先把标题里的“EBC”纠正过来这条需求真正要你做的三件事如果你是因为对接文档里写着“AES/EBC/PKCS5Padding-base64”而搜到这篇那第一件要做的不是找代码而是先把“EBC”改成“ECB”——这个笔误在真实需求单里反复出现它对应的是电子密码本模式不是某种缩写。拿错模式去联调加密出来的东西对方永远解不开这是最典型的开局翻车。这条标题看着像一个加解密小工具拆开其实是三件事按AES算法对二进制分组、用ECB或CBC模式处理块与块之间的关系、在PKCS5Padding填充后用base64把密文变成可传输的文本。Delphi XE2恰好是老工程里存量很大、加密代码最容易被翻出来的版本很多系统补加密需求都落在这上面。适合读这篇的人有两类一类是手里维护着XE2老工程、要给现有接口补AES加密的开发者另一类是拿到一份跨语言联调文档发现自己写的Delphi代码和对方Java/PHP/Go对不上的工程师。这篇会把标准、可编译代码和踩过的坑一次讲完。2. AES 标准与 Delphi XE2 的选型PKCS5Padding 别名、ECB/CBC 差异与库的选择2.1 一段历史与一个别名PKCS5Padding 在 AES 里就是 PKCS7Padding先说名字。PKCS#5 是早期为 DES 这类 8 字节分组的算法设计的填充规范规则是“缺几个字节就补几个值为几的字节”后来 PKCS#7 把同一套规则推广到任意分组长度AES 的分组是固定的 16 字节所以严格来说应该叫 PKCS7Padding。但 Java 的密码学接口、各类文档里都习惯写 AES/CBC/PKCS5Padding调用底层时实际执行的还是 PKCS7 的规则——因为 AES 没有 5只有 7。在 Delphi 里没有哪个自带组件能直接说“给我来一个 PKCS5Padding”填充逻辑必须自己写而且必须按“16 - (len mod 16)”计算。这里有个老手都容易看漏的点当明文长度刚好是 16 的整数倍时PadLen 算出来是 16也就是要额外补出完整的一块 16 个 0x10如果这时候手一抖写成if len mod 16 0 then PadLen : 0那解密方收到的数据就少了一块PKCS5 校验直接失败。这个细节后面避坑章还会再提这里先立住。理解了填充再看标题里的 base64。AES 加密出来的是二进制字节流可能含不可见字符、换行符、甚至 0x00直接塞进 JSON、数据库或者 URL 参数都会被截断或转义。所以标准做法是加密完了再包一层 base64解密时先解 base64 再做 AES。这一层本身不算算法但它是跨语言联调里最容易出现“明明算法一样却对不上”的地方。2.2 ECB 与 CBC 的差别以及标题为什么把 ECB 放在前面ECB 和 CBC 的差别一句话能说清ECB 把每一块明文独立加密块与块之间没有任何关系CBC 则是让前一块的密文参与下一块的加密第一个块先和一个叫 IV 的初始向量做异或。这个差别带来一个非常直观的后果——ECB 模式下相同的明文块必然产生相同的密文块16 字节的图片或结构化数据加密后你甚至能从密文轮廓猜出原图CBC 则因为前块密文参与了异或相同的明文块在不同位置产生的密文不同。对比项ECBCBC是否需要 IV不需要需要16 字节相同明文块是否产生相同密文会不会密文是否泄露明文结构泄露基本不泄露典型适用场景单块短数据、与老系统兼容通用数据加密、接口传输Delphi 实现方式EncryptBlock 逐块调用EncryptCBC 一次性处理那标题为什么把 ECB 放在前面我见过的大量跨语言需求文档里ECB 往往是被当作“最简单模式”写在前面有些甚至是从十几年前的老接口模板里复制下来的顺手就写成了“EBC”。这不代表推荐你优先用 ECB只代表你需要先去确认对方到底要什么。安全性上凡是明文有重复结构、数据超过一个分组的场景我都建议直接用 CBC顶多多传一个 IV。2.3 常见 Delphi 加密库的取舍DCPcrypt 与 XE2 自带单元的边界Delphi XE2 这条版本线比较微妙IDE 里自带了一些密码学相关的单元能调底层 API但接口偏老、封装极薄写起来像在操作指针网上能查到的可靠资料也少对大多数业务工程师来说性价比不高。社区里最常用、老工程里出现频率最高的是 DCPcrypt它把 Rijndael 算法实现成了标准 AES支持 ECB/CBC/CFB/CTR 多种模式XE2 32 位下直接加进工程就能编译。选择 DCPcrypt 我一般基于三条第一它的 Rijndael 实现和标准 AES 完全对齐密钥长度、分组大小都由算法本身保证第二它只提供分组加解密的底层操作不做自动填充这反而逼着你把 PKCS5Padding 写明白联调时少一层黑匣子第三它在老版本 Delphi 里的兼容性记录很干净几乎不需要改代码。需要注意 DCPcrypt 不是“开箱即填”的库它把填充决策留给你。好处是你能完全掌控字节流代价是如果忘了填充、或者填充规则和对方有半点不一致加密能跑通但对方解不开。我这个章节后面给的完整函数就是把填充、模式、base64 这三层一次封装好避免每次写都踩一遍。3. 用 DCPcrypt 在 XE2 里跑通 AES/ECB 与 AES/CBC完整可编译函数3.1 准备工作把 DCPcrypt2 与 DCPrijndael 两个单元接进 XE2 工程先把库接进来。DCPcrypt 以源码形式分发解压后里面最重要的两个单元是 DCPcrypt2基类与通用枚举定义和 DCPrijndaelAES 算法实现。常见做法是把整个目录拷到工程目录下然后在工程里 Add 这两个 .pas也有的包把基类单元命名为 DCPcrypt.pas引用时跟着改名字就行。uses System.SysUtils, System.Classes, System.NetEncoding, DCPcrypt2, DCPrijndael;这六个单元各管一摊前两个是 Delphi 基础运行时System.NetEncoding 提供 base64 编解码后面两个是 DCPcrypt 的基类和 AES 实现。如果你在单元引用阶段就报找不到 DCPcrypt2多半是文件没加进工程或者路径没配先检查 Project-Options-Directories 里的 Search path。3.2 核心封装填充、两模式加解密与 base64 输出下面这段是我在 XE2 工程里实际用的一套封装一个加密函数一个解密函数通过 AMode 参数切换 ECB 和 CBC。代码里把 PKCS5Padding、UTF-8 字节转换、base64 输出全做了你直接抄进单元里就能跑。function AESEncrypt(const APlainText, AKey, AIV: string; const AMode: TCipherMode): string; var Cipher: TDCP_rijndael; DataBytes, KeyBytes, IVBytes: TBytes; Padded, OutBytes: TBytes; PadLen, KeyLen, I, BlockCount, Offset: Integer; begin // 1. 明文统一转 UTF-8避免 ANSI 编码差异 DataBytes : TEncoding.UTF8.GetBytes(APlainText); // 2. PKCS5PaddingAES 固定 16 字节一块整倍数也要补满一块 PadLen : 16 - (Length(DataBytes) mod 16); SetLength(Padded, Length(DataBytes) PadLen); if Length(DataBytes) 0 then Move(DataBytes[0], Padded[0], Length(DataBytes)); for I : Length(DataBytes) to Length(Padded) - 1 do Padded[I] : PadLen; // 3. 密钥转 UTF-8AES-128 取 16 字节演示代码做了补零/截断 KeyBytes : TEncoding.UTF8.GetBytes(AKey); KeyLen : Length(KeyBytes); if KeyLen 16 then begin SetLength(KeyBytes, 16); for I : KeyLen to 15 do KeyBytes[I] : 0; end else if KeyLen 16 then SetLength(KeyBytes, 16); // 4. CBC 模式必须准备 16 字节 IVECB 模式忽略 IV if AMode cmECB then SetLength(IVBytes, 0) else begin IVBytes : TEncoding.UTF8.GetBytes(AIV); if Length(IVBytes) 16 then raise Exception.Create(CBC 模式要求 16 字节 IV); end; Cipher : TDCP_rijndael.Create(nil); try // 5. 先指定模式再初始化密钥避免部分版本 Reset 时把 IV 清掉 Cipher.CipherMode : AMode; if AMode cmECB then Cipher.InitKey(KeyBytes[0], 128, nil) else Cipher.InitKey(KeyBytes[0], 128, IVBytes[0]); SetLength(OutBytes, Length(Padded)); if AMode cmECB then begin // DCPcrypt 没有现成的 EncryptECB按块循环调用即可 BlockCount : Length(Padded) div 16; for I : 0 to BlockCount - 1 do begin Offset : I * 16; Cipher.EncryptBlock(Padded[Offset], OutBytes[Offset]); end; end else Cipher.EncryptCBC(Padded[0], OutBytes[0], Length(Padded)); finally Cipher.Free; end; // 6. 二进制密文转 base64方便 JSON 和数据库传输 Result : TNetEncoding.Base64.EncodeBytesToString(OutBytes); end; function AESDecrypt(const AEncoded, AKey, AIV: string; const AMode: TCipherMode): string; var Cipher: TDCP_rijndael; DataBytes, KeyBytes, IVBytes, OutBytes: TBytes; PadLen, KeyLen, I, ValidLen: Integer; begin // 1. 先解 base64得到原始密文字节 DataBytes : TNetEncoding.Base64.DecodeStringToBytes(AEncoded); if (Length(DataBytes) 0) or (Length(DataBytes) mod 16 0) then raise Exception.Create(密文长度必须是 16 的倍数且不能为空); // 2. 密钥处理与加密侧保持一致 KeyBytes : TEncoding.UTF8.GetBytes(AKey); KeyLen : Length(KeyBytes); if KeyLen 16 then begin SetLength(KeyBytes, 16); for I : KeyLen to 15 do KeyBytes[I] : 0; end else if KeyLen 16 then SetLength(KeyBytes, 16); if AMode cmECB then SetLength(IVBytes, 0) else begin IVBytes : TEncoding.UTF8.GetBytes(AIV); if Length(IVBytes) 16 then raise Exception.Create(CBC 模式要求 16 字节 IV); end; Cipher : TDCP_rijndael.Create(nil); try Cipher.CipherMode : AMode; if AMode cmECB then Cipher.InitKey(KeyBytes[0], 128, nil) else Cipher.InitKey(KeyBytes[0], 128, IVBytes[0]); SetLength(OutBytes, Length(DataBytes)); if AMode cmECB then begin for I : 0 to (Length(DataBytes) div 16) - 1 do Cipher.DecryptBlock(DataBytes[I * 16], OutBytes[I * 16]); end else Cipher.DecryptCBC(DataBytes[0], OutBytes[0], Length(DataBytes)); finally Cipher.Free; end; // 3. 解析 PKCS5 填充最后一个字节就是填充长度并逐字节校验 PadLen : OutBytes[Length(OutBytes) - 1]; if (PadLen 1) or (PadLen 16) then raise Exception.Create(填充字节非法请检查 Key/IV/模式); for I : Length(OutBytes) - PadLen to Length(OutBytes) - 1 do if OutBytes[I] PadLen then raise Exception.Create(填充校验失败密钥或密文有误); ValidLen : Length(OutBytes) - PadLen; Result : TEncoding.UTF8.GetString(OutBytes, 0, ValidLen); end;对这段代码有四个关键参数要说明。AMode 直接用 DCPcrypt2 里定义的 cmECB 和 cmCBC 两个枚举值传给加密和解密函数时必须一致混合使用会直接解不出来。AKey 在演示代码里做了“不足 16 字节补零、超过 16 字节截断”的处理这是为了让函数在任何输入下都能跑通但正式联调时我建议你直接要求密钥精确为 16 字节并把这段补零逻辑删掉否则密钥长度不对的时候错误会被悄悄掩盖。AIV 仅在 CBC 模式下使用固定 16 字节ECB 模式下传什么都被忽略所以我把 ECB 分支的 IVBytes 直接置空避免误导。填充和解密校验是这段代码的核心。加密侧先算 PadLen再在循环里给新增的每个字节赋这个值解密侧从末尾取一个字节判断填充长度然后检查最后 PadLen 个字节是否全部等于 PadLen。这个校验不是可选项——密钥错误时解密出来的是一个随机尾块有了校验就能直接抛出异常而不是返回一段乱码让你误以为“解密成功了”。很多线上问题都是因为少了这道校验把乱码当数据存进了库。3.3 base64 交换层为什么密文必须过一遍 EncodeBase64看完代码你会发现整个流程是“UTF-8 明文 - AES 密文 - base64 文本”顺序不能反。AES 输出的原始字节是二进制直接放进 JSON 字符串会被转义破坏放进数据库字段又可能被按字符串截断就算你把字节硬塞进参数对方拿到的也未必是原始字节。base64 把这堆二进制重排成 64 个可见字符集任何文本通道都能无损传输。XE2 里做 base64 有两套写法一个是我代码里用的 TNetEncoding.Base64另一个是 Soap.EncdDecd 单元里的 EncodeBase64 函数。两者结果一样我推荐前者因为它在 System 命名空间下不引入额外的 Soap 依赖。解密时用对应的 DecodeStringToBytes注意别用 DecodeStringBase64 这种老重载它返回的是字符串不是字节数组吞进去再转字节会出乱码。base64 还有一个变种坑标准 base64 包含 、/、 三个特殊字符如果密文要放进 URL 参数或者文件名/ 和 可能被服务端解析掉这时候需要把 换成 -、/ 换成 _并去掉末尾的 。大部分接口联调用的是标准 base64只有当对端明确说“URL safe”时才做替换这个判断不要自作主张。4. 与 Java/PHP/Go 联调时最容易翻车的 4 个坑4.1 “EBC”还是“ECB”拼写错误让方案从一开始就对不上现象对接文档写着“AES/EBC”你按 ECB 实现加密、解密在本地都正常发给对方却全部解密失败。对方接口人也坚持说“就是 EBC按文档写的”。原因ECB 是 Electronic Codebook 的缩写EBC 是笔误几乎可以确定是对接文档从旧模板复制时手滑。这类拼写错误危害不在于算法差异而在于双方都拿着错误的共识去对参数越对越乱。解决第一时间把全套参数写清楚发过去——算法、模式、填充、密钥长度、base64 是否启用。我会直接列一行“AES/ECB/PKCS5Padding密钥 16 字节 UTF-8输出 base64”让对端逐项确认。这个动作能过滤掉九成以上的拼写和参数误解。4.2 密钥编码不一致UTF-8 与老工程默认编码的错位现象Delphi 端加密正常Java 端拿到 base64 后解密报“Given final block not properly padded”。密钥里带了一个中文或特殊符号时必现纯 ASCII 密钥时反而不出现。原因XE2 的 string 默认是 Unicode但老工程里大量代码用了 AnsiString 或者依赖当前系统代码页取字节。Java 端统一按 UTF-8 取密钥字节Delphi 端如果用了 ANSI 编码两边拿到的密钥字节不一样算法再标准也解不开。明文也是同理加密前必须和对方确认用 UTF-8 还是 GBK。解决所有字节转换统一走 TEncoding.UTF8代码里取明文、密钥、IV 三个地方都用同一套编码。我在第 3 章函数里写的 GetBytes 全部指定了 UTF-8就是为了杜绝这类“能加密不能解密”的玄学问题。如果你的老工程里某个环节必须保留 ANSI 字符串请至少保证密钥和 IV 在传入加解密函数前已经被转成 TBytes。4.3 长度为 16 的整数倍时PKCS5 也要补满 16 个字节现象解密时报填充错误或者解密出来的明文尾部多出一串不可见的字符。只发生在明文长度恰好为 16、32、48 字节的时候。原因明文长度正好是 16 的倍数时PKCS5 规则要求补一个完整块即 16 个 0x10。有些实现者会想“都满了还补什么”于是跳过填充直接加密解密方却按标准去掉末尾 16 字节结果要么报错要么丢内容。解决填充逻辑里不写任何“整倍数就不补”的例外分支。我代码里 PadLen 的计算是16 - (Length(DataBytes) mod 16)当 mod 结果为 0 时 PadLen 是 16天然补满一块。这行公式是 PKCS5 的唯一正确写法任何试图“优化”它的分支都是在给联调埋雷。4.4 CBC 的 IV 固定不变能通但迟早出问题现象CBC 模式联调全通功能上线后安全测试不过报告里写着“IV 固定导致重放攻击”。另一个更隐蔽的现象是同一个明文用同一个密钥加密多次密文完全相同CBC 的安全性优势完全失效。原因CBC 的安全设计前提是 IV 每次随机IV 固定会让 CBC 退化成确定性加密攻击者可以重放请求、比对密文块推断明文结构。很多联调文档为了图省事把 IV 写成固定字符串两边代码都能跑但漏洞是实打实的。解决密钥不变IV 每次加密随机生成并把 16 字节 IV 拼在密文前一起传输解密时从密文中截出前 16 字节做 IV剩下的才是真正密文。这样对端不需要额外约定 IV只靠密文本身就能解开。这个方案我下一章会给一个最小实现属于安全性和易用性兼顾的常用做法。5. 把 Key/IV/Padding 收敛成一份配置顺便留一组自测用例5.1 一个最小配置结构与随机 IV 方案联调最怕的不是算法写不出来而是参数散落在调用方手里这个函数传一个 Key那个函数传一组固定 IV出了事谁能查清楚。我现在的习惯是建一个 TAESConfig 记录体把算法层面的决定一次性收敛起来业务代码里只传明文和密文。type TAESConfig record Key: TBytes; // 固定 16 字节UTF-8 UseRandomIV: Boolean; // True 时自动生成随机 IVFalse 时用 FixedIV FixedIV: TBytes; // UseRandomIVFalse 时使用 end;如果 UseRandomIV 为 True加密时生成随机 16 字节输出密文前先拼上这 16 字节解密时前 16 字节当 IV、后面才是密文。这样对端拿到的是一条完整的 base64 串不需要额外传 IV 参数风险只是对端必须知道“密文前 16 字节是 IV”这个约定在文档里写清楚就行。用固定的 Key 加密多份数据时这个方案能让密文全部不同同时也让联调参数从“四个字段”减到“一个密钥”。5.2 自测用例写法与联调核对顺序代码抄过去第一步先做本地自测别直接连对端。我的做法是固定一组输入明文Hello AES、密钥0123456789abcdef、CBC 模式、固定 IV0000000000000000加密后立刻解密比对是否还原然后跑 16 字节整倍数的明文比如 32 个A验证填充边界没问题最后再跑一次空字符串确保空输入不会崩。自测通过后再和对端做交叉验证你加密一段固定明文把 base64 结果发给对方解对方加密一段固定明文发给你解。这个顺序能快速隔离问题——如果你能解对方、对方不能解你多半是填充或 base64 的细节如果两边互相解不开先回去核对密钥和 IV 的编码那才是真正的血泪经验区。就这一点我吃过亏的教训是永远不要把“本地加密再解密成功”当成联调通过。本地自测只能证明代码自洽证明不了字节流兼容。我现在的收尾动作永远是拿对端给的密文做一次真实解密通过了才算数。希望帮到你。本文还有配套的精品资源点击获取