现在写对称加密,大多数人第一反应是AES,DES在工程里早就退居二线了。但你要是正在学密码学、要写相关课程设计,或者面试时被问到Feistel结构,手写一遍DES的Python实现绝对是最值得做的练习。DES是教科书级的对称分组加密算法:64位分组、56位有效密钥、16轮Feistel网络,再加上8个S盒和一堆置换表,它把现代分组密码的核心概念完整演示了一遍。这篇文章我不讲太多泛泛的背景,直接沿着“从零实现DES”这条主线,把整体结构、关键代码、测试向量和踩坑经验一起说清楚。目标是让你照着这篇文章,能在一个小时内写出一个通过标准测试向量、还能对真实文本加解密的Python版DES。适合刚接触密码学的初学者、写课程设计的同学,以及想搞明白分组密码底层原理的开发者。
1. 为什么还要自己手写一遍DES
我最早接触DES是用C写的,后来工作里又用Python重写了一遍。周围不少朋友问过我同一个问题:明明有pycryptodome这种现成库,调一个接口就完事,为什么还要自己折腾?我的回答是,调库拿到的是黑盒,自己写一遍拿到的是一整套思维模型。
DES虽然老,但它的设计思想一点不老。手动实现一轮DES,你不得不去理解每个细节:初始置换IP、轮函数里的E扩展置换、8个S盒、P置换、密钥编排里的PC-1和PC-2、最后一轮的左右交换、逆初始置换IP^-1。这些概念在AES、SM4这些现代算法里,虽然细节变了,但骨架是相通的。把DES手写一遍,再看其它算法的文档,速度完全不一样。
还有一个很打动我的点:DES的加解密是同构的。解密不需要单独写一个F函数的逆,而是把同一套Feistel流程跑一遍,只是把16个子密钥的顺序倒过来。我当年第一次理解这件事时,觉得这种对称之美是DES最优雅的地方。很多新手搞不懂“为什么解密不用求逆函数”,这个疑问其实值得亲手写一遍代码去解开。
手写DES还能顺带训练一些工程基本功:字节和位之间的转换、大表数据的组织、分块时的边界对齐、填充协议的设计,这些在平时写业务代码时很少遇到,但在写底层协议、做嵌入式移植、分析二进制格式的时候全是基本功。
当然也得说清楚边界:手写版是绝佳的学习工具,并不是生产工具。真实系统里要加密数据,请用成熟的密码库,并且优先选AES、SM4这类现代算法,单DES已经是淘汰的东西,不适合再用于实际保密。
2. 动手前先把DES整体结构装进脑子
2.1 64位分组、56位密钥与Feistel网络:核心框架
DES每次处理一个64位分组,也就是8个字节。通常我们说DES是64位密钥,严格讲是64位密钥材料,其中第8、16、24、32、40、48、56、64这8个位是奇偶校验位,真正参与算法运算的只有56位。
整体流程可以拆成三步:先做初始置换IP,然后跑16轮Feistel网络,最后经过左右交换和逆初始置换IP^-1输出密文。每次置换只是重新排列位的位置,不改变位的值,这一点新手容易忽略。
Feistel网络一轮很简单。64位数据经过IP后被切成左半和右半,各32位。一轮的更新规则是:
- L_i = R_{i-1}
- R_i = L_{i-1} ⊕ F(R_{i-1}, K_i)
也就是说,这一轮的右半直接变成下一轮的左半;左半则要和一轮函数F的输出做异或。这里的F就是整个DES的核心,后面单独讲。
为什么解密不需要F的逆函数?关键在于R_{i-1}本身就是上一轮的左半部分。解密时已知L_i,立刻就能拿到R_{i-1}=L_i,于是L_{i-1}=R_i ⊕ F(L_i, K_i)就能算回来。异或是对称操作,F在这里只是被异或的对象,所以F本身不需要可逆。这个结构的好处是,设计者可以随心所欲构造一个足够混乱的非线性函数,只要它正向可计算,就能用于构造可逆的分组密码。这是Feistel结构经久不衰的根本原因。
2.2 密钥编排:从64位主密钥到16个48位子密钥
每轮Feistel网络都要用到一个48位的子密钥K_i,16轮就需要16个。密钥编排干的事情,就是把最初的64位密钥材料加工成这16个48位子密钥。过程分成四步。
第一步,PC-1置换。64位密钥里只有56位会进入下一步,被跳过的正是那8个奇偶校验位。PC-1输出的56位,对半分成C0和D0两个28位部分。
第二步,循环左移。C和D两个部分各自做循环左移,每一轮移动的位数不是固定的,而是查表得到:第1轮和第9轮左移1位,其余轮数左移2位。这个SHIFT表是DES标准的一部分,不能自己乱改,改一处结果就全错。
第三步,PC-2压缩置换。把每轮循环左移后的C_i和D_i拼接成56位,再用PC-2表从中选出48位,作为这轮的子密钥K_i。
整个过程可以用一个简单的表概括:
| 步骤 | 输入长度 | 输出长度 | 作用 |
|---|---|---|---|
| PC-1 | 64位 | 56位 | 剔除奇偶校验位 |
| 循环左移 | 28位+28位 | 28位+28位 | 让每轮子密钥不同 |
| PC-2 | 56位 | 48位 | 压缩并打乱生成子密钥 |
我建议初学者把PC-1、PC-2和SHIFT表抄下来的时候多核对几遍,这种表一旦抄错一个数字,整个密钥编排就废了,而且程序不会报错,只会输出错误结果,排查起来很费劲。
2.3 轮函数F:扩展、异或、S盒、P置换
轮函数F接收两个输入:32位的右半部分R和48位的子密钥K_i,输出32位结果。F内部要做四件事:E扩展置换、与子密钥异或、S盒替换、P置换。
E扩展置换把32位的R扩展到48位,具体做法是每4位一组,每组向外扩展成6位,其中首尾位会被复制一次。扩展的意义在于让R的位数能和48位子密钥对齐做异或,同时让数据变化更快扩散到下一轮。
48位结果和K_i做异或之后,进入8个S盒。每个S盒接收6位输入,输出4位,8个S盒一共把48位压缩回32位。S盒是DES唯一的非线性组件,也是DES抵抗线性密码分析、差分密码分析等攻击的关键所在。6位输入中,第1位和第6位拼成一个2位的行号(0到3),中间4位拼成一个4位的列号(0到15),查表得到0到15之间的数,然后转成4位二进制输出。
最后一步是P置换。32位S盒输出经过P表的重新排列,产生本轮F函数的最终输出。P置换的意义是扩散,让一个S盒的输出尽可能影响到下一轮多个S盒的输入,这是“雪崩效应”的重要来源。
轮函数F的流水线也整理成一个表:
| 步骤 | 输入长度 | 输出长度 | 作用 |
|---|---|---|---|
| E扩展置换 | 32位 | 48位 | 扩展并对齐密钥长度 |
| 与K_i异或 | 48位+48位 | 48位 | 混入密钥 |
| S盒替换 | 48位 | 32位 | 非线性变换 |
| P置换 | 32位 | 32位 | 重新分布位,增强扩散 |
3. Python实现的关键细节:数据表示与常见陷阱
3.1 数据表示:位列表方案为什么更适合学习
写代码之前,首先要想清楚用什么数据结构表示64位的块。我见过两种主流做法:一种是把块存成一个整数,通过移位和掩码做运算;另一种是把块展开成一个Python列表,每个元素是0或1,表示一个位。
我最终选了位列表方案,原因是它和置换表配合起来太直观了。置换本质上就是查表,一个permute函数写出来就一行:
def permute(bits, table): return [bits[x - 1] for x in table]table里写着“取第几位放到当前位置”,这一行代码就直接表达了这个语义,调试的时候对着标准文档核对表数据非常方便。用整数加位运算也能做,而且性能更好,但每做一次置换都要写一堆循环和移位,代码可读性差很多,刚开始学习时很容易把自己绕晕。
位列表方案的代价是速度和内存。Python里每个int对象都有额外开销,一个64位块用列表存下来,比用一个整数多耗几十倍内存,运算也慢。但作为教学版本,这点代价完全可以接受,换来的可读性和可调试性非常值。
3.2 标准表的下标陷阱:从1开始,而Python从0开始
这是我在实现过程中踩得最狠的坑,没有之一。DES标准文档里的所有置换表,比如IP、E、PC-1、PC-2,里面的数字全部从1开始编号。比如IP表的第一个数字是58,意思是“输出的第1位来自输入的第58位”。
但Python列表的下标从0开始。所以permute函数里必须做x-1,否则取到的就会是输入的第59位,有一个位的位置错位,后面所有结果全错。更讨厌的是,这种错位不会抛异常,程序照样跑,但输出对不上标准测试向量。
我建议把permute函数单独写好,然后反复测试。第一次实现的时候我以为自己记住了下标问题,结果写S盒查表时又犯了一次:S盒的行号、列号计算,也涉及从0还是从1开始的语义。S盒表本身的行就是0到3,列就是0到15,这个和Python下标天然一致,但如果拿着资料里手工标注的1-based表格对照,就得多留个心眼。
3.3 轮函数与S盒查表的实现要点
轮函数F的核心代码,其实很短。E置换就是把P置换函数复用一次,异或用列表推导式一行搞定,S盒查表稍微绕一点。
S盒查表的关键是6位输入如何变成行号和列号。假设某组6位输入是chunk,那么行号由第1位和第6位拼出,列号由中间4位拼出:
row = (chunk[0] << 1) | chunk[5] col = (chunk[1] << 3) | (chunk[2] << 2) | (chunk[3] << 1) | chunk[4] val = S_BOX[i][row][col]想理解这段代码,可以这么类比:6位输入就像一个二维坐标,首尾两位指明“在第几行”,中间四位指明“在第几列”,到对应S盒的表里查一个数字出来。S盒8个表,每个都是4行16列,刚好对应行号0到3、列号0到15。
查表得到的val是0到15的整数,需要转成4位二进制追加到输出列表里。我用列表的extend方法一次补进4个bit:
out.extend([(val >> 3) & 1, (val >> 2) & 1, (val >> 1) & 1, val & 1])这样8组处理完,48位输入就变成了32位输出。这个过程不难,但S盒的8张表一共512个数字,抄错一个都不行。我第一次动手时偷懒跳过核对,结果对不上测试向量,后来把8张表的每一行都和公开参考资料逐行比对,才发现第4个S盒的第三行某个数字被我抄错了。
3.4 面对真实文本:分组与PKCS7填充
DES一次加密一个64位分组,但真实文本长度不可能总是8字节的整数倍。这就必须引入填充方案。我采用PKCS7填充,这是分组密码里最常见的做法,逻辑很自然:还缺几个字节,就补上几个字节,每个填充字节的值等于填充长度。
比如数据长度是10字节,需要补到16字节,就补6个值为0x06的字节。如果数据刚好是8字节的整数倍呢?PKCS7的做法是仍然补一个完整的8字节块,每个字节都是0x08。也就是说,分组是8字节时,数据长度模8为0,pad_len算出来是8而不是0。这样做的好处是解密时可以通过最后一个字节的值确定要删几个填充字节,不会和二义性纠缠。
在我的最终封装函数里,明文先pad,分组加密,解密后unpad。这一步看起来简单,但特别容易和“子密钥反序”搞混。正确的顺序是:加密的时候先pad再切块;解密的时候先逐块解出完整数据,再unpad去掉填充,顺序不要反。
4. 完整可运行的DES代码(Python)
4.1 完整代码清单
下面这一版我整理了完整的实现,包括全部置换表和S盒数据,Python 3.8以上版本无需任何第三方依赖,复制就能运行。代码里同时包含了ECB模式下的字符串加解密封装,以及标准测试向量验证。
from typing import List IP = [58, 50, 42, 34, 26, 18, 10, 2, 60, 52, 44, 36, 28, 20, 12, 4, 62, 54, 46, 38, 30, 22, 14, 6, 64, 56, 48, 40, 32, 24, 16, 8, 57, 49, 41, 33, 25, 17, 9, 1, 59, 51, 43, 35, 27, 19, 11, 3, 61, 53, 45, 37, 29, 21, 13, 5, 63, 55, 47, 39, 31, 23, 15, 7] IP_INV = [40, 8, 48, 16, 56, 24, 64, 32, 39, 7, 47, 15, 55, 23, 63, 31, 38, 6, 46, 14, 54, 22, 62, 30, 37, 5, 45, 13, 53, 21, 61, 29, 36, 4, 44, 12, 52, 20, 60, 28, 35, 3, 43, 11, 51, 19, 59, 27, 34, 2, 42, 10, 50, 18, 58, 26, 33, 1, 41, 9, 49, 17, 57, 25] E = [32, 1, 2, 3, 4, 5, 4, 5, 6, 7, 8, 9, 8, 9, 10, 11, 12, 13, 12, 13, 14, 15, 16, 17, 16, 17, 18, 19, 20, 21, 20, 21, 22, 23, 24, 25, 24, 25, 26, 27, 28, 29, 28, 29, 30, 31, 32, 1] P = [16, 7, 20, 21, 29, 12, 28, 17, 1, 15, 23, 26, 5, 18, 31, 10, 2, 8, 24, 14, 32, 27, 3, 9, 19, 13, 30, 6, 22, 11, 4, 25] PC1 = [57, 49, 41, 33, 25, 17, 9, 1, 58, 50, 42, 34, 26, 18, 10, 2, 59, 51, 43, 35, 27, 19, 11, 3, 60, 52, 44, 36, 63, 55, 47, 39, 31, 23, 15, 7, 62, 54, 46, 38, 30, 22, 14, 6, 61, 53, 45, 37, 29, 21, 13, 5, 28, 20, 12, 4] PC2 = [14, 17, 11, 24, 1, 5, 3, 28, 15, 6, 21, 10, 23, 19, 12, 4, 26, 8, 16, 7, 27, 20, 13, 2, 41, 52, 31, 37, 47, 55, 30, 40, 51, 45, 33, 48, 44, 49, 39, 56, 34, 53, 46, 42, 50, 36, 29, 32] SHIFT = [1, 1, 2, 2, 2, 2, 2, 2, 1, 2, 2, 2, 2, 2, 2, 1] S_BOX = [ [ [14, 4, 13, 1, 2, 15, 11, 8, 3, 10, 6, 12, 5, 9, 0, 7], [0, 15, 7, 4, 14, 2, 13, 1, 10, 6, 12, 11, 9, 5, 3, 8], [4, 1, 14, 8, 13, 6, 2, 11, 15, 12, 9, 7, 3, 10, 5, 0], [15, 12, 8, 2, 4, 9, 1, 7, 5, 11, 3, 14, 10, 0, 6, 13] ], [ [15, 1, 8, 14, 6, 11, 3, 4, 9, 7, 2, 13, 12, 0, 5, 10], [3, 13, 4, 7, 15, 2, 8, 14, 12, 0, 1, 10, 6, 9, 11, 5], [0, 14, 7, 11, 10, 4, 13, 1, 5, 8, 12, 6, 9, 3, 2, 15], [13, 8, 10, 1, 3, 15, 4, 2, 11, 6, 7, 12, 0, 5, 14, 9] ], [ [10, 0, 9, 14, 6, 3, 15, 5, 1, 13, 12, 7, 11, 4, 2, 8], [13, 7, 0, 9, 3, 4, 6, 10, 2, 8, 5, 14, 12, 11, 15, 1], [13, 6, 4, 9, 8, 15, 3, 0, 11, 1, 2, 12, 5, 10, 14, 7], [1, 10, 13, 0, 6, 9, 8, 7, 4, 15, 14, 3, 11, 5, 2, 12] ], [ [7, 13, 14, 3, 0, 6, 9, 10, 1, 2, 8, 5, 11, 12, 4, 15], [13, 8, 11, 5, 6, 15, 0, 3, 4, 7, 2, 12, 1, 10, 14, 9], [10, 6, 9, 0, 12, 11, 7, 13, 15, 1, 3, 14, 5, 2, 8, 4], [3, 15, 0, 6, 10, 1, 13, 8, 9, 4, 5, 11, 12, 7, 2, 14] ], [ [2, 12, 4, 1, 7, 10, 11, 6, 8, 5, 3, 15, 13, 0, 14, 9], [14, 11, 2, 12, 4, 7, 13, 1, 5, 0, 15, 10, 3, 9, 8, 6], [4, 2, 1, 11, 10, 13, 7, 8, 15, 9, 12, 5, 6, 3, 0, 14], [11, 8, 12, 7, 1, 14, 2, 13, 6, 15, 0, 9, 10, 4, 5, 3] ], [ [12, 1, 10, 15, 9, 2, 6, 8, 0, 13, 3, 4, 14, 7, 5, 11], [10, 15, 4, 2, 7, 12, 9, 5, 6, 1, 13, 14, 0, 11, 3, 8], [9, 14, 15, 5, 2, 8, 12, 3, 7, 0, 4, 10, 1, 13, 11, 6], [4, 3, 2, 12, 9, 5, 15, 10, 11, 14, 1, 7, 6, 0, 8, 13] ], [ [4, 11, 2, 14, 15, 0, 8, 13, 3, 12, 9, 7, 5, 10, 6, 1], [13, 0, 11, 7, 4, 9, 1, 10, 14, 3, 5, 12, 2, 15, 8, 6], [1, 4, 11, 13, 12, 3, 7, 14, 10, 15, 6, 8, 0, 5, 9, 2], [6, 11, 13, 8, 1, 4, 10, 7, 9, 5, 0, 15, 14, 2, 3, 12] ], [ [13, 2, 8, 4, 6, 15, 11, 1, 10, 9, 3, 14, 5, 0, 12, 7], [1, 15, 13, 8, 10, 3, 7, 4, 12, 5, 6, 11, 0, 14, 9, 2], [7, 11, 4, 1, 9, 12, 14, 2, 0, 6, 10, 13, 15, 3, 5, 8], [2, 1, 14, 7, 4, 10, 8, 13, 15, 12, 9, 0, 3, 5, 6, 11] ] ] def bytes2bin(data: bytes) -> List[int]: bits = [] for b in data: for i in range(7, -1, -1): bits.append((b >> i) & 1) return bits def bin2bytes(bits: List[int]) -> bytes: out = [] for i in range(0, len(bits), 8): v = 0 for bit in bits[i:i + 8]: v = (v << 1) | bit out.append(v) return bytes(out) def bits2hex(bits: List[int]) -> str: return bin2bytes(bits).hex().upper() def permute(bits: List[int], table: List[int]) -> List[int]: return [bits[x - 1] for x in table] def generate_subkeys(key_bits: List[int]) -> List[List[int]]: assert len(key_bits) == 64, "密钥必须是64位" key56 = permute(key_bits, PC1) left, right = key56[:28], key56[28:] subkeys = [] for shift in SHIFT: left = left[shift:] + left[:shift] right = right[shift:] + right[:shift] subkeys.append(permute(left + right, PC2)) return subkeys def sbox_substitute(bits48: List[int]) -> List[int]: out = [] for i in range(8): chunk = bits48[i * 6:(i + 1) * 6] row = (chunk[0] << 1) | chunk[5] col = (chunk[1] << 3) | (chunk[2] << 2) | (chunk[3] << 1) | chunk[4] val = S_BOX[i][row][col] out.extend([(val >> 3) & 1, (val >> 2) & 1, (val >> 1) & 1, val & 1]) return out def round_function(right32: List[int], subkey48: List[int]) -> List[int]: expanded = permute(right32, E) xored = [a ^ b for a, b in zip(expanded, subkey48)] substituted = sbox_substitute(xored) return permute(substituted, P) def des_block(bits64: List[int], subkeys: List[List[int]]) -> List[int]: ip = permute(bits64, IP) left, right = ip[:32], ip[32:] for subkey in subkeys: new_left = right new_right = [a ^ b for a, b in zip(left, round_function(right, subkey))] left, right = new_left, new_right pre_output = right + left return permute(pre_output, IP_INV) def des_encrypt_block(bits64: List[int], key_bits: List[int]) -> List[int]: return des_block(bits64, generate_subkeys(key_bits)) def des_decrypt_block(bits64: List[int], key_bits: List[int]) -> List[int]: return des_block(bits64, generate_subkeys(key_bits)[::-1]) def pkcs7_pad(data: bytes) -> bytes: pad_len = 8 - len(data) % 8 return data + bytes([pad_len]) * pad_len def pkcs7_unpad(data: bytes) -> bytes: pad_len = data[-1] if pad_len < 1 or pad_len > 8: raise ValueError("填充值非法") return data[:-pad_len] def des_encrypt(plaintext: bytes, key: bytes) -> bytes: if len(key) != 8: raise ValueError("DES密钥必须是8字节(64位)") key_bits = bytes2bin(key) subkeys = generate_subkeys(key_bits) padded = pkcs7_pad(plaintext) result = [] for i in range(0, len(padded), 8): block = bytes2bin(padded[i:i + 8]) result.append(bin2bytes(des_block(block, subkeys))) return b''.join(result) def des_decrypt(ciphertext: bytes, key: bytes) -> bytes: if len(key) != 8: raise ValueError("DES密钥必须是8字节(64位)") key_bits = bytes2bin(key) subkeys = generate_subkeys(key_bits)[::-1] result = [] for i in range(0, len(ciphertext), 8): block = bytes2bin(ciphertext[i:i + 8]) result.append(bin2bytes(des_block(block, subkeys))) return pkcs7_unpad(b''.join(result)) if __name__ == "__main__": test_key = bytes.fromhex("133457799BBCDFF1") test_msg = bytes.fromhex("0123456789ABCDEF") print("标准测试向量输出:", bits2hex(des_encrypt_block(bytes2bin(test_msg), bytes2bin(test_key)))) key = b"12345678" plaintext = b"Hello, DES implementation!" cipher = des_encrypt(plaintext, key) print("文本加密结果:", cipher.hex().upper()) recovered = des_decrypt(cipher, key) print("解密与原文一致:", recovered == plaintext)4.2 用标准测试向量验证实现
写完代码最怕的就是“看起来对了,实际错得离谱”。DES有个非常经典的公开测试向量:密钥取十六进制133457799BBCDFF1,明文取0123456789ABCDEF,期望密文是85E813540F0AB405。
上面代码运行时,第一行打印的标准测试向量输出就是这一串值。如果你的实现输出不是这个,说明至少有一个表或一个逻辑环节有问题。这个向量相当于一把尺子,能精准地帮你定位问题。
我实际调试时的经验是:一开始跑出错误结果不要慌,先在代码里加几个print,分别打印IP置换后的左右半部分、第一轮F函数的输出、第一轮子密钥,然后找一份靠谱的参考实现,逐段比对中间值。比对你就会发现,问题往往出在很具体的地方,比如某个S盒的数据少写了一个0,或者PC-2表某行前后顺序颠倒。
4.3 真实字符串的加解密测试
标准测试向量通过之后,再拿真实文本验证一次。我的代码里用b"12345678"当密钥,明文是"Hello, DES implementation!",这个明文长度不是8的整数倍,正好测到了PKCS7填充逻辑。运行后会先打印出一段十六进制密文,然后打印True,表示解密后的数据和原始明文完全一致。
这里有一个容易被忽略的点:密钥必须是8字节。如果你习惯用字符串当密钥,一定要先encode成bytes,并且保证编码后长度正好是8。比如中文密钥经过UTF-8编码后长度很大,就不能直接用。我的封装函数里已经做了长度校验,少了会直接抛ValueError。
5. 常见问题与排查技巧实录
5.1 测试向量对不上:先按这个顺序排查
如果标准测试向量输出不对,建议按下面这个顺序排查:
第一,确认S盒数据完全正确。S盒是数据量最大的部分,抄错概率最高,而且一旦错一个值,输出立刻不对。把8张表逐行和公开资料核对,不要只看开头几行。
第二,确认所有置换表的下标都经过减一处理。记住标准表从1开始,Python从0开始,最容易出问题的就是E表和PC-2表,因为这两个表数字范围跨度大,抄的时候很容易眼晕。
第三,确认最后一轮的左右交换。DES在16轮结束之后,必须把R16放在左边、L16放在右边,然后才做IP逆置换。我的代码里是right + left,这一步很多人会写反。
第四,确认子密钥生成时循环左移的位数。SHIFT表是[1,1,2,2,...],第1轮和第9轮都是1位,其余是2位,别把表记成固定2位。
| 症状 | 可能原因 | 处理方式 |
|---|---|---|
| 标准向量输出完全不对 | S盒数据错、置换表下标错 | 逐表核对并打印中间值 |
| 前面几轮对,后面发散 | 循环左移位数表错 | 核对SHIFT表 |
| 解密失败 | 子密钥没有反序 | 检查[::-1] |
5.2 解密出来乱码:三个高频原因
解密乱码是第二个高频问题,通常出在三个地方。
子密钥反序。加密用的是generate_subkeys(key_bits),解密必须用generate_subkeys(key_bits)[::-1]。如果解密直接复用了加密的子密钥顺序,只有加密的逆过程永远不会成立,结果一定是乱码。
填充没有处理。DES只能处理完整8字节块,解密后必须调用pkcs7_unpad,去掉填充字节。如果你只做了逐块解密就急着转字符串,多出来的填充字节会变成乱码字符。更隐蔽的是,如果你加密前没pad,解密后却unpad,或者反过来,都会出问题。
切块边界没对齐。密文长度必须是8的整数倍,如果因为填充错误导致密文长度出现意外,最后一组不满8字节,代码就会在处理最后一块时异常或产生错误结果。碰到这种情况,回过头检查加密时是否对明文长度做了pad。
5.3 速度慢是正常的:学习版与工程版的边界
如果你拿这版代码去加密一个几MB的文件,会明显感觉到慢。位列表方案每个位都是一个Python对象,运算开销非常大,加密1MB数据可能需要好几秒,这在学习和验证场景下没问题,但绝不能拿去搞真实加密。
想提速可以做两件事:一是把每个块从位列表改成整数,通过移位和掩码配合置换表实现,运算会快一个数量级;二是直接换用成熟密码库。我个人的建议是,学习阶段先用位列表把算法逻辑彻底搞明白,之后再考虑优化。永远不要觉得自己手写了一个能跑的DES,就可以替代工程中的标准库。
5.4 弱密钥、分组模式与下一步练习
写完之后还有两个值得了解的知识点。
第一,DES存在弱密钥。全0和全1这类密钥,会让16轮子密钥全部相同;还有一类半弱密钥,会让子密钥只有两种模式轮流出现。真实系统里使用DES时通常需要过滤掉这类密钥。虽然现在不建议用单DES,但这个常识在分析别的算法时也很有参考价值。
第二,我这版代码默认是ECB模式。ECB有个明显的缺陷:相同的明文块会得到相同的密文块。测试的时候你会发现,用同一把密钥反复加密同一个块,输出永远一样。真实场景更常用CBC模式:每个明文块先和上一块密文异或,再进入DES,这样相同明文块在不同位置会得到不同密文。
我在写完DES之后,顺手给外层加了一个CBC模式,改动量很小:加密时把上一个密文块缓存下来,当前明文块先和它异或再进des_block;解密时在上一个块完整解出之前,先把密文缓存下来,当前块解密后和缓存值异或回去走一遍。这一步做完,你对分组密码工作模式的理解也会更具体。
如果你也想手动实现一遍DES,我的建议是先别看完整代码,自己把置换表和S盒敲进去,然后对着标准测试向量调试,一步步把问题找出来。踩坑的过程,恰恰是这个练习价值最大的部分。