资讯动态

跨平台DES与SM4加解密实战:ECB模式统一方案与踩坑记录

发布时间:2026/10/17 15:51:49 来源:尧图企业网站定制
做跨平台数据对接的时候最怕的就是加解密两边各写各的明明算法名一样结果一边能解一边解不开。最近正好做了一个支持Android和PC的DES、SM4加解密小工具DES是老的金融兼容需求SM4是国密标准要求两套算法都得支持ECB模式。这个项目本身不大但跨平台踩坑的密度极高——填充方式、密文编码、字符集、模式参数任何一处没对齐联调就是一场灾难。这篇就围绕这个标题从头梳理一遍核心方案怎么定、两个算法在ECB模式下要注意什么、Android和PC端的代码怎么落地、以及我实际联调中遇到的那些“看起来一样却不对”的问题。如果你正打算在手机端和桌面端打通加密数据或者被国密算法SM4和旧系统DES的兼容问题卡住了这篇可以直接照着参考。1. 项目整体设计思路与方案选型1.1 需求源头为什么需要跨平台加解密这个工具的起因很典型一个业务系统需要同时服务移动端App和PC客户端两端都要和服务端进行敏感数据交互。服务端是老系统接口里约定的是DES ECB模式加密而一些新的国密改造场景里又要求SM4的ECB模式。也就是说同一个客户端有时候要用DES解密老的报文有时候又要用SM4处理新的数据。这种“新旧并存”的需求在存量系统里非常普遍。老的接口用了十多年的DES不太可能一次性全部切换新的合规要求又明文规定必须用国密SM4。所以客户端不能只做一个算法而是要把DES和SM4都做成通用能力再通过统一入口去分发。设计上我没有把两套逻辑写死而是先定了一个抽象层传入算法类型DES或SM4、模式固定ECB、明文或密文、密钥。内部根据算法分发到不同的实现。这样后续即使要加AES或者把ECB换成CBC改动面也只是在分发层加一个分支。1.2 算法选型为什么是DES和SM4为什么用ECB先说DES。DES是数据加密标准密钥长度64位实际有效位56位分组长度64位。放到今天看安全性已经过时从2018年起很多合规标准都不再允许新系统使用DES。但存量系统里DES存量非常大尤其是一些金融历史接口、老版POS机通信协议、工业控制系统的配置指令都还在用DES。所以做兼容适配时躲不开。再说SM4。SM4是国密算法标准定义的对称分组密码算法分组长度128位密钥长度128位。它的设计公开、实现免费在国内政务、金融、企业内部加密改造里已经是事实标准。SM4加解密速度和AES同一水平但生态上早年不如AES普及尤其跨语言、跨平台的时候库的命名和调用方式差异不小容易踩坑。至于ECB模式很多人一听就说“ECB不安全你怎么还用”。这个观点在某些场景下是对的ECB模式下相同的明文分组会产生相同的密文分组明文结构容易被推断。但现实项目里它仍然大量存在原因主要有三个老系统协议固定死了ECB你只能跟着做兼容短数据、随机性强的数据比如令牌、一次性口令、密钥片段用ECB不会暴露明显结构ECB不需要IV跨平台参数最少联调最简单。所以我的处理原则是代码里支持ECB但调用方必须明白它的边界。如果数据本身是高重复性的长文本我会在文档里明确建议升级到CBC或者GCM。工具提供能力使用方对安全性负责。1.3 统一参数约定的重要性跨平台加解密最核心的一件事不是算法本身而是“约定一致”。算法变了谁都跑不出来但约定和算法不一致往往是“单端自测通过联调就挂”。工具在正式写代码前我先定了下面这些约束密钥编码统一UTF-8字符串形式的密钥转字节数组时用UTF-8密钥长度DES 8字节SM4 16字节且只接受刚好这个长度避免填充导致混乱明文编码统一UTF-8密文编码统一Base64字符串不在接口里传原始字节数组填充方式统一PKCS5Padding具体底层行为后面详讲字符集所有字符串-字节转换强制指定UTF-8绝不让系统默认字符集参与。这六个约束看着简单但每一条对应一次踩坑。后面联调之所以顺利就是因为这些约定在编码之前就已经白纸黑字写清楚了。2. 算法原理与模式基础拆解2.1 DES的算法结构与ECB行为DES是Feistel结构的经典分组算法分组大小64位。加密时把64位明文按位拆成左右两半经过16轮迭代每轮通过轮函数F结合48位子密钥做置换、替换和异或操作。解密则是同一套流程的逆向执行密钥编排按相反顺序生成子密钥。ECBElectronic Codebook电子密码本是最简单的工作模式。它把明文按64位一组分割每组独立用同一个密钥加密各组之间没有任何关联。这个过程可以理解为每个人都有同一个固定印章把一段话按固定长度切块每块单独盖章——同样的内容盖出来的章完全一样。ECB的好处是简单、可并行、无IV坏处也正好暴露在这个“同样内容同样密文”上。如果明文有规律性密文也会有规律性。经典例子是加密位图图片ECB模式下图片轮廓依然清晰可见。所以ECB只适合短数据、随机数据的传输保护不适合结构化长文本加密。在Android的Java环境中DES的ECB模式调用JCEJava Cryptography Extension原生支持不需要额外引第三方库。只需要指定“DES/ECB/PKCS5Padding”这个转化字符串系统就会按标准流程执行。2.2 SM4算法结构与实现差异SM4也是一个分组密码分组长度128位密钥长度128位加解密结构是32轮非线性迭代。它的算法结构公开不依赖任何专利授权在国密体系中地位等同于国际算法里的AES。SM4在Java原生JCE里并不直接支持除非某些厂商的JDK增加了国密Provider。所以常见方案是引入第三方库。目前我用过的方案有两套Bouncy Castle轻量级API需要注册Provider或者直接用它的轻量级API类Hutool的SMUtil适合做普通业务加密内部封装了SM4和SM2等对中小项目很友好。我自己在这个工具里是用Bouncy Castle的轻量级API实现的原因是它不依赖全局Provider状态控制力更强。Hutool代码写起来更短但在特殊场景比如要严格控制Provider加载顺序下灵活性略差。SM4在ECB模式下的逻辑和DES在ECB下的逻辑完全一样把128位明文分组每组独立用密钥加密。只是分组关系和密钥长度不同。从工程实现的角度看只要封装好了两种算法的调用姿势几乎可以统一。2.3 ECB、CBC与GCM模式的直观对比为了说清为什么很多场合还要保留ECB而又为什么不建议新系统用它我做了个对比工作模式是否需要IV分组间关联安全强度联调复杂度ECB不需要无关联最低明文结构会泄露最低参数最少CBC需要前后分组链式异或较高同一明文块也能产生不同密文中需要约定IVGCM需要融合认证最高同时提供加密和完整性校验中高需要约定IV和认证标签如果只是内部工具、短随机数据、或者兼容老接口ECB是可行的。但如果是对用户隐私数据做加密或者传输的是结构化业务报文我强烈建议用CBC起步能上GCM更好。工具里保留ECB是兼容需求不代表它是推荐项。3. 跨平台实现细节与核心代码3.1 Android端实现Java KeyGenerator统一入口Android端的实现直接放在一个加密工具箱类里。统一入口方法接收算法名、原文和密钥字符串内部判断走DES还是SM4。整体用Java实现兼容性最好不涉及Kotlin协程的额外依赖。先看DES部分import javax.crypto.Cipher; import javax.crypto.spec.SecretKeySpec; import java.nio.charset.StandardCharsets; import java.util.Base64; public class CryptoBox { private static final String DES_TRANSFORM DES/ECB/PKCS5Padding; public static String desEncrypt(String plainText, String key) throws Exception { byte[] keyBytes key.getBytes(StandardCharsets.UTF_8); if (keyBytes.length ! 8) { throw new IllegalArgumentException(DES密钥长度必须为8字节); } SecretKeySpec keySpec new SecretKeySpec(keyBytes, DES); Cipher cipher Cipher.getInstance(DES_TRANSFORM); cipher.init(Cipher.ENCRYPT_MODE, keySpec); byte[] encrypted cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8)); return Base64.getEncoder().encodeToString(encrypted); } public static String desDecrypt(String cipherText, String key) throws Exception { byte[] keyBytes key.getBytes(StandardCharsets.UTF_8); if (keyBytes.length ! 8) { throw new IllegalArgumentException(DES密钥长度必须为8字节); } SecretKeySpec keySpec new SecretKeySpec(keyBytes, DES); Cipher cipher Cipher.getInstance(DES_TRANSFORM); cipher.init(Cipher.DECRYPT_MODE, keySpec); byte[] decrypted cipher.doFinal(Base64.getDecoder().decode(cipherText)); return new String(decrypted, StandardCharsets.UTF_8); } }注意几个点密钥必须强校验长度DES密钥刚好8字节。如果密钥长度不对JCE会直接抛InvalidKeyException与其等到那里不如在入口就报清楚。Base64.getEncoder()在Android的API级别上兼容性还行但老项目如果还停留API 26以下可能需要换用android.util.Base64。明文和密文分别编码成Base64避免字节数组在跨接口传递时被隐式转码。3.2 SM4实现基于Bouncy Castle轻量级APISM4部分引入Bouncy Castle。Gradle依赖implementation org.bouncycastle:bcprov-jdk18on:1.78.1这里我用的是Bouncy Castle的轻量级API不抢系统Provider的位置适合在多种运行环境下共存import org.bouncycastle.crypto.BlockCipher; import org.bouncycastle.crypto.engines.SM4Engine; import org.bouncycastle.crypto.modes.ECBBlockCipher; import org.bouncycastle.crypto.paddings.PaddedBufferedBlockCipher; import org.bouncycastle.crypto.paddings.PKCS7Padding; import org.bouncycastle.crypto.params.KeyParameter; import java.nio.charset.StandardCharsets; import java.util.Base64; public class Sm4Crypto { private static final int SM4_KEY_LENGTH 16; public static String sm4Encrypt(String plainText, String key) throws Exception { byte[] keyBytes key.getBytes(StandardCharsets.UTF_8); if (keyBytes.length ! SM4_KEY_LENGTH) { throw new IllegalArgumentException(SM4密钥长度必须为16字节); } byte[] input plainText.getBytes(StandardCharsets.UTF_8); PaddedBufferedBlockCipher cipher new PaddedBufferedBlockCipher( new ECBBlockCipher(new SM4Engine()), new PKCS7Padding()); cipher.init(true, new KeyParameter(keyBytes)); byte[] output new byte[cipher.getOutputSize(input.length)]; int len cipher.processBytes(input, 0, input.length, output, 0); len cipher.doFinal(output, len); byte[] result new byte[len]; System.arraycopy(output, 0, result, 0, len); return Base64.getEncoder().encodeToString(result); } public static String sm4Decrypt(String cipherText, String key) throws Exception { byte[] keyBytes key.getBytes(StandardCharsets.UTF_8); if (keyBytes.length ! SM4_KEY_LENGTH) { throw new IllegalArgumentException(SM4密钥长度必须为16字节); } byte[] input Base64.getDecoder().decode(cipherText); PaddedBufferedBlockCipher cipher new PaddedBufferedBlockCipher( new ECBBlockCipher(new SM4Engine()), new PKCS7Padding()); cipher.init(false, new KeyParameter(keyBytes)); byte[] output new byte[cipher.getOutputSize(input.length)]; int len cipher.processBytes(input, 0, input.length, output, 0); len cipher.doFinal(output, len); byte[] result new byte[len]; System.arraycopy(output, 0, result, 0, len); return new String(result, StandardCharsets.UTF_8); } }Bouncy Castle里SM4的ECB模式使用ECBBlockCipher包装SM4EnginePadding用PKCS7Padding。从效果上它与JCE里的PKCS5Padding完全等价因为JCE里所谓的PKCS5Padding在分组密码上实际就是PKCS7的逻辑。注册Provider的方式也可以但我不推荐在这类工具里做全局注册因为部分SDK会修改系统JCE的Provider顺序注册时机不对容易引起其他加密逻辑的NoSuchAlgorithmException。轻量级API把密码组件和Provider状态彻底隔离省心。3.3 PC端实现C#版DES与SM4对照PC端有几种语言场景如果服务端是C#客户端和服务端直接语言对齐是最容易的如果PC端是Java桌面应用那就和Android代码几乎完全复用。我这里以C#版为例写一套对照参考因为C#和Java的加密API命名差异最大联调时的坑也最多。C#的DES加密通常用系统自带的DESCryptoServiceProviderusing System.Security.Cryptography; using System.Text; public static string DesEncrypt(string plainText, string key) { using (var des DES.Create()) { des.Key Encoding.UTF8.GetBytes(key); des.Mode CipherMode.ECB; des.Padding PaddingMode.PKCS7; var encryptor des.CreateEncryptor(); using (var ms new MemoryStream()) { using (var cs new CryptoStream(ms, encryptor, CryptoStreamMode.Write)) { byte[] input Encoding.UTF8.GetBytes(plainText); cs.Write(input, 0, input.Length); cs.FlushFinalBlock(); } return Convert.ToBase64String(ms.ToArray()); } } } public static string DesDecrypt(string cipherText, string key) { using (var des DES.Create()) { des.Key Encoding.UTF8.GetBytes(key); des.Mode CipherMode.ECB; des.Padding PaddingMode.PKCS7; var decryptor des.CreateDecryptor(); using (var ms new MemoryStream(Convert.FromBase64String(cipherText))) using (var cs new CryptoStream(ms, decryptor, CryptoStreamMode.Read)) using (var reader new StreamReader(cs, Encoding.UTF8)) { return reader.ReadToEnd(); } } }C#的SM4没有系统内置实现同样用BouncyCastle的C#版本using Org.BouncyCastle.Crypto.Engines; using Org.BouncyCastle.Crypto.Modes; using Org.BouncyCastle.Crypto.Paddings; using Org.BouncyCastle.Crypto.Parameters; using System.Text; public static string Sm4Encrypt(string plainText, string key) { byte[] input Encoding.UTF8.GetBytes(plainText); byte[] keyBytes Encoding.UTF8.GetBytes(key); var cipher new PaddedBufferedBlockCipher( new EcbBlockCipher(new SM4Engine()), new Pkcs7Padding()); cipher.Init(true, new KeyParameter(keyBytes)); byte[] output new byte[cipher.GetOutputSize(input.Length)]; int len cipher.ProcessBytes(input, 0, input.Length, output, 0); len cipher.DoFinal(output, len); return Convert.ToBase64String(output, 0, len); } public static string Sm4Decrypt(string cipherText, string key) { byte[] input Convert.FromBase64String(cipherText); byte[] keyBytes Encoding.UTF8.GetBytes(key); var cipher new PaddedBufferedBlockCipher( new EcbBlockCipher(new SM4Engine()), new Pkcs7Padding()); cipher.Init(false, new KeyParameter(keyBytes)); byte[] output new byte[cipher.GetOutputSize(input.Length)]; int len cipher.ProcessBytes(input, 0, input.Length, output, 0); len cipher.DoFinal(output, len); return Encoding.UTF8.GetString(output, 0, len); }C#里有一个容易被忽略的差异DES.Create()在不同系统上可能返回不同实现但ECBPKCS7的行为在.NET Framework和.NET Core/5上是一致的前提是显式指定Mode和Padding不要依赖默认值。Java默认是ECB/PKCS5PaddingC#默认不是ECB如果都不显式设置就会很隐蔽地不一致。3.4 为什么SM4的Padding也叫PKCS7很多初学者会困惑Java里写PKCS5PaddingC#里写PKCS7Padding两个都能和SM4配合吗能。因为DES和SM4的分组长度是8字节或16字节而PKCS5Padding标准定义的是针对8字节分组的填充PKCS7Padding则通用地适配不同分组长度。JCE在DES/ECB/PKCS5Padding里实际执行的就是PKCS7的填充规则。所以两端一边写PKCS5一边写PKCS7跑出来结果一致。这个别名陷阱是跨端联调最容易引起恐慌的地方——看到名字不一样就以为对不上。4. 跨端联调踩坑与一致性问题排查4.1 最坑的三种不一致填充、密钥、Base64我个人联调时遇到的第一类问题就是填充设置不一致。有一回Java端默认PCKS5PaddingC#端刚好设了PKCS7因为别名等价所以没事。但有一次某人把C#端改成了PaddingMode.None结果测试数据能过、数据一长就抛异常。原因很简单输入长度不是块大小的整数倍时没有Padding就无法加密如果刚好是整数倍NoPadding也能跑造成一种“某些数据可以”的错觉。密钥长度是第二类问题。DES要求8字节、SM4要求16字节不能多也不能少。很多实现会静默截断或补零导致同一把“密钥字符串”在两端得到不同的实际字节数组。例如“12345678”作为DES密钥是8字节没问题但如果位的密钥是“1234567890”A端可能直接报错B端却悄悄截断用前8字节。这种情况最危险因为B端自测全通过联调怎么都对不上。第三类是Base64。加密结果返回的是Base64字符串但有的团队习惯用Hex有的用Base64接口文档没写清楚结果密文传过去全是乱码。我的建议是所有跨进程、跨语言传递的密文一律用Base64因为它对URL友好度、存储友好度都更高Hex只在调试时用不要在正式接口里混用。4.2 一个经典联调失败案例的定位过程模拟一个场景A端是Android用Java的DES/ECB/PKCS5Padding加密输出Base64B端是C#桌面程序用DES.Create()解密一直报“Length of the data to decrypt is invalid”。排查时第一反应是密文数据被截断但检查Base64解码后的字节长度没问题。接着怀疑是密文合解密的Padding自动去除失败把C#端改为手动去掉填充字节发现最后一位填充字节值竟然是0而不是常规的PKCS7填充字节。再回头查A端代码发现有人在加密前手动做了补位又设置了PKCS5Padding等于填充了两次。解密端只处理一次填充自然报错。这个案例说明一个关键经验不要让调用方自己整形填充行为。工具内部只管标准算法实现外界只需传明文/密文和密钥。谁往工具外面私自加了补位、去位、转码谁负责后续所有不一致问题。4.3 常见问题速查表现象可能原因解决方向单端自测通过双端解密失败填充、密钥长度或编码约定不一致核对六项基础约定抛出“Given final block not properly padded”密钥错误或密文被篡改再用约定密钥解一次更换密文样本DES报InvalidKeyException密钥长度不等于8字节密钥串补齐或更换方案SM4报KeyParameter长度错误密钥长度不等于16字节检查UTF-8编码后的字节数Base64解码抛异常密文包含URL转义字符或回车换行解码前去掉空白字符换用URL安全Base64解密出来的字符串乱码字节到字符串转换未指定UTF-8全局强制使用UTF-8C#里报“Padding is invalid and cannot be removed”解密密钥错误或密文格式错误且Padding模式不匹配确认两侧Padding别名等价关系4.4 调试技巧先用固定测试向量打通跨平台联调不要一开始就用真实业务数据。我的习惯是先准备一组固定的测试向量比如明文“demo1234”、密钥“12345678”预先在某个可信参考实现比如命令行OpenSSL或在线工具计算出期望Base64结果。然后两端分别跑只要加密结果和期望值一致就说明算法链路没问题再互相解对方的结果验证解密方向。用标准向量比用真实数据有一个好处问题发生时你可以确定是代码问题还是数据问题。否则你用一段几千字的业务报文去排查光是在密文堆里定位差异就够折腾的。准备工作看似多花五分钟实际联调会顺利非常多。5. 安全边界与工程化管理建议5.1 明确告知使用方ECB的安全边界工具里既然是“ECB模式”对外提供服务那安全说明就必须跟上。我不是主张禁用ECB而是主张在工具文档和代码注释里明确标出适用场景。好的做法是在统一入口的JavaDoc里写清楚仅适合长度短、随机性强、重复率低的数据禁止加密多行结构化文本、图片、数据库整表数据涉及个人敏感信息时应改用CBC或GCM模式工具默认只提供ECB不代表业务方可以无脑选它。如果代码是给团队内部用的最好是把这个说明写到仓库的README里并配上对比示例。否则三个月后有人复用这段代码加密用户手机号出了问题就是事故。5.2 密钥管理不要让业务代码接触密钥串这个工具本身不负责存储密钥但调用方式上我要提一个工程经验加解密函数不要直接暴露密钥字符串的SourceCode常量。尤其是Android端密钥硬编码在代码里APK一拆包就全部暴露。至少要做到密钥放到配置中心或服务端下发客户端运行时获取再退一步也要用本地加密存储把密钥包一层。PC端的密钥又常用配置文件存储必须要设置权限限制防止被同机其他用户读取。这些在你开发工具时未必能完全决定但至少要在接口设计里为密钥动态传入留好口子别图省事写死。5.3 日志与错误信息也要脱敏跨平台联调阶段最容易把密钥和明文打到日志里。我见过DEBUG日志直接打印密钥串、明文报文、解密后的完整内容这在开发环境看着方便一旦日志传到生产或第三方手里等于加密白做。建议在工具内部统一禁止日志输出明文内容。异常信息可以抛出去但异常消息里也不要拼接密钥和明文。最简单的做法是在统一入口处把传入的明文、密文、密钥都做长度脱敏只打印前几位或后几位例如只显示密钥的最后4位方便区分是哪一套密钥在跑又不泄漏完整内容。5.4 抽象出统一的加解密门面最后一个工程建议无论Android端还是PC端业务代码都不应该直接依赖Cipher、SM4Engine这些底层类。我会在两端各自封装一个门面接口比如public interface CryptoService { String encrypt(String algorithm, String plainText, String key) throws Exception; String decrypt(String algorithm, String cipherText, String key) throws Exception; }业务层只关心“算法名明文/密文密钥”不关心内部用的是JCE还是Bouncy Castle也不用理会分组、填充这些细节。将来如果要在门面里加入AES、CBC、GCM支持只改门面实现业务代码不动。这个抽象看起来多写了一层但跨语言维护的时候价值非常大——两端的门面接口签名完全一致排查问题都不用两边翻代码直接在接口对齐的层次上对比。6. 常见问题排查与适配扩展6.1 工具适配旧版本Android API的注意点如果你的Android项目targetSdk还在比较低的范围以下几个API兼容问题比较常见java.util.Base64在API 26才可用旧项目需改用android.util.Base64注意它和JDK Base64的编码策略不完全一致特别是URL安全模式StandardCharsets.UTF_8在API 19可用太旧的设备需用UTF-8.getBytes()加显式异常处理Bouncy Castle依赖包体积不算小如果App对包体积敏感可以开启混淆去掉未使用的类但SM4涉及的类不能混淆需要在ProGuard规则里keep住相关包。这些都算不上卡点但提前知道能省排查时间。6.2 SM4是否能在OpenSSL里打通有一个大家常问的点SM4能不能用OpenSSL做PC端验证可以。从OpenSSL 1.1.1开始SM4算法已经内置支持但默认不支持ECB模式直接通过-sm4-ecb参数调用。实际要写一点配置或用命令行指定cipher name。不同发行版行为有差异稳定性不如直接给PC端都装BouncyCastle来对齐。所以我建议PC端在开发和测试环境统一用BouncyCastle不依赖OpenSSL的版本差异。6.3 扩展到CBC/GCM的预留设计虽然项目标题是ECB模式但代码结构上我在门面抽象层预留了模式参数。比如把transform字符串设计成由“算法、模式、填充”三段组合而不是写死常量。这样后续增加CBC模式时只要在Cipher.init时增加IvParameterSpec参数就能在现有架构上平滑扩展。ECB不需要IV而CBC需要16字节IV对于SM4两端的IV生成规则又得重新对齐——这个等到真正做的时候再细化。7. 实操总结与个人体会最后讲一下整个项目做下来我最大的感受跨平台加解密从来不是算法本身难写而是细节约定太难管。DES和SM4的ECB实现Java、C#甚至Python写出来都不过几十行难的是你对“密钥编码、填充模式、密文表示、字符集”这四个维度有没有全局一致的强制标准。我在这个项目里最大的收获就是把参数约定前置成了文档一边写代码一边对照文档联调时间缩短了一大半。如果你也在做类似的事我建议拿到需求先别急着写代码花20分钟把两端的密钥规范、Padding别名、Base64策略、测试向量先对齐后面就不会有那种“明明都是DES就是解不开”的深夜排障时刻。有一点我特别想强调无论加解密工具做得多顺手都不要无脑推广ECB模式。老项目不得不兼容的时候用归用但你要明确知道它的短板。新写的数据交换接口尽量往CBC或者GCM方向走哪怕工作量多一两天长期来看安全账是划算的。这个工具我后续的扩展计划就是先把CBC模式补上再抽空给密文加上完整性校验这样才能真正覆盖更多的业务场景。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