Delphi XE2 AES加解密单元与Java互通实战
简介:面向需要在Delphi中实现标准AES加解密的开发者,这份源码包聚焦ECB与CBC两种模式、PKCS5Padding填充以及128/192/256位密钥的完整实现,密钥采用0填充,便于与Java端及在线AES工具互相验证,适合做接口加密、数据安全传输和跨语言联调的初中级开发者参考。压缩包共33个文件,约4.4MB,包含pas单元、dcu编译文件、示例工程与窗体、可执行程序、Java对照代码及说明文档等,既可阅读核心算法实现,也能直接运行示例观察结果;其中对BASE64加密返回做了完善,但需注意Base64支持仅在XE及以上版本可用,Delphi7无法直接沿用。已有2170人学习,可作为Delphi与Java互通AES的实用参考,也能帮助读者理解模式、填充和密钥长度对加解密结果的影响。
1. 先把结论说清:这份 AES 单元的价值在“能和 Java 对上”
去年接了个对接活,服务端是 Java,客户端是 Delphi XE2 写的桌面端,接口要求请求体先 AES 加密再 Base64 传过去。两边都显示“加密成功”,密文却怎么都不一致,联调卡了两天。最后靠这份 Delphi XE2 + 标准 AES 加解密算法的单元包,半小时定位到问题——它把 AES/ECB、AES/CBC、PKCS5Padding 填充和 Base64 编码拆成四类能直接调用的函数,参数顺序和 Java 那边Cipher.getInstance("AES/ECB/PKCS5Padding")的写法一一对应,改一行就能把两端的字节流对齐。适合谁:手上维护着 XE2 到 XE7 之间的老工程、需要和 Java/PHP/Go 服务端做对称加密互通、又不想为了一个 AES 引入整包加密库的开发者。ECB 图省事、CBC 要带 IV,两种模式都在里面。
2. 项目结构与技术选型:AES.pas 一个单元撑起四类函数
2.1 目录构成与各文件的职责边界
解压后的工程结构大致是这样,dpr和dproj是 XE2 的工程文件,剩下的.pas才是真正要看的部分:
AESDemo\ AESDemo.dpr // 工程入口,XE2 生成 AESDemo.dproj // 编译配置,含目标平台 Win32/Win64 uMain.pas / uMain.dfm // 演示窗体:明文、密钥、IV、模式、结果 AES.pas // 算法层:S 盒、轮密钥扩展、加解密单块 uAESUtils.pas // 封装层:字符串、编码、填充、模式调度这里的分层是关键,也是我建议保持不动的地方。AES.pas只认字节:输入 16 字节块、输出 16 字节块,密钥长度用位数传进去,它对外不关心你传的是中文还是 JSON。uAESUtils.pas才是业务侧看到的门面,它负责三件事——把 Delphi 的string(XE2 里是 UTF-16 的UnicodeString)转成 UTF-8 字节数组、按块长补齐、把结果用 Base64 编成可打印 ASCII。演示窗体只做参数收集,不碰任何算法。
把算法和编码混在一个函数里,是这类代码最常见的退化方式。一旦混在一起,你想换成 CBC、想加个 Hex 输出、想在服务端换成不带填充的流模式,就得改同一个函数,改一次回归一次。分层之后,AES.pas可以原封不动复制到下一个项目,uAESUtils.pas按业务重写。
XE2 有一点得提前知道:RTL 里没有对称加密实现,System.Hash之类只覆盖哈希,编码侧只有EncdDecd单元的 Base64。所以这份包自带AES.pas,不是重复造轮子,是没得选。常见做法是找一个经过验证的AES.pas放进工程,或者引第三方加密组件;前者体积小、无依赖,后者功能全但要处理部署和授权。这个项目选了前者,代价是必须自己做互通验证。
2.2 为什么偏偏是 ECB/CBC + PKCS5Padding + Base64 这套组合
这套组合不是算法上的最优解,是兼容性上的最优解。原因很直接:服务端那套 Java 代码里写的就是这三样,客户端没有选择权。
分组加密算法的一个硬约束是明文长度必须是块长的整数倍,AES 的块长固定 16 字节。明文字节数不可能刚好都是 16 的倍数,所以必须填充。PKCS5Padding 的做法是补 n 个字节,每个字节的值都等于 n;如果明文本身就是 16 的整数倍,也要额外补满一整块 16 个 0x10。严格来说 PKCS5 只定义了 8 字节块,16 字节块上跑的是 PKCS7,但 Java 的Cipher在 AES 场景下写PKCS5Padding实际执行的就是 PKCS7 行为,这个名称错位坑过不少人,后面第 4 章会展开。
ECB 和 CBC 的差别只在“当前明文块要不要先和前一块密文异或”。ECB 每一块独立加密,同样的明文块永远得到同样的密文块,实现最简单,也不需要 IV;CBC 从第二块起用前一块的密文做异或输入,第一块用外部传入的 IV,安全性好得多,代价是必须传 IV 且解密要按顺序来。这类老接口里 ECB 出现频率极高,因为早期对接文档就那一行AES/ECB/PKCS5Padding,谁也没多想。
Base64 是最后一层。AES 输出的是任意字节,直接塞进 JSON 字符串、HTTP Header 或者表单字段,遇到0x00、引号、换行就会出事。Base64 把 3 字节扩成 4 个 ASCII 字符,代价是体积膨胀约 33%,换来的是全链路安全传输。XE2 上用EncdDecd.EncodeBase64(Pointer, Size)就够了,不需要额外依赖。
提示:如果新接口是你说了算,优先选 CBC 加随机 IV,或者直接用带认证的模式。这份单元保留 ECB 是为了兼容存量,不是为了推荐。
2.3 密钥、IV 与字符集的参数约定
两端能对上,靠的不是算法一致,是这四张参数全部一致。任何一项不同,密文就完全不同,而且报错信息通常只有一个“解密失败”,没有更多线索。
| 参数 | 本项目约定 | 常见的错法 |
|---|---|---|
| 密钥长度 | 16 字节(AES-128),支持 24/32 | 用密码原文当密钥,长度随意 |
| IV 长度 | 16 字节,仅 CBC 需要 | 传了 8 字节,或者 ECB 也传 IV |
| 字符集 | UTF-8 | 一端 UTF-8、一端 GBK |
| 填充方式 | PKCS5Padding(等价 PKCS7) | 一端 NoPadding、一端 PKCS5 |
密钥这一项最容易出问题。业务方给的往往是“一个字符串”,比如abc123,长度 6,不是 16 的倍数。Java 端new SecretKeySpec(keyBytes, "AES")遇到长度不合法的密钥会直接抛InvalidKeyException,所以服务端一定做了某种补齐或哈希。客户端如果自作主张在右边补\0到 16 字节,而服务端做的是 MD5 取前 16 字节,那两边算出来的密钥根本不是一个东西。
我的做法是:拿到密钥字符串后,先问清楚服务端怎么处理,然后把这个处理过程在两个端都用同一段代码实现,并且在文档里写死。这份单元的演示窗体里,密钥输入框做了长度校验,不到 16 字节会提示,而不是悄悄补齐。
IV 同理,CBC 模式下 IV 必须是 16 字节,且加解密两端必须相同。IV 不是机密,可以随密文一起传,但绝不能在 ECB 模式下传——有些实现会把 ECB 收到的 IV 忽略掉,有些会拿它做别的事,行为不统一。
3. 核心模块拆解:一段明文从进入到变成 Base64 的完整链路
3.1 统一入口:把 string、编码、模式收敛到一个函数
uAESUtils.pas对外只暴露两个函数,加密和解密,参数里带上模式和 IV。这样调用方不需要知道底下有几个步骤。
unit uAESUtils; interface uses SysUtils, Classes, EncdDecd, AES; type TAESMode = (amECB, amCBC); // AKey 为密钥原文,内部统一转 UTF-8 字节;AIV 仅 CBC 使用 function AESEncryptStr(const APlain, AKey, AIV: string; AMode: TAESMode): string; function AESDecryptStr(const ACipher, AKey, AIV: string; AMode: TAESMode): string; implementation function AESEncryptStr(const APlain, AKey, AIV: string; AMode: TAESMode): string; var KeyBuf, IVBuf, PlainBuf, CipherBuf: TBytes; begin // 1. 字符串统一走 UTF-8,中英文都按字节算 KeyBuf := TEncoding.UTF8.GetBytes(AKey); PlainBuf := TEncoding.UTF8.GetBytes(APlain); // 2. 密钥 / IV 长度在做业务之前就拦住 if not (Length(KeyBuf) in [16, 24, 32]) then raise Exception.CreateFmt('密钥长度必须是 16/24/32 字节,当前 %d', [Length(KeyBuf)]); if AMode = amCBC then begin IVBuf := TEncoding.UTF8.GetBytes(AIV); if Length(IVBuf) <> 16 then raise Exception.Create('CBC 模式的 IV 必须是 16 字节'); end; // 3. 按模式调度,底层返回已填充的密文 if AMode = amECB then CipherBuf := AESEncryptECB(KeyBuf, PlainBuf) else CipherBuf := AESEncryptCBC(KeyBuf, IVBuf, PlainBuf); // 4. 二进制转可传输文本 Result := EncodeBase64(@CipherBuf[0], Length(CipherBuf)); end;这段代码的价值在顺序。很多人把密钥校验放在加密之后,结果是一个长度不对的密钥在AES.pas里被当成 16 字节读,越界读到相邻的堆内存,程序不崩,密文却每次都不一样,排查起来就是玄学。把校验前置,错误在入口就抛出,堆栈干净。
TEncoding.UTF8.GetBytes在 XE2 上可用,返回TBytes。如果你的工程还在 2007 以前的版本,string是AnsiString,这一步要换成UTF8Encode,但那份代码拿到 XE2 上编译会因为string语义变化出现乱码,不建议跨版本复制。
解码那侧同理,只是顺序反过来:先DecodeBase64得到字节,再按模式解密,再去填充,最后TEncoding.UTF8.GetString。注意EncdDecd.DecodeBase64在 XE2 里返回TBytes,而更早的版本返回string,换版本编译报“不兼容的类型”多半是这个原因。
3.2 PKCS5Padding 的填充与去填充:三行代码定生死
填充逻辑本身很短,短到容易被忽略,但它是两端对不上的头号嫌疑。
// 加密前:按 16 字节块补齐,返回值一定是 16 的整数倍 function PKCS5Pad(const AData: TBytes): TBytes; var PadLen, OldLen: Integer; begin OldLen := Length(AData); PadLen := 16 - (OldLen mod 16); // 刚好整除时得到 16,补满一整块 SetLength(Result, OldLen + PadLen); if OldLen > 0 then Move(AData[0], Result[0], OldLen); FillChar(Result[OldLen], PadLen, Byte(PadLen)); // 每个字节都等于填充长度 end; // 解密后:读最后一个字节,校验并截断 function PKCS5UnPad(const AData: TBytes): TBytes; var PadLen, I, NewLen: Integer; begin if Length(AData) = 0 then raise Exception.Create('密文为空'); PadLen := AData[Length(AData) - 1]; if (PadLen < 1) or (PadLen > 16) then raise Exception.Create('填充字节非法,密码或模式可能不对'); // 逐个校验,防止用错密钥时"恰好"解出合法尾字节 for I := Length(AData) - PadLen to Length(AData) - 1 do if AData[I] <> Byte(PadLen) then raise Exception.Create('填充校验失败'); NewLen := Length(AData) - PadLen; SetLength(Result, NewLen); if NewLen > 0 then Move(AData[0], Result[0], NewLen); end;PadLen := 16 - (OldLen mod 16)这一行是整个填充的核心。当OldLen mod 16 = 0时,16 - 0 = 16,也就是补满一整块,这正是 PKCS5/PKCS7 的规定。如果写成16 - (OldLen mod 16)再判个if PadLen = 16 then PadLen := 0,那就变成了“零填充”,Java 端解出来末尾会多出一串0x10,或者干脆抛BadPaddingException。
去填充那段里的循环校验,是我强烈建议保留的。只用PadLen截断、不校验字节值,在密钥错误的情况下有 1/16 的概率“成功”解出一段垃圾数据,上层拿到一个乱码字符串还以为解密成功了,问题被推迟到业务层才暴露。加上校验,密钥错就立刻报填充失败,定位成本直线下降。
3.3 ECB 与 CBC 的分叉点:只差一个异或
两种模式在AES.pas里的差异非常小,小到可以用一个循环覆盖。
// ECB:每块独立,直接过轮函数 function AESEncryptECB(const AKey, AData: TBytes): TBytes; var Padded: TBytes; I, BlkCount: Integer; begin Padded := PKCS5Pad(AData); BlkCount := Length(Padded) div 16; SetLength(Result, Length(Padded)); AES_Init(@AKey[0], Length(AKey) * 8); // 位数:128/192/256 for I := 0 to BlkCount - 1 do AES_EncryptBlock(@Padded[I * 16], @Result[I * 16]); end; // CBC:加密前先和 IV/前一块密文异或,再加密 function AESEncryptCBC(const AKey, AIV, AData: TBytes): TBytes; var Padded, Prev: TBytes; Block: array[0..15] of Byte; I, J, BlkCount: Integer; begin Padded := PKCS5Pad(AData); BlkCount := Length(Padded) div 16; SetLength(Result, Length(Padded)); Prev := Copy(AIV, 0, 16); // 第一块用 IV AES_Init(@AKey[0], Length(AKey) * 8); for I := 0 to BlkCount - 1 do begin for J := 0 to 15 do Block[J] := Padded[I * 16 + J] xor Prev[J]; // 关键的一步异或 AES_EncryptBlock(@Block[0], @Result[I * 16]); Prev := Copy(Result, I * 16, 16); // 下一块用这块的密文 end; end;看懂这段,CBC 解密的写法就自然出来了:解密时先用密钥解出块,再和前一块密文(第一块用 IV)异或,Prev要取的是“密文输入”而不是“解密输出”,这一步顺序搞反是新手最常见的错误,解出来的第一块正确、后面全乱,现象很有迷惑性。
AES_Init传入的是密钥位数,不是字节数。Length(AKey) * 8这个乘法不能省,传 16 进去会被当成 16 位密钥,轮数算错,密文自然不对。这类底层函数的参数语义,一定要在单元头部注释清楚。
还有一个性能细节:CBC 循环里我用了Copy取前一块,每块分配一次内存。数据量大的时候(比如加密一个几 MB 的文件)这会明显拖慢速度,改成用局部数组Move会快很多。演示工程里数据量小,看得清楚比跑得快重要。
3.4 密钥扩展与轮函数:AES 真正在算什么
AES_EncryptBlock收到 16 字节后做的是:把字节按列排成 4×4 状态矩阵,然后跑 10 轮(AES-128)变换,每轮包含字节代换、行移位、列混合、轮密钥加,最后一轮省掉列混合。工程里用的是查表实现,把字节代换和列混合合并成 4 张 256 项的Te表,一轮只需要 16 次表查找加异或。
// 一轮加密(最后一轮不加列混合),S 为当前状态 4 个字,RK 为轮密钥 T0 := Te0[(S[0] shr 24) and $FF] xor Te1[(S[1] shr 16) and $FF] xor Te2[(S[2] shr 8) and $FF] xor Te3[ S[3] and $FF] xor RK[0]; T1 := Te0[(S[1] shr 24) and $FF] xor Te1[(S[2] shr 16) and $FF] xor Te2[(S[3] shr 8) and $FF] xor Te3[ S[0] and $FF] xor RK[1]; // T2、T3 按同样的循环移位规律展开四个字的取字节位置各不相同,这就是“行移位”在查表实现里的体现,不用单独写一个移位步骤。轮密钥RK由密钥扩展产生:AES-128 扩展成 44 个字,前 4 个就是原始密钥,之后每个字由前一个字递推,每第 4 个字要先做一次字节循环左移、S 盒代换,再异或轮常数Rcon。
这段代码不需要改,也不建议改。真要动,只动AES_Init的密钥长度判断——有些老单元只支持 128 位,传 32 字节密钥进去会算出错误的轮数而不报错。判断方法是拿第 4 章的标准向量跑一遍 256 位用例,对不上就说明这个单元只支持到 128 位。
3.5 Base64 收尾:XE2 上的正确调用姿势
EncdDecd里其实有两组签名,EncodeBase64(const Input: Pointer; Size: Integer): string按字节处理,另一组接受string参数。加密结果必须走前一组。
uses EncdDecd; // 正确:按字节数组编码,不做任何字符集转换 B64 := EncodeBase64(@CipherBuf[0], Length(CipherBuf)); // 危险:参数是 string,会先把字节当成 AnsiString 处理 // B64 := EncodeBase64(RawByteString(CipherBuf));坑在于空数组。@CipherBuf[0]在数组长度为 0 时会越界,虽然加密结果不可能为空(填充至少补 16 字节),但解密侧DecodeBase64遇到空串返回空数组,再往后走就会在@CipherBuf[0]上出事。入口处加一句长度判断,能省掉一次莫名其妙的访问违例。
XE7 之后有了System.NetEncoding.TNetEncoding.Base64,写法更现代,但那份代码拿到 XE2 上编译不过。这个项目锁在 XE2,就老老实实用EncdDecd,不要为了“看起来新”引入版本依赖。
4. Delphi 与 Java 互通:把同一份密钥在两端跑通
4.1 Java 端的对照实现
服务端那几行代码是整个对接的“事实标准”,客户端所有参数都要向它看齐。
public static String encrypt(String plain, String keyStr) throws Exception { byte[] keyBytes = keyStr.getBytes("UTF-8"); // 与 Delphi 侧一致 byte[] plainBytes = plain.getBytes("UTF-8"); SecretKeySpec keySpec = new SecretKeySpec(keyBytes, "AES"); Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding"); cipher.init(Cipher.ENCRYPT_MODE, keySpec); // ECB 不传 IV byte[] out = cipher.doFinal(plainBytes); return Base64.getEncoder().encodeToString(out); } // CBC 版本只改两处:模式和 IV public static String encryptCbc(String plain, String keyStr, String ivStr) throws Exception { byte[] keyBytes = keyStr.getBytes("UTF-8"); byte[] ivBytes = ivStr.getBytes("UTF-8"); Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(keyBytes, "AES"), new IvParameterSpec(ivBytes)); return Base64.getEncoder().encodeToString( cipher.doFinal(plain.getBytes("UTF-8"))); }对着看就能发现,Delphi 侧的AESEncryptStr和这里的encrypt是逐行对应的:getBytes("UTF-8")对应TEncoding.UTF8.GetBytes,SecretKeySpec对应AES_Init的密钥字节,PKCS5Padding对应PKCS5Pad,Base64.getEncoder()对应EncodeBase64。只要这四处一一对齐,密文必然相同。
Base64.getEncoder()是较新 JDK 的写法,老代码里是javax.xml.bind.DatatypeConverter.printBase64Binary或者第三方工具类。三者输出完全一样,都是标准 Base64,不带换行。真正会出差异的是 URL-safe 变体(把+换成-、/换成_),如果服务端用了getUrlEncoder(),Delphi 侧就得自己做字符替换,否则解密时会在 Base64 这一步就失败。
4.2 先用标准向量验证算法本身
联调卡住时,第一件事不是怀疑对方,是确认自己的算法实现是对的。用公开标准文档里公布的那组 AES 测试向量,跟业务代码完全隔离地跑一遍。
| 用例 | 密钥(hex) | IV(hex) | 明文(hex) | 期望密文(hex) |
|---|---|---|---|---|
| AES-128 ECB | 000102...0f | 无 | 00112233...eeff | 69c4e0d8...c55a |
| AES-128 CBC | 2b7e1516...4f3c | 000102...0f | 6bc1bee2...172a | 7649abac...197d |
把这两行做成一个独立的测试过程,输入输出全用十六进制,不经过任何字符串和 Base64。
procedure TestKnownVector; var Key, Plain, Expect, Got: TBytes; begin Key := HexToBytes('000102030405060708090a0b0c0d0e0f'); Plain := HexToBytes('00112233445566778899aabbccddeeff'); Expect:= HexToBytes('69c4e0d86a7b0430d8cdb78070b4c55a'); Got := AESEncryptECB(Key, Plain); // 注意:这里不能带填充 Assert(BytesToHex(Got) = BytesToHex(Expect), 'ECB 向量不匹配'); end;这里有个细节:标准向量里的明文刚好 16 字节,如果AESEncryptECB内部自动做了 PKCS5 填充,结果会变成 32 字节,和期望值对不上。所以测试时要直接调AES_EncryptBlock,或者另写一个不填充的入口。这一点没想清楚,很容易得出“算法是错的”这个错误结论,然后花半天去改一份本来正确的轮函数。
向量过了,说明算法层没问题,剩下的错误一定在编码、填充、参数传递这三层里。这个判断能把排查范围从“整个加密链路”缩到“三行代码”。
4.3 中文、UTF-8 和字节序:对不上先查这三处
英文测试通过、中文就乱,这是最典型的症状。Delphi XE2 的string是 UTF-16,一个汉字占 2 个字节(代理对占 4 个);Java 的String也是 UTF-16,但getBytes("UTF-8")会转成 3 个字节。两边的“字符数”一样,“字节数”也一样,前提是都显式指定了 UTF-8。
如果 Delphi 侧写成Move(S[1], Buf, Length(S))这种按字符直接搬内存的写法,搬出去的是 UTF-16 的字节,一个汉字变 2 字节,而 Java 侧是 3 字节,密文自然对不上,解密出来还会是乱码。判断方法很简单:加密一个纯中文串,看密文长度。UTF-8 下“你好”是 6 字节,填充后 16 字节,Base64 后 24 个字符;如果算出来只有 4 字节填充到 16,那多半是编码用错了。
字节序的问题出现在十六进制输出和某些底层实现上。AES 标准规定状态矩阵按列填充、大端解释,AES.pas里如果用了htonl/ntohl之类的转换,或者把 4 个字节按小端拼成Integer,在 x86 上跑出来的结果会和 Java 不同。Delphi 的TBytes是纯字节序列,只要不经过Integer/Cardinal的强制类型转换,就不会有字节序问题;一旦为了性能把 4 个字节读成一个LongWord,就必须显式处理大小端。
第三处是 BOM。用TEncoding.UTF8.GetBytes不会加 BOM,但如果明文是从文件读的、文件带 BOM,那几个字节会被一并加密,长度和内容都变了。服务端解密出来的字符串前面多出\uFEFF,JSON 解析直接失败。读文件时统一用TEncoding.UTF8并手动跳过前 3 字节,或者明确用无 BOM 的读取方式。
4.4 密钥长度与 256 位的许可限制
AES-128 用 16 字节密钥,AES-192 用 24 字节,AES-256 用 32 字节。Delphi 这边只要AES_Init支持,长度传对就行;Java 那边在旧版本 JDK 上有个额外限制:默认策略只允许 128 位,用 256 位密钥会抛InvalidKeyException: Illegal key size。解决办法是换更新的 JDK,或者装无限制策略文件。这个限制跟代码无关,是运行环境的事,联调时遇到“同样的密钥客户端能加密、服务端解不了”,先确认服务端 JDK 版本。
另一个高频问题是密钥来源。业务方给的经常是 32 位十六进制字符串,比如0123456789abcdef0123456789abcdef,看着像 32 字节,其实是 32 个 ASCII 字符,转成字节还是 32 字节,正好是 AES-256 的密钥长度。但服务端可能是先Hex.decode成 16 字节再用,那两边就差了一倍。这种“看着对、其实不对”的情况,只能靠打印密钥的字节长度和十六进制值来确认,别靠肉眼数。
5. 避坑与排查:五条踩出来的经验
5.1 先看密文长度,它能反推出大半问题
密文长度是可以精确计算的:填充后长度 =(明文字节数 div 16 + 1) * 16,Base64 后长度 =((填充后长度 + 2) div 3) * 4。拿这个公式和实际输出对一下,能立刻排掉一批可能。
比如明文“hello”是 5 字节,填充后 16 字节,Base64 后 24 个字符(末尾带==)。如果客户端算出 24、服务端算出 24 但内容不同,问题在密钥或模式;如果客户端 24、服务端 32,说明服务端多补了一块,多半是填充实现里“整除时不补块”的差异;如果客户端算出 8 个字符,那不是 AES,是 DES 或者某处截断了。
这个方法不需要任何调试工具,打印两个长度就能定位方向,比在两端各加一堆日志快得多。
5.2 五条具体踩坑记录
第一条:解密报“填充校验失败”,但密文长度是对的。现象是长度完全匹配,解出来的字节数也对,就是最后一个字节的值不在 1 到 16 之间。 原因九成是密钥不一致。特别注意密钥字符串里的不可见字符——从配置文件读出来的值可能带尾部空格或换行,Trim一下再比。 解决是把两端的密钥字节数组打印成十六进制,逐字节对比,不要比字符串。字符串看着一样、字节不一样的情况太常见了。
第二条:英文正常,中文解密出来是乱码或抛异常。现象是同一套参数,纯英文往返正常,中文就出错。 原因是编码没统一。Delphi 侧如果用了默认的AnsiString转换,中文会按系统代码页(GBK)编码成 2 字节,Java 侧按 UTF-8 期望 3 字节,长度和内容都对不上。 解决是两端都显式指定 UTF-8,Delphi 用TEncoding.UTF8.GetBytes,Java 用getBytes(StandardCharsets.UTF_8),不要在中间任何一步依赖默认编码。
第三条:ECB 模式传了 IV,结果某些数据解密失败。现象是大部分数据正常,个别数据失败,且失败的总是特定几条。 原因是 ECB 实现里对 IV 参数的处理不一致。有些实现忽略 IV,有些会拿 IV 参与第一块的某种运算,行为取决于具体代码。 解决是 ECB 调用时不要传 IV,接口上就把这个参数去掉,从签名层面杜绝误用。这也是第 3 章里我把 ECB 和 CBC 拆成两个函数的原因。
第四条:EncdDecd.DecodeBase64编译报类型不匹配。现象是同一份代码在同事机器上能编译,在自己机器上报错。 原因是 Delphi 版本差异,这个方法在早期版本返回string,在 XE 之后返回TBytes。 解决是先确认工程用的编译器版本,然后统一按TBytes处理;如果必须兼容多个版本,用{$IFDEF}包一层,别指望它自动适配。
第五条:加密结果里出现+和/,经过 URL 后被改掉。现象是接口在 Postman 里测通了,放到实际请求里就解密失败。 原因是 Base64 的标准字符集包含+和/,在 URL 查询参数里会被转义或者被服务端当成空格。 解决是传输前做 URL 编码,或者改用 URL-safe 的 Base64 变体(-和_),两端约定一致即可。如果服务端已经上线改不了,就在客户端做+→-、/→_的替换,收到时再换回来。
6. 二次开发:把这份单元改成适合自己业务的样子
这份代码能直接跑,但直接塞进业务工程通常会遇到两个诉求:一是想把“密钥管理”从业务代码里抽出去,二是想加一层完整性校验。我按自己的改法说。
第一件事是把密钥和 IV 从函数参数里挪走,收到一个配置对象里。现在AESEncryptStr(APlain, AKey, AIV, AMode)四个参数,调用方每次都要传密钥,密钥就会散落在几十个调用点,将来轮换密钥要改一片。改成一个TAESContext记录,构造时把密钥、IV、模式定好,之后只传数据:
type TAESContext = record Key: TBytes; IV: TBytes; Mode: TAESMode; class function Create(const AKey, AIV: string; AMode: TAESMode): TAESContext; static; function Encrypt(const APlain: string): string; function Decrypt(const ACipher: string): string; end;这样密钥只在初始化时读一次配置文件,业务代码里看不到明文密钥。轮换的时候改配置重启即可,不用重新编译。
第二件事是加校验。纯 CBC 没有完整性保护,攻击者改一个字节的密文,解密出来的明文对应位置也会变,而且填充可能仍然合法,业务层拿到一份被篡改的数据还以为是正常的。低成本的做法是在明文末尾追加一段“业务字段 + 密钥”的哈希,解密后先验哈希再用数据;正规做法是换 AES-GCM,但 GCM 在老版本 Java 上支持不一,跨端成本高。如果服务端能配合,优先推 GCM;不能配合,就加哈希兜底。
第三件事是补测试。这类加密代码最怕“改了一行,看着没错,上线才发现某类数据挂了”。我一般会留一个测试单元,里面放三组固定数据:纯英文、纯中文、中英混合加特殊字符,每组都存好期望的 Base64 密文。任何对AES.pas或uAESUtils.pas的改动,跑一遍测试,三个都过才算改完。这比联调时抓瞎强得多。
还有个小技巧:给uAESUtils加一个调试用的AESDebugInfo函数,返回密钥字节数、IV 字节数、填充后长度、Base64 长度四个值。联调时让对面也打印同样的四项,一比就知道卡在哪一层。这个函数不参与加密,上线前用编译开关关掉即可。
从那以后我每次对接加密接口,都先把标准向量跑通,再打印两端的密钥十六进制和密文长度对比,最后才写业务代码。这三步花不了二十分钟,能省掉两天联调。希望帮到你。
本文还有配套的精品资源,点击获取