手写DES加解密:Feistel结构与密钥调度的Python实现
2026/9/17 15:10:18 网站建设 项目流程

简介:本资源是一份面向高校密码学与信息安全课程学习者的DES对称加密算法实验教学文档,聚焦算法原理理解与工程实现验证。文档系统梳理DES的64位分组处理机制、56位有效密钥结构、16轮Feistel迭代流程,详解初始置换IP、S盒非线性变换、P置换、子密钥生成(PC-1/PC-2)等核心环节,并结合C语言代码框架(含完整置换表与S盒定义)指导加解密编程实践,助力读者掌握混淆与扩散安全特性的实证分析方法。资源为单个431KB的Word文档(.docx),内容涵盖实验目的、原理、环境、详细步骤及代码片段,结构清晰,理论与实现紧密结合。目前已有204人学习下载,适合密码学初学者通过动手实验深入理解经典分组密码的设计思想与运行细节。

1. 为什么今天还要动手实现 DES?不是早被 AES 取代了吗?

很多人看到“实验3 对称密码算法DES.docx”第一反应是:这玩意儿不是上世纪70年代的老古董?NIST早在2002年就正式弃用DES,连3DES也已在2023年被NIST SP 800-131A Rev.2明确标记为“禁止用于新系统”。但现实是——软考信息安全工程师、高校密码学实验课、金融系统遗留协议逆向、嵌入式设备固件分析、CTF密码题靶场,仍高频出现DES。它不是“过时”,而是密码学的底层语法锚点:S盒置换、Feistel结构、密钥调度、差分/线性分析的最小可验证载体。本实验不追求性能或合规,而聚焦在无第三方库依赖下,用纯Python逐轮复现DES加解密全流程,尤其吃透56位密钥如何经PC-1、循环左移、PC-2生成16轮子密钥,以及E扩展、异或、S盒查表、P置换这四步Feistel核心操作。适合刚学完《现代密码学》第3章、手头只有Python解释器、需要交实验报告且想真正看懂每一步输出的同学。


2. DES加解密的Feistel结构与密钥调度:为什么必须手写而不调用pycryptodome?

2.1 Feistel网络的本质:不是“加密函数”,而是“可逆结构模板”

DES采用16轮Feistel结构,其核心思想是:将64位明文分为左右两半(L₀, R₀),每轮执行
Rᵢ = Lᵢ₋₁ ⊕ F(Rᵢ₋₁, Kᵢ)
Lᵢ = Rᵢ₋₁
其中F函数包含E扩展、密钥异或、S盒替换、P置换四步。关键在于——F函数本身无需可逆,整个结构天然可逆(只需反向使用子密钥)。这解释了为何DES加解密流程几乎相同,仅子密钥顺序相反。若直接调用pycryptodomeDES.new(),你看到的是cipher.encrypt(plaintext)一行结果,却无法观察L/R分组在每轮的十六进制变化、S盒输出是否符合标准表、P置换后比特位是否错位。实验价值荡然无存。

提示:Feistel结构中,第i轮输入是(Lᵢ₋₁, Rᵢ₋₁),输出是(Lᵢ, Rᵢ) = (Rᵢ₋₁, Lᵢ₋₁ ⊕ F(Rᵢ₋₁, Kᵢ))。解密时,将密文(Cₗ, Cᵣ)作为(L₁₆, R₁₆),用K₁₆→K₁顺序代入同一F函数,即可还原(L₀, R₀)。

2.2 密钥调度:56位有效密钥如何生成16个48位子密钥?

DES原始密钥为64位(8字节),但每字节第8位为奇偶校验位,实际有效密钥长度56位。密钥调度分三步:

  1. PC-1置换(Permuted Choice-1):将64位密钥按固定表打乱,丢弃8个校验位,输出56位中间密钥
  2. 16轮循环左移:将56位密钥分为C₀(28位)、D₀(28位)两段;第i轮对Cᵢ₋₁、Dᵢ₋₁分别左移shift[i]位(shift=[1,1,2,2,2,2,2,2,1,2,2,2,2,2,2,1])
  3. PC-2置换(Permuted Choice-2):将每轮移位后的Cᵢ+Dᵢ(56位)按另一固定表选取48位,生成Kᵢ

