
我第一次认真核算加密后的数据量是因为一次线上排障。客户传回来的密文长度和接口文档对不上数据库字段直接抛了 Data truncation最后查下来不是算法用错而是所有人都想当然地认为“SM4加密后数据量不会膨胀太多”。实际上密钥、模式、填充规则、编码方式每一个环节都在往最终长度里加东西。这篇文章就把 SM4 加密前后的数据膨胀变化彻底算清楚同时覆盖 Java、Python、C 和嵌入式场景下的实践验证适合后端接口开发、数据库设计、自定义协议设计以及正在做 STM32 等设备对接的朋友。1. 数据膨胀从哪里来先拆成三层看1.1 第一层分组填充想躲都躲不掉的16字节对齐SM4 是一种分组密码明文按 128 位即 16 字节一组处理。既然按组处理就存在“最后一组不够16字节”的问题。不够怎么办填充。这个环节不需要做任何额外操作只要用 PKCS7 之类规则密文天然就比明文长。具体多长取决于明文长度对16取余的情况。很多人第一次接触时以为“填充最多多出15个字节”但真实情况比这个更反直觉当明文长度正好是16的整数倍时仍然要额外补满16字节。原因在于解密时无法区分“最后一个分组本来就是满的”和“最后一个分组是被填充出来的”所以标准做法强制补一组。我用一个生活化的类比解释过很多次一间房有16个床位来1个人要开1间来15个人要开1间来16个人也要再单独开1间因为房管规定必须知道哪些床是“临时加的”。这就是数据膨胀的第一层来源也是后续所有计算的基准。1.2 第二层编码转换肉眼最容易忽略的33%或100%加密出来的密文是二进制字节。二进制不能直接塞进 JSON、URL、日志或者某些数据库文本字段里于是惯例做法是转成 Base64 或 Hex。Base64 把每3个字节编码成4个可见字符长度大约是原来的 4/3Hex 则把每个字节转成两个十六进制字符长度直接翻倍。这一层特别容易误导人。举个例子明文只有1个字节经过 SM4-CBC 加密后密文是16字节看起来膨胀了16倍如果转成 Base64 是24个字符对比原明文就变成24倍转成 Hex 是32个字符变成32倍。很多人一听说 SM4 加密膨胀就吓到了其实真正落到存储或协议里的时候算的往往是编码后的长度所以必须把 Base64/Hex 的影响一起算进去不能只盯着密文长度。1.3 第三层IV、Tag、版本号等附加数据除了分组填充和编码方案设计本身的附加数据也会挤占空间。CBC 模式需要16字节随机 IVGCM 模式通常需要12字节 Nonce还会生成16字节认证标签 Tag如果协议里还要记录算法版本、模式、填充方式、IV长度那又是一批固定开销。这一层不是算法强制的而是你自己设计封装格式时决定的。我曾见过一个团队把 IV 硬编码成固定值省了存储空间但安全强度直接归零也见过有人把 IV 和密文分开存两个字段结果忘了备份其中一个导致数据无法解密。附加数据显示了推断一个严谨封装方案的重要性该存的不存后续全是坑多余的不存长度才可控。2. 模式、填充规则与编码三个关键选择怎么影响体积2.1 PKCS7填充规则为什么16字节的整数倍反而额外多16字节PKCS7 的规则说起来很简单需要补 k 个字节就补 k 个值为 k 的字节如果明文长度刚好是16的整数倍那就需要补16个值为 0x10 的字节。手工实现时可以这样写def pkcs7_pad(data: bytes) - bytes: pad_len 16 - (len(data) % 16) if pad_len 0: pad_len 16 return data bytes([pad_len]) * pad_len注意当len(data) % 16 0时16 - 0算出来是 0但按规则必须补16字节所以这里要显式判断。这个边界不知道坑了多少人。如果想用一条公式快速算密文长度可以这样密文长度 ((明文长度 16) 4) 4这个公式对任何大于0的明文长度都有效包括 16、32 这类对齐长度因为它等价于“向上取整到16的倍数但最小值是16”。我建议把这条公式写进代码注释里避免后续维护者踩同样的坑。2.2 模式选型CBC、ECB、CTR、GCM到底谁膨胀最小数据膨胀与模式强相关。ECB 和 CBC 因为按分组加密必须有填充密文长度一定是16的整数倍CTR、CFB、OFB 这类流式工作模式本质上是对明文逐字节处理最后一组不满16字节时只异或对应长度所以密文长度等于明文长度GCM 是在 CTR 基础上加了认证所以密文本身不膨胀但要额外附带 Tag。模式是否需要填充IV/Nonce附加Tag密文二进制长度典型场景ECB是不需要无16字节对齐单块数据不做多块否则模式泄露CBC是16字节无16字节对齐常规数据存储与传输CTR否16字节常用无等于明文长度长度敏感的自定义二进制协议GCM否12字节推荐16字节明文长度16需要防篡改的场景CFB/OFB否16字节无等于明文长度流式加密实际使用相对少从“膨胀最小”角度CTR 模式最省但 CTR 不提供完整性校验密文被篡改了很难发现所以很多生产系统最终选的是 GCM。GCM 表面上看密文长度等于明文长度可一旦把 IV 和 Tag 算进总存储整体还是多出 28 字节左右12字节 IV 16字节 Tag。这里需要根据自己的存储和协议格式做取舍不存在绝对的“最优模式”。2.3 编码方式对比纯二进制、Base64、Base64Url、Hex二进制密文直接用 BLOB/bytea 存储时没有编码膨胀这是最省的方式。但现实是不少系统需要走 JSON 接口或者文本存储这时不得不做编码。Base64 和 Hex 的长度对比很直观编码方式长度公式相对原始密文的增幅典型场景无编码n无BLOB、二进制协议、嵌入式DMA传输Base64ceil(n/3)*4约33%JSON、XML、数据库varcharBase64Url同Base64去掉末尾约33%URL参数、JWT类场景Hexn*2100%日志、调试、密钥与哈希展示Base64Url 取消了、/和但是解码时往往要把补回来所以在做接口对接时要约定清楚“是否保留等号”。Hex 虽然长度难看但在嵌入式串口日志和调试场景里非常好用一眼能看出字节值我经常在联调时用它打印密文。3. 实操测算Java、Python、C与嵌入式各跑一组数据3.1 Java加BouncyCastle测出真实的密文长度JDK 自身不内置 SM4直接写Cipher.getInstance(SM4/CBC/PKCS7Padding)会抛出NoSuchAlgorithmException这也是很多人搜“java.security.nosuchalgorithmexception: no such algorithm: sm4/cbc/pkcs7padd”的原因。解决办法是引入 BouncyCastle 并显式注册Security.addProvider(new BouncyCastleProvider()); byte[] key 0123456789abcdef.getBytes(StandardCharsets.UTF_8); byte[] iv 0000000000000000.getBytes(StandardCharsets.UTF_8); Cipher cipher Cipher.getInstance(SM4/CBC/PKCS7Padding, BC); cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(key, SM4), new IvParameterSpec(iv)); byte[] encrypted cipher.doFinal(hello.getBytes(StandardCharsets.UTF_8)); System.out.println(cipher len: encrypted.length); // 16 System.out.println(base64 len: Base64.getEncoder().encodeToString(encrypted).length()); // 24 System.out.println(hex len: Hex.toHexString(encrypted).length()); // 32这段代码实测下来明文hello是5字节填充后是16字节Base64 输出24字符Hex 输出32字符。如果你在自己项目里发现长度比这个值大大概率是前面拼接了版本号、IV、盐值之类的头部信息。把头部开销列清楚长度才不会失控。3.2 Python用gmssl验证填充与编码Python 生态里常用 gmssl 库做 SM4 加解密。下面是 CBC 模式下的验证from gmssl.sm4 import CryptSM4, SM4_ENCRYPT key b0123456789abcdef iv b0000000000000000 sm4 CryptSM4() sm4.set_key(key, SM4_ENCRYPT) plain bhello cipher sm4.crypt_cbc(iv, plain) print(len(plain)) # 5 print(len(cipher)) # 16gmssl库默认做PKCS7填充 print(cipher.hex()) # 32个十六进制字符不同版本的库对填充的处理不完全一致我建议拿到库之后先拿 5 字节明文跑一遍用结果验证它到底填不填充。另外一个小提醒Python 的bytes转字符串时不要直接str(cipher)那会把字节对象序列化成一堆转义字符长度和真实密文对不上调试时容易误判。想看可读内容就.hex()或base64.b64encode()。3.3 C语言与嵌入式缓冲区怎么开才不越界嵌入式场景下没有 Java/Python 那么方便的动态内存缓冲区尺寸必须在编译期或分配期算好。以 STM32F103 这类芯片来说RAM 很紧张公式和宏定义可以这样处理#define SM4_BLOCK_SIZE 16u #define SM4_CIPHER_LEN(plain_len) ((((plain_len) SM4_BLOCK_SIZE) / SM4_BLOCK_SIZE) * SM4_BLOCK_SIZE)这个宏和前面的公式等价当明文长度是16的倍数时(plain_len 16) / 16 * 16会多出一个块不是倍数时则向上取整到下一个16字节边界。使用时要保证输出缓冲区至少是SM4_CIPHER_LEN(plain_len) 1并且最好把输入缓冲区和输出缓冲区分开不要原地加密。CBC/ECB 模式下 PKCS7 填充会改变数据长度原地操作很容易越界写坏内存这个坑在裸机开发中尤其致命。如果最终要通过串口以 Base64 文本形式输出建议分块编码不要一次性申请几 KB 的堆内存否则在小 RAM 设备上很容易崩溃。可以先计算必要长度base64_len ((cipher_len 2) / 3) * 4再决定缓冲区大小。4. 真实事故与排查速查4.1 数据库字段 Data truncation最典型的线上事故是原来明文是 VARCHAR(200)加密后存不进去了。如果一个中文字符在 UTF-8 下占3字节200个字符就是600字节SM4-CBC 填充后变成608字节Base64 编码后约812字符原字段长度 200 自然直接报错。如果字段类型是 VARCHAR(64) 存手机号这类短数据加密后 Base64 只有24字符反而很宽裕。明文内容明文字节数SM4-CBC密文Base64后建议字段手机号111624VARCHAR(32)单条地址100112152VARCHAR(200)100个中文字符300304408VARCHAR(512)200个中文字符600608812TEXT或VARCHAR(1024)建议在数据库设计阶段就实现一个统一的“长度预估函数”把最大明文长度、模式、编码方式都放进去输出最终需要的字符数。不要凭感觉预留不要以为多留一倍就万无一失因为不同编码、不同字符集影响很大。4.2 通信协议长度字段写错导致粘包自定义 TCP 协议里最常见的坑是头部长度字段写的是明文长度而消息体实际放的是密文长度。接收端按错误长度解析会把下一条消息的开头几个字节吞掉也可能把粘包的尾部当作正常消息处理整个会话直接乱掉。排查这类问题要做的第一件事是抓包确认“实际传输字节数”第二件是打印发送端的长度字段值两者一对问题基本就定位了。更稳妥的做法是统一消息封装格式不要依赖隐性约定。例如可以这样设计头部版本号占1字节、模式标识占1字节、IV长度占1字节、Tag长度占1字节后面依次放 IV、密文、Tag。头部明确写了长度解析方不需要去猜算法库升级替换也更容易。4.3 GCM的Tag和IV少存了一个字节都会翻车GCM 模式使用不当最常见的是解密时 Tag 校验失败、抛AEADBadTagException。原因是 Tag 没有保存或者保存的拼接位置和解密时读取位置不一致。我常用的存储格式是IV Ciphertext TagIV 用12字节Tag 用16字节解析时先从头部取 IV再取密文最后 16 字节作为 Tag顺序固定后基本不会出错。还要特别记住CTR/GCM 模式下同一把密钥的 IV/Nonce 绝不能重复使用。IV 不需要保密但必须唯一重复使用会直接导致密钥流重复攻击者拿到两组密文就能算出明文异或这是方案级的安全漏洞。如果设备重启后 IV 计数器重置必须结合随机数或机器唯一序列号重新生成 IV。4.4 透明加密与在线解密别被“看起来不膨胀”骗了“Linux 透明加密”这类文件系统层方案日志上看到的往往是明文文件大小但磁盘占用和导出的加密文件仍然受分组填充影响。文件系统的块对齐或加密层的尾部填充会造成额外占用只是对普通进程透明不意味着“没有膨胀”。判断数据膨胀最直接的办法是加密前后各看一次实际字节数或者看磁盘块使用量而不是只看 ls 显示的逻辑尺寸。至于“SM4在线解密”一类的工具我建议别把生产环境密钥和密文贴到网页上。SM4 的安全边界完全在密钥上交给第三方等于把自己的锁和钥匙都送出去。自测解密只需要本机一条命令或一个小脚本几秒钟就能完成麻烦一点但安全得多。5. 我的一些个人经验与建议踩过几次坑之后我做加密方案的习惯固定成了两条。第一条所有长度相关的预期都必须先用真实数据验证写一段最长业务样例数据跑一次加密把密文长度、Base64长度、Hex长度全部打出来贴进设计文档作为数据库字段和协议字段的定义依据。这个动作花不了五分钟却能让后续字段扩容和兼容性补丁减少一大半。第二条公式要写进单元测试而不是只写在注释里。算法库升级、底层硬件变更、填充策略调整都可能导致长度行为变化一跑测试就能发现异常。数据膨胀本身并不可怕可怕的是对它没有精确的预期。SM4 和 AES 同为 128 位分组密码膨胀规律基本一致换算法并不会让长度变短真正影响体积的是模式选型、编码方式和附加数据的封装设计。如果你刚开始接触 SM4建议先用一段 5 字节和一段 16 字节的明文分别测一次把填充的边界条件刻进记忆里后面设计任何存储和协议都会顺手很多。