简介:国密SM4算法加解密实现程序,以C++语言开发,主要面向需要国产密码算法支持的开发者和信息安全爱好者,可应用于合规改造、实验教学或业务数据加密等场景。程序支持中英文及特殊字符混编内容,同时提供字符串与文件两种加密方式,并可在CBC与ECB模式间自由切换,兼顾了不同数据规模与安全强度需求。压缩包内共有四个文件,包括两个源文件、一个头文件和一个工程文件。其中源文件分别实现核心运算与调用逻辑,头文件负责对外接口声明,工程文件可配合相应开发环境直接构建,整体体积仅为十四KB,结构紧凑,便于快速查阅与集成。该资源至今已吸引一千二百余人浏览学习,实用价值获得初步验证。阅读代码时,可重点关注密钥扩展、轮函数迭代、填充与解密对齐等关键细节,接口封装较为清晰,稍作调整即可嵌入既有项目,从而降低国密算法的落地门槛。
1. SM4 国密加密工具拆解:一个同时搞定字符串、文件、CBC 和 ECB 的实用库
某次做内部安全改造,安全规范要求敏感数据加密必须用国密SM4,不能直接套用现有AES接口。查了一圈现成库,发现最底层只负责加密一个16字节块,填充、编码、IV、模式全部要调用方自己补;一旦明文里混了中文和特殊字符,字符串长度和字节长度对不上,解密第一轮就出错。这个项目就是来解决这个落地问题的:它用纯Python实现了SM4算法内核,对外提供字符串加密和文件加密两套能力,支持CBC和ECB两种模式,中英文和特殊字符都能正确编码,不依赖第三方库就能跑起来。适合正在做合规改造、或者想在业务代码里集成SM4,又不想重造轮子的开发者。
2. 核心架构拆解:密钥扩展、轮函数与模式关系的底层设计
2.1 目录结构与模块职责
打开资源包,第一眼看到的是按职责拆出来的五个文件,而不是把所有逻辑塞进一个脚本里。这点在做加密组件时非常重要,因为一旦把填充逻辑、底层分组加密、业务调用全混在一起,后面想换模式或者排查乱码问题会非常痛苦。
sm4_cipher/ ├── sm4_core.py # 底层核心:S盒、密钥扩展、加解密轮函数 ├── padding.py # PKCS7 填充与去填充 ├── string_cipher.py # 字符串加解密:UTF-8 -> 填充 -> CBC/ECB -> Base64 ├── file_cipher.py # 文件流加解密:分块读写 + 16字节文件头 └── cli.py # 命令行入口:encrypt/decrypt 子命令这个拆法有个明显好处:如果只想用字符串加密,可以完全不看file_cipher.py;如果只想让底层算法被自己的协议复用,只 importsm4_core.py就行。我一般会建议拿到源码先跑一遍cli.py的自测或回环用例,确认环境没问题再改代码,避免把“环境问题”和“算法问题”混在一起排查。
2.2 密钥扩展:32轮轮密钥怎么来
SM4是128位分组、128位密钥、32轮迭代的分组密码。它不像AES那样从密钥里直接抽出多轮子密钥,而是通过固定的FK和CK常量,把一个16字节密钥扩展成32个32位轮密钥。密钥扩展这部分一旦写错,后续所有轮都会用错轮密钥,加解密结果完全对不上。
# sm4_core.py 节选 FK = [0xa3b1bac6, 0x56aa3350, 0x677d9197, 0xb27022dc] def _key_expansion(key: bytes) -> list[int]: mk = struct.unpack(">4I", key) # 16字节密钥,大端拆成4个32位字 k = [mk[0] ^ FK[0], mk[1] ^ FK[1], mk[2] ^ FK[2], mk[3] ^ FK[3]] rk = [] for i in range(32): t = k[1] ^ k[2] ^ k[3] ^ CK[i] t = _linear_key(_sbox(t)) # 密钥扩展里用的是 L' 变换 rk.append(t ^ k[0]) k = [k[1], k[2], k[3], t ^ k[0]] return rk这里的关键点是struct.unpack(">4I", key)必须用大端序。如果换成小端,生成的轮密钥完全不同,而且底层不报错,解密结果只会是一堆乱码,属于非常隐蔽的坑。CK是算法固定给的32个32位常量,完整列表在源码里,不需要自己推导。想要自定义密钥派生时,不要动这里的扩展逻辑,而是在外层先把口令转成16字节。
2.3 加密一轮核心轮函数:一次迭代干了什么
有了轮密钥,剩下的就是把一个16字节明文块拆成4个32位整数,然后迭代32轮。每轮做的事情在代码里看非常直观:用后三个字和轮密钥做异或,过S盒,过线性变换,再和前一个字异或。
def sm4_encrypt_block(block: bytes, rk: list[int]) -> bytes: x = list(struct.unpack(">4I", block)) # 把16字节明文拆成X0..X3 for i in range(32): t = x[1] ^ x[2] ^ x[3] ^ rk[i] t = _linear(_sbox(t)) # 先查S盒,再做L位移 x[0] ^= t x = [x[1], x[2], x[3], x[0]] # 每个轮次窗口左移一个字 return struct.pack(">4I", x[3], x[2], x[1], x[0]) # 注意末轮逆序输出一个容易看懵的点是最后return的x[3], x[2], x[1], x[0]。SM4的设计就是在输出阶段把四个字倒过来,解密时同样倒序、轮密钥反过来用,整个流程对称。如果你自己写测试,拿国标里的样例向量跑一遍,输出对不上就说明轮函数或密钥扩展有问题,而不是后面调用方的锅。
2.4 ECB和CBC在底层实现上的差别
底层分组加密完成后,ECB和CBC的区别其实很小,但安全性差距很大。ECB模式里每个16字节块独立加密,互不影响;CBC模式则把前一个密文块拿过来,和当前明文块异或之后再加密。
def ecb_encrypt(blocks, rk): return [sm4_encrypt_block(b, rk) for b in blocks] def cbc_encrypt(blocks, rk, iv): out, prev = [], iv for blk in blocks: xored = bytes(a ^ b for a, b in zip(blk, prev)) out.append(sm4_encrypt_block(xored, rk)) prev = out[-1] # 当前密文成为下一轮的异或输入 return out从代码能直接看出来,ECB完全可以并行处理,CBC则是一条链,必须顺序执行。这也是为什么CBC在绝大多数业务场景里更受推荐:同样的明文块,出现在文件不同位置时,加密后结果不一样,不会泄露结构信息。看懂这一段,后面文件流加密里维护prev变量的逻辑就顺理成章了。
3. 字符串加解密实战:UTF-8、PKCS7 与 CBC 模式的完整链路
3.1 中文和特殊字符怎么变成固定块
SM4处理的是字节,不是字符串。而中文字符在UTF-8下占3个字节,英文和半角符号占1个字节。一段“国密算法SM4”看起来没几个字符,实际字节长度可能就是“SM4”的三倍。如果直接把字符串编码后按16字节切块,最后一块不足16字节,解密时会丢掉数据,所以必须先做PKCS7填充。
def pad_pkcs7(data: bytes, block_size: int = 16) -> bytes: pad = block_size - len(data) % block_size return data + bytes([pad] * pad) def encode_blocks(plaintext: str) -> list[bytes]: raw = plaintext.encode("utf-8") padded = pad_pkcs7(raw) return [padded[i:i+16] for i in range(0, len(padded), 16)]填充规则很简单:缺几个字节就补几个数字。比如明文最后剩5个字节,就补11个0x0b;如果明文刚好是16的整数倍,也额外补16个0x10,这样解密时能明确知道最后一块是纯填充块。项目里统一用UTF-8编码,这是处理中英文和特殊字符最稳妥的方式。用GBK处理常规汉字没问题,但遇到生僻字、emoji或者特殊控制字符就可能直接抛异常,所以我在自己的项目里也一律以UTF-8为准。
3.2 CBC模式字符串加密与 Base64 输出
字符串加密入口做了两件重要的事:一是自动生成或接收IV,二是把IV拼到密文前面,一起做Base64输出。这么做的好处是,调用方只需要保存一段字符串,就能完成解密,不会出现“密文记得,IV丢了”的情况。
def encrypt_string(plaintext: str, key: bytes, iv: bytes = None, mode: str = "cbc") -> str: if len(key) != 16: raise ValueError("SM4密钥长度必须为16字节") blocks = encode_blocks(plaintext) if mode == "ecb": cipher = b"".join(ecb_encrypt(blocks, key_expansion(key))) return base64.b64encode(cipher).decode("ascii") if iv is None: iv = os.urandom(16) cipher = b"".join(cbc_encrypt(blocks, key_expansion(key), iv)) return base64.b64encode(iv + cipher).decode("ascii")参数说明:key必须严格16字节,否则SM4算法无法运行;iv是可选的16字节随机数,每次加密都换一个新IV更安全;mode支持cbc和ecb字符串,默认CBC。输出格式上,ECB模式直接加密后Base64,CBC模式则先把IV和密文拼成一个整体再做Base64。解密时按偏移切开即可。
def decrypt_string(payload: str, key: bytes, mode: str = "cbc") -> str: raw = base64.b64decode(payload) if mode == "ecb": cipher = raw else: iv, cipher = raw[:16], raw[16:] blocks = [cipher[i:i+16] for i in range(0, len(cipher), 16)] rk = key_expansion(key) if mode == "cbc": plain = b"".join(cbc_decrypt(blocks, rk, iv)) else: plain = b"".join(ecb_decrypt(blocks, rk)) pad = plain[-1] return plain[:-pad].decode("utf-8")解密时取最后一个字节作为填充长度,然后从末尾切除。这里要求填充值必须等于填充数量,所以PKCS7在格式校验上比ZeroPadding更严谨。如果收到报错“invalid padding”,优先检查密文是不是被截断过,再检查模式和IV是否和解密端一致。
3.3 调用方式与参数约定
项目提供的命令行入口可以直接跑通整个流程,方便验证环境:
python cli.py encrypt --mode cbc --key "my-secret" --text "国密算法SM4测试,特殊字符!@#" python cli.py decrypt --mode cbc --key "my-secret" --payload "QKf..."命令行参数如下:
| 参数 | 说明 | 备注 |
|---|---|---|
--mode | cbc或ecb | 默认cbc |
--key | 任意字符串口令 | 内部做 SHA-256 后截取前 16 字节 |
--iv | 16字节十六进制字符串 | 不传则随机生成 |
--text | 待加密字符串 | 支持中文、英文、特殊字符 |
--payload | 待解密的 Base64 字符串 | 解密后输出原始字符串 |
这里有一个容易被误解的地方:命令行传的--key是“口令”,不是算法直接使用的16字节密钥。项目内部会把口令通过SHA-256派生为16字节密钥。直接看源码时,如果发现传了一个26字节的口令还能加密,不要觉得奇怪,这是封装层的设计。
4. 文件加密与 ECB/CBC 模式选择:流式处理的正确姿势
4.1 文件流加密实现
文件加密和字符串加密最大的区别是数据量。一个几百MB的文件如果一次性读到内存里再加密,机器内存很容易被打满。项目的做法是每次读1MB,把能凑成完整16字节块的部分先加密写出,剩下的不足16字节数据留在缓冲区,等读到文件末尾再做填充。
def encrypt_file(src_path: str, dst_path: str, key: bytes, iv: bytes, mode: str = "cbc") -> None: rk = key_expansion(key) prev = iv with open(src_path, "rb") as fin, open(dst_path, "wb") as fout: fout.write(b"SM4F" + iv) # 4字节魔数 + 16字节IV remain = b"" while True: chunk = fin.read(1 << 20) # 每次读1MB if not chunk: break remain += chunk full = len(remain) - len(remain) % 16 for i in range(0, full, 16): block = remain[i:i+16] if mode == "ecb": enc = sm4_encrypt_block(block, rk) else: enc = sm4_encrypt_block(bytes(a ^ b for a, b in zip(block, prev)), rk) prev = enc fout.write(enc) remain = remain[full:] # 不足16字节的部分留到下一轮 # EOF:对剩余字节做 PKCS7 填充 last = pad_pkcs7(remain) if mode == "ecb": fout.write(sm4_encrypt_block(last, rk)) else: fout.write(sm4_encrypt_block(bytes(a ^ b for a, b in zip(last, prev)), rk))这段代码里最关键的是remain变量。它把每次读取后不够16字节的尾部留到下一轮,等下一轮新数据进来再拼接。文件读完时remain里最多还剩15个字节,也可能为空,统一走pad_pkcs7填充。如果文件大小恰好是16的整数倍,这里会补出一个完整的空白填充块,这是PKCS7的规则,解密时会自动去掉。
4.2 文件头与解密流程
加密后的文件不能只是纯密文,否则解密时不知道IV是什么。项目在文件开头写了4字节魔数SM4F和16字节IV,这个设计从字符串加密的“IV拼在密文前”延伸到文件场景,逻辑一致。
| 偏移 | 字节数 | 内容 |
|---|---|---|
| 0 | 4 | 魔数SM4F |
| 4 | 16 | IV |
| 20 | 16的倍数 | 密文数据 |
解密流程对应的处理是:先校验魔数,再读IV,然后逐块解密。和加密不同的是,解密读到最后一块时要先解开密文,再用unpad_pkcs7去掉填充,才能把明文写入目标文件。如果漏掉去填充这一步,写出来的文件末尾会多一串0x01到0x10的填充字节。
4.3 ECB 模式的适用边界
看到项目支持ECB,很多人会很自然地默认ECB也能用于所有文件。但如果你用ECB加密一张BMP图片,加密后再用看图软件打开,会发现还能隐约看出原图轮廓。原因很简单:ECB模式下,相同的明文块加密后得到完全相同的密文块,图片中大片的相同颜色区域会产生相同的密文块,形状信息就暴露了。
# 像这样用ECB处理BMP图片,会暴露轮廓 bmp_blocks = read_blocks("demo.bmp") enc = b"".join(ecb_encrypt(bmp_blocks, rk))ECB真正适合的场景是加密已经是“高随机性”的数据,比如压缩包、密钥文件、已经加密过的数据,这些数据中几乎不存在相同块,ECB的效率优势可以发挥出来。判断标准就一句话:如果明文两处相同会让密文也相同,而这个代价场景能接受,才用ECB;否则一律CBC。项目的默认模式也设成了CBC,这是对大多数业务场景更安全的默认值。
5. 避坑清单:SM4 开发中常见的五个翻车现场
5.1 中文解密后乱码
现象:加密时传了一串中文,解密后输出变成了“锟斤拷”或直接抛出UnicodeDecodeError。
原因:加密和解密两侧用了不同编码。比如加密侧用了UTF-8,解密侧按GBK解码,或者反过来。还有一种是明文里混了emoji,GBK根本无法编码,加密前就已经报错。
解决:统一使用UTF-8编码。加密前encode("utf-8"),解密后decode("utf-8"),不要依赖系统默认编码,更不要在代码里用str.encode()不传参数。
plaintext = payload.decode("utf-8") # 必须和加密时的编码一致5.2 CBC 模式 IV 没保存
现象:加密时明明成功了,但程序重启后再解密,输出是一堆乱码,而且不报错。
原因:CBC模式第一块明文要先和IV异或,解密必须用同一个IV。如果用随机IV却只保存了密文,重启后IV丢失,整个解密链路从第一块开始就乱了。
解决:把IV和密文放在一起保存。字符串场景可以把iv + cipher拼起来做Base64;文件场景可以像项目一样在文件头写入IV。要注意IV不需要保密,但不能丢失。
5.3 PKCS7 填充校验失败
现象:解密时直接报ValueError: invalid padding,日志里能看到填充字节长度异常。
原因:手动去填充时只取了最后一个字节作为长度,没有校验填充值是否合法。比如密文在传输过程中被截断,或者开头多了一个字节,末尾字节恰好变成0x07,程序就会多切掉7个字节。
解决:用完整的填充校验逻辑,同时判断填充值范围和数据内容。
def unpad_pkcs7(data: bytes) -> bytes: pad = data[-1] if pad < 1 or pad > 16 or data[-pad:] != bytes([pad] * pad): raise ValueError("填充校验失败") return data[:-pad]5.4 ECB 加密图片后轮廓漏光
现象:BMP格式图片加密后,用普通看图工具打开,还能看到原始图像的大致形状。
原因:ECB模式下相同明文块加密结果相同,图片大面积相同颜色区域变成了大量相同密文块,图像结构信息被直接暴露。CBC模式因为引入了链式异或,不会出现这个问题。
解决:遇到图片、日志、数据库导出文件这类结构化数据,不要用ECB。项目默认CBC,这个默认值不要改。只有在加密压缩包、随机密钥等无法出现合法块对比的数据时才考虑ECB。
5.5 密钥长度不对却不报错
现象:命令行传了一个任意长度的口令,加密成功,但另一个模块用同一个口令解密时失败,或者底层库报密钥长度错误。
原因:SM4原生密钥必须是16字节,但很多封装为了易用性,把任意字符串先做哈希再截取。两个端如果用了不同派生方式,比如一端哈希截断、另一端直接补零,结果自然对不上。
解决:统一密钥派生规则。常见做法是口令UTF-8编码后做SHA-256,取前16字节输出。
def derive_key(password: str) -> bytes: return hashlib.sha256(password.encode("utf-8")).digest()[:16]这个推导过程也建议固定下来写进接口文档,不然团队成员很容易各自实现一套派生逻辑,联调时互相看不懂对方为什么解密失败。
6. 二次开发扩展:把 SM4 工具改成可插拔的加密服务
这个项目的分层方式很适合往“多算法加密组件”方向扩展。如果产品后续要求同时支持SM4和AES,或者要在不同模式间做切换,不建议在业务代码里直接到处调用sm4_encrypt_block,而是抽象一个策略接口,让上层只关心是“加密”还是“解密”。
class CipherStrategy: def encrypt_block(self, block: bytes, ctx) -> bytes: raise NotImplementedError def decrypt_block(self, block: bytes, ctx) -> bytes: raise NotImplementedError class SM4Strategy(CipherStrategy): def encrypt_block(self, block, ctx): return sm4_encrypt_block(block, ctx.rk)做了这层抽象以后,业务侧只需要持有CipherStrategy实例,完全不依赖具体算法。后续新增AES时,实现一个AESStrategy就能无缝接入,字符串和文件两套调用入口都不用改。
我改造时会优先处理三个地方:第一,把文件头的SM4F魔数升级成一个带版本号的头部结构,比如SM4F0100,以后调整算法或块长度还能兼容旧文件;第二,把密钥派生逻辑收敛到一个公共模块,禁止调用方直接传16字节原始密钥对外暴露;第三,在CLI里给文件操作增加--chunk-size参数,方便在低内存服务器上把1MB分块改成256KB,避免大文件加密时内存波动。
改造完成后最有效的验证方式就是回环测试。把中英文、特殊字符、空字符串、长字符串全部跑一遍,断言解密结果和原始明文完全一致:
def roundtrip_check(): samples = ["hello", "国密算法sm4", "特殊字符!@#$%^&*()_+{}[]|;:'\",.<>?/"] site_key = derive_key("s3cr3t") for sample in samples: assert decrypt_string(encrypt_string(sample, site_key), site_key) == sample这套用例看起来简单,但每次改动底层轮函数、填充逻辑或编码方式后跑一遍,能挡住大部分回归问题。从那以后我每次接加解密需求,都会在动业务代码之前先过一遍“填充校验、IV保存、编码统一”这三道关,确认回环能通过再往上叠业务逻辑。希望帮到你。
本文还有配套的精品资源,点击获取