以下为PC-1和PC-2置换表(标准FIPS 46-3定义):

# PC-1置换表(56位输出,索引从1开始) 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 ] # PC-2置换表(48位输出) 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 ]
2.2.1 手写密钥调度函数:逐行解析位操作逻辑
def generate_subkeys(key_bytes): """ 输入: 8字节bytes对象(64位密钥) 输出: list of 16个48位bytes(每个为6字节) """ # 步骤1: 字节转56位整数(跳过校验位) key_int = 0 for b in key_bytes: key_int = (key_int << 8) | b # 按PC-1提取56位,注意:PC1表索引从1开始,需减1 pc1_key = 0 for i, pos in enumerate(PC1): bit = (key_int >> (64 - pos)) & 1 # 取第pos位(高位在左) pc1_key = (pc1_key << 1) | bit # 步骤2: 分割C0/D0,循环左移 c, d = pc1_key >> 28, pc1_key & 0xFFFFFFF # 高28位C0,低28位D0 subkeys = [] shifts = [1,1,2,2,2,2,2,2,1,2,2,2,2,2,2,1] for i in range(16): # 循环左移shifts[i]位 c = ((c << shifts[i]) | (c >> (28 - shifts[i]))) & 0xFFFFFFF d = ((d << shifts[i]) | (d >> (28 - shifts[i]))) & 0xFFFFFFF cd = (c << 28) | d # 合并56位 # 步骤3: PC-2置换得48位子密钥 k = 0 for j, pos in enumerate(PC2): bit = (cd >> (56 - pos)) & 1 k = (k << 1) | bit # 转为6字节bytes(高位在前) subkeys.append(k.to_bytes(6, 'big')) return subkeys

参数说明与关键点

  • key_int构建时,key_bytes[0]对应最高8位,因此>> (64-pos)确保按标准位序取位;
  • cd的循环左移用(c << s) | (c >> (28-s))实现,& 0xFFFFFFF(28个1)截断高位;
  • PC-2输出k为48位整数,.to_bytes(6, 'big')生成6字节,与DES标准子密钥格式一致;
  • 若此处输出与NIST测试向量不符,首要检查PC-1/PC-2表索引是否从1开始、位序是否高位在左。

3. F函数四步详解:E扩展、异或、S盒查表、P置换的位级实现

3.1 E扩展:32位→48位,为什么必须“重复相邻位”?

F函数第一步是E扩展(Expansion),将32位右半部分Rᵢ₋₁扩展为48位,以便与48位子密钥Kᵢ异或。E表定义如下(48位输出,索引从1):

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 ]

E扩展并非简单补零,而是将Rᵢ₋₁的边界位(bit1、bit32)各复制一次,并让内部位bit2~bit31在E表中出现两次(如bit4出现在位置5和位置7)。这种设计增强雪崩效应——单比特变化可影响多个S盒输入。实现时需将32位整数按位拆解:

def e_expand(r_int): """r_int: 32位整数(0~2^32-1)""" expanded = 0 for i, pos in enumerate(E): # E表共48项 # pos为1~32,取r_int第pos位(高位在左,pos=1为最高位) bit = (r_int >> (32 - pos)) & 1 expanded = (expanded << 1) | bit return expanded # 48位整数

3.2 S盒替换:8个6位→4位映射表,线性分析的起点

DES的8个S盒是其非线性核心。每个S盒接收6位输入(b₁b₂b₃b₄b₅b₆),输出4位:

  • 行号 =2*b₁ + b₆(0~3)
  • 列号 =b₂b₃b₄b₅二进制值(0~15)
  • 查S盒[row][col]得4位输出

标准S1盒(其余7个类似):

S1 = [ [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] ]
3.2.1 S盒查表函数:处理6位分组与边界对齐
def s_boxes(input_48bits): """input_48bits: 48位整数,切分为8个6位块""" result = 0 s_boxes_list = [S1, S2, S3, S4, S5, S6, S7, S8] # 定义全部8个S盒 for i in range(8): # 取第i个6位块:从高位开始,每6位一组 start_bit = 48 - (i + 1) * 6 block = (input_48bits >> start_bit) & 0x3F # 6位掩码 row = ((block >> 5) & 0x01) * 2 + (block & 0x01) # b1,b6 col = (block >> 1) & 0x0F # b2-b5 s_out = s_boxes_list[i][row][col] result = (result << 4) | s_out return result # 32位整数

注意:s_boxes输出为32位整数,后续需经P置换。此处block & 0x3F确保取低6位,>> start_bit保证从高位正确截取——这是初学者最易出错的位序问题。

3.3 P置换:32位重排,完成F函数最后一环

P置换表(32位输出):

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 ]

实现逻辑与E扩展类似,但输入输出均为32位:

def p_permute(s_output): """s_output: 32位整数""" permuted = 0 for pos in P: # P表48项?不,是32项!标准P表长32 bit = (s_output >> (32 - pos)) & 1 permuted = (permuted << 1) | bit return permuted
3.3.1 F函数完整组装:E→异或→S→P
def f_function(r_int, subkey_bytes): """r_int: 32位整数;subkey_bytes: 6字节子密钥""" # 1. E扩展 expanded = e_expand(r_int) # 2. 与子密钥异或(需将6字节转48位整数) subkey_int = int.from_bytes(subkey_bytes, 'big') xor_result = expanded ^ subkey_int # 3. S盒替换 s_out = s_boxes(xor_result) # 4. P置换 return p_permute(s_out)

关键验证点

  • 对NIST测试向量key=0x0123456789ABCDEF,plaintext=0x0000000000000000,首轮F函数输入R₀=0x00000000,K₁应为0x1920272a2e313437(hex),E(R₀)=0x000000000000,XOR后仍为0,S盒输出全0,P置换后仍为0 —— 此为调试基线。

4. 16轮Feistel迭代与最终IP⁻¹逆置换:如何避免左右分组错位?

4.1 初始置换IP与逆置换IP⁻¹:64位比特重排的不可省略步骤

DES加解密均以IP(Initial Permutation)开始,以IP⁻¹结束。IP表(64位):

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⁻¹是IP的逆置换表(即IP[i]=j ⇒ IP⁻¹[j]=i),标准值为:

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 ]
4.1.1 IP置换函数:处理64位整数的位重排
def ip_permute(block_int): """block_int: 64位整数""" permuted = 0 for pos in IP: bit = (block_int >> (64 - pos)) & 1 permuted = (permuted << 1) | bit return permuted def ip_inv_permute(block_int): """block_int: 64位整数""" permuted = 0 for pos in IP_inv: bit = (block_int >> (64 - pos)) & 1 permuted = (permuted << 1) | bit return permuted

4.2 16轮主循环:左右分组更新与子密钥顺序

加解密唯一区别在于子密钥顺序:加密用K₁→K₁₆,解密用K₁₆→K₁。统一函数:

def des_rounds(block_int, subkeys, decrypt=False): """block_int: 64位整数;subkeys: 16个6字节list;decrypt: True为解密""" # IP置换 block = ip_permute(block_int) # 分割L0, R0(各32位) l, r = (block >> 32) & 0xFFFFFFFF, block & 0xFFFFFFFF # 16轮迭代 if decrypt: subkeys = subkeys[::-1] # 反向使用子密钥 for i in range(16): # Feistel轮函数 f_out = f_function(r, subkeys[i]) # 更新:L_i = R_{i-1}, R_i = L_{i-1} XOR F(R_{i-1}, K_i) new_r = l ^ f_out l, r = r, new_r # 合并R16,L16(注意:最后交换) final_block = ((r << 32) | l) & 0xFFFFFFFFFFFFFFFF # IP逆置换 return ip_inv_permute(final_block)

关键细节

  • block >> 32取高32位为L₀,& 0xFFFFFFFF确保32位;
  • 第16轮结束后,输出为(R₁₆, L₁₆),故合并时r放高位、l放低位;
  • & 0xFFFFFFFFFFFFFFFF防止Python整数溢出导致高位符号位错误;
  • 解密时subkeys[::-1]创建新列表,不影响原密钥调度。

5. 实验验证与常见陷阱:用NIST向量定位S盒或置换表错误

5.1 必测NIST向量:单轮调试与全加密比对

NIST SP 800-20附录B提供权威测试向量。最简验证用向量#1:

  • Key:0x133457799BBCDFF1
  • Plaintext:0x0123456789ABCDEF
  • Ciphertext:0x85E813540F0AB405

编写验证脚本:

def test_des(): key_bytes = bytes.fromhex("133457799BBCDFF1") pt_bytes = bytes.fromhex("0123456789ABCDEF") ct_expected = bytes.fromhex("85E813540F0AB405") # 转64位整数(大端) pt_int = int.from_bytes(pt_bytes, 'big') key_int = int.from_bytes(key_bytes, 'big') # 生成子密钥 subkeys = generate_subkeys(key_bytes) # 加密 ct_int = des_rounds(pt_int, subkeys, decrypt=False) ct_bytes = ct_int.to_bytes(8, 'big') print(f"Expected: {ct_expected.hex().upper()}") print(f"Got: {ct_bytes.hex().upper()}") print(f"Match: {ct_bytes == ct_expected}") test_des()
5.1.1 分阶段断点调试:当结果不符时,查哪一步?
阶段检查点正确值(向量#1)调试命令
IP置换后L₀,R₀L₀=0x00400800, R₀=0x00000000print(f"L0={l:08X}, R0={r:08X}")
第1轮F输出F(R₀,K₁)0x00000000(因R₀=0且K₁异或后仍0)f_function返回前打印s_out
第1轮后L₁,R₁L₁=0x00000000, R₁=0x00400800打印l,r更新后值
IP⁻¹后最终密文0x85E813540F0AB405比对ct_bytes

提示:若L₀/R₀错误,检查IP表索引是否从1开始、>> (64-pos)是否到位;若F输出非0,检查E扩展是否漏位、S盒行列计算是否颠倒(b₁b₆为行,b₂b₃b₄b₅为列)。

5.2 三个高频陷阱与修复方案

5.2.1 陷阱1:字节序混淆(大端 vs 小端)

DES标准所有操作基于大端序:密钥0x0123456789ABCDEF对应字节[0x01,0x23,0x45,0x67,0x89,0xAB,0xCD,0xEF]。若用int.from_bytes(..., 'little'),PC-1置换将完全错乱。
修复:所有bytes → int转换强制'big',所有int → bytes同理。

5.2.2 陷阱2:S盒输入6位块切分错误

常见错误:将48位xor_resultxor_result >> (42-i*6) & 0x3F取块,但i从0开始导致首块取错。正确做法是start_bit = 48 - (i+1)*6,确保第0块取最高6位。
验证:对xor_result=0x3F000000000000,第0块应为0x3F,第1块为0x00

5.2.3 陷阱3:Feistel轮次未交换L/R导致解密失败

解密时若忘记final_block = (r << 32) | l,而写成(l << 32) | r,则IP⁻¹后结果全错。
验证:加密后立即解密,应严格等于原文。添加assert des_rounds(des_rounds(pt_int, subkeys), subkeys, True) == pt_int

5.2.4 性能优化提示:预计算置换表与S盒查表

对实验环境,纯Python已足够;但若需加速,可将IP、PC-1等置换表预编译为位运算掩码数组,或用array.array('B')存储S盒提升缓存命中率——不过这已超出“实验3”的教学目标,留作拓展思考。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询