Enigma 密码破解
背景
Enigma 机是二战时德军使用的加密装置。它拥有三个可以转动的转子,一个固定的反射器,插线板,键盘,灯板等组件。插线板能够交换两个字母,转子可以将一个字母映射为另一个字母。这都是通过电路实现的。使用者点击键盘上的按键输入明文字母后,机器中电池产生的电流将通过插线板、三个转子、反射器,然后折返,再次通过三个转子和插线板,将灯板上的密文字母对应的灯泡点亮。每次按下按键时,转子会产生旋转,并且在到达特定位置时会产生进位。因此,每次加密时导通的电路会发生变化,使得 Enigma 机的加密过程难以被破解。Enigma 的实现和软件模拟器可以参考前面的文章自己打造一台恩尼格玛密码机。
在二战后期,德军使用的 Enigma 机允许操作者从多个转子中选择 3 个,每个转子有 26 种初始位置,并且插线板上可以交换 10 组字母。这产生了一个巨大的密钥空间,看上去使 Enigma 机坚不可摧。然而,波兰数学家 Rejewski 在 30 年代就发现了 Enigma 机操作流程上存在的弱点,并针对其设计了破解方法。在二战爆发后,相关的技术被共享给了盟军的情报机构,但德军也加强了 Enigma 密码的安全性。英国数学家和计算机科学家 Turing 进一步地设计了已知明文攻击的方法,成功破解了 Enigma 机,为世界反法西斯战争的胜利做出了重要的贡献。笔者复现了 Rejewski 和 Turing 破解 Enigma 机使用的方法,并将在本文中详细介绍。
算法原理
Rejewski 的破解方法
在上世纪三十年代,Enigma 机的操作流程是:每天所有人都会使用相同的日密钥,但日密钥不用于加密信息;在发送一条信息时,操作者需要先随机生成三个字母的信息密钥。操作者会首先使用日密钥,将信息密钥输入两次,产生的 6 个字母的密文作为开头。随后,操作者将机器的转子调整为信息密钥所对应的转子位置,并开始加密信息。信息密钥输入两次的原因是为了避免信息传输中因为干扰出现错误,收信方如果发现通过日密钥解码出的前 6 个字母明文不是重复两次的格式,则可以发现问题。但是,重复是加密的敌人,Rejewski 敏锐地观察到了这一点。第一个字母和第四个字母是同一个明文加密出来的,并且输入第一个字母时,转子的设置都是日密钥,是完全相同的。于是,在某一天中收到的所有由 Enigma 机加密的密文,都会满足类似这样的规则:
- 如果第 1 个字母为 A,那么第 4 个字母为 E;
- 如果第 1 个字母为 B,那么第 4 个字母为 L;
- 如果第 1 个字母为 C,那么第 4 个字母为 C;
- …
A | B | C | D | E | F | G | H | I | J | K | L | M | N | … |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
E | L | C | O | N | W | D | I | A | P | K | S | Z | H | … |
表:密文的第一个字母和第四个字母的对应表格(部分)
将结果列出,如上表所示。Rejewski 发现,表中的字母存在一些 “链”,例如,上一行的字母 A 对应下一行的字母 E,上一行的字母 E 对应下一行的字母 N,以此类推,最后又回到字母 A。这样就产生了字母链 AENHI。同时,也可以找到其它的字母链:BLSJP,C,DOFWVG,K,MZURTY,Q,X。如果机器的初始状态相同,那么得到的字母链自然也是相同的。更重要的是,Rejewski 发现,插线板的存在只会改变字母链中的字母,但不会影响各个字母链的长度。
例如,在上面的场景中,额外将 A 和 C 之间加入一条接线,那么,原先的 AENHI 链会变为 CENHI,C 链会变为 A 链;但两条链的长度是不变的。因此,字母链的长度特征是插线板作用下的不变量,它只和转子的初始位置有关,由此可以将插线板的作用和转子的初始位置解耦合。波兰的破译团队用了一年的时间进行预计算,得到了所有转子初始位置与字母链的长度的对应关系。此后,在收到新的密文后,破译者可以查表,将字母链的长度与预计算的结果进行比较,得到可能的转子的初始位置。在成功恢复出转子初始位置后,破译者可以对密文进行解密,之后进一步地分析插线板的状态。具体的算法原理如下。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35输入:前6个密文字母的对应表格 T
输出:解集 Solutions
预计算阶段:
----------------------------------------
# 由波兰 Bomba 机模拟的 Enigma 机
E ← Enigma()
# 存储的数据库,用于之后查表
Database ← Map()
for each S in PossibleSettings:
E.setPosition(S)
C ← FindChainsByEnigma(E)
Database.store(S, C)
破解过程:
----------------------------------------
Solutions ← Set()
for each S in PossibleSettings:
Matched ← true
for i ← 1 to 3:
Chain ← FindChainsInTableRow(T[i], T[i+3])
Expected ← Database.get(S + i)
if Chain ≠ Expected:
# 字母链没有通过检验
Matched ← false
if Matched = true:
# 当前初始状态对应的字母链通过了检验
# 将可能的解加入解集
Solutions.add(S)
Turing 的破解方法
在波兰沦陷之前,Rejewski 的方法被共享给了英国和法国人。英国情报部门认为德军很快会发现重复输入信息密钥的漏洞,并改进 Enigma 的操作流程,因此 Turing 接到的任务是发明一个不依赖于这个漏洞的破解方法。
Turing 发现,每天早上 6 点左右,德军都会发送天气预告,其中包含固定的德语单词 Wetterbericht。这个已知明文单词在信息中的位置会略有变化,但 Enigma 机有一个重要的特点:字母加密之后不会是其自身。因此,通过将 Wetterbericht 在密文中进行比较,就可以得到其可能出现的合法位置,这就相当于获得了一段明文和对应的密文。
序号 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
---|---|---|---|---|---|---|---|---|---|
明文 | A | S | T | W | B | K | D | J | X |
密文 | S | T | W | T | J | D | J | X | S |
序号 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
---|---|---|---|---|---|---|---|---|
明文 | S | A | X | Q | F | C | J | T |
密文 | A | Q | B | J | Q | J | O | D |
表:明文与密文的对应表格
将一段已知的明文和密文列出来,也可以得到一张表,如上表所示。同样的,这张表上也存在字母圈。例如,第 5 列 B 加密为 J,第 8 列 J 加密为 X,第 12 列 X 加密为 B。根据 Enigma 机的原理,明文字母在输入进转子前,会经过插线板;密文字母在从转子输出后,也会经过插线板。这种加密状态具体如下图所示。
假设 B 经过插线板后变为字母 V1,而 J 是由字母 V2 经过插线板变换得到的。由插线板的性质,字母 J 再次经过插线板后,会再次变为字母 V2。以此类推,我们也可以得知,V4 与 V1 应当是相等的,虽然我们还无法确定 V1 是哪个字母。因此,由这个由字母 B,J,X 组成的圈可以推导出以下结论:
存在字母 V1,使得其经过转子位置 5、转子位置 8、转子位置 12 的加密后,得到的结果是其自身。
而插线板的贡献,则完全被抵消掉了。因此,像 Rejewski 一样,Turing 也成功地将暴力破解密钥的搜索空间减小到了一个可以接受的范围。接下来要做的事情,便是枚举每一种转子的初始位置,验证是否存在满足要求的字母 V1。并且,这种循环的字母圈可能不止一种。只有当猜测的初始位置满足所有的字母圈约束,它才可能是正确的解。具体的算法原理如下。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21输入:明文与密文字母的对应表格 T
输出:解集 Solutions
# 由英国 Bombe 机模拟的 Enigma 机
E ← Enigma()
Solutions ← Set()
Chains ← FindChainsInTable(T)
for each S in PossibleSettings:
E.setPosition(S)
Matched ← true
for each C in Chains:
# 检查是否满足字母链,并得到插线板规则的约束条件
Matched, Constraint ← ExamineChains(E, C)
if Matched = true:
# 当前初始状态对应的字母链通过了检验
if SolveConstraint(Constraint) = true:
# 并且插线板规则没有冲突
Solutions.add(S)
实际样例的攻击过程和结果
通过 Python 语言编程实现了 Rejewski 和 Turing 的破解方法。Enigma 机的参数,包括转子和反射器的内部连接基于 Enigma I/Enigma M3 型号的机器设计。具体参数可以参考模拟器 The Enigma machine: Encrypt and decrypt online - cryptii。
实际样例的攻击是固定 Ring Setting 为 D-E-S,恢复转子顺序和 Initial Position。程序运行在 Apple M1 Pro 的 CPU 上,由于计算量不大,没有设计多进程并行计算加速。
Rejewski 的破解方法
程序首先根据密文的第一个字母和第四个字母,第二个字母和第五个字母,第三个字母和第六个字母的对应表格,找到循环圈。随后,程序遍历所有的转子顺序和 Initial Position,为不同状态的 Enigma 机生成对应的字母循环圈。程序自动根据循环圈中的字母数量,比对结果,并将比对成功的 Enigma 机设定输出。程序运行耗时 27 秒,得到的解为:转子顺序为 II- III-I,Initial Position 为 A-A-A。程序输出了唯一解,且与预期的初始设置符合。
Turing 的破解方法
程序根据已知的明文和密文,搜寻字母圈。随后,枚举转子的初始状态,寻找是否有状态能够满足所有的字母圈规则。程序运行耗时 74 秒,得到的解为:转子顺序为 II- III-I,Initial Position 为 A-A-A。程序输出了唯一解,且与预期的初始设置符合。
代码文档
以下代码 enigma.py
定义了 Enigma 机模拟器和转子行为的实现。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120class BaseRotor:
"""定义转子基础类,存储转子的转换规则"""
def __init__(self, wiring: str, notch: str):
"""初始化函数,传入转子的转换规则和转子的进位卡口位置"""
self.wiring = wiring
self.notch = ord(notch) - 65
# 按照题目要求,定义转子数据
ROTOR_1 = BaseRotor("EKMFLGDQVZNTOWYHXUSPAIBRCJ", "Q")
ROTOR_2 = BaseRotor("AJDKSIRUXBLHWTMCQGZNPYFVOE", "E")
ROTOR_3 = BaseRotor("BDFHJLCPRTXVZNYEIWGAKMUSQO", "V")
# 定义反射器数据
REFLECTOR = "YRUHQSLDPXNGOKMIEBFZCWVJAT"
def get_rotors(r1, r2, r3):
"""封装函数,通过转子的序号获得对应的转子"""
rotors = [ROTOR_1, ROTOR_2, ROTOR_3]
return [rotors[r1 - 1], rotors[r2 - 1], rotors[r3 - 1]]
class Rotor:
"""定义转子类,用于实际执行转子对字符的转换"""
def __init__(self, rotor: BaseRotor, ring_setting: str, position: str):
"""初始化函数,传入转子的转换规则,以及转子的 Ring setting 和初始位置"""
self.wiring = rotor.wiring
self.notch = rotor.notch
self.ring_setting = ord(ring_setting) - 65
self.position = ord(position) - 65
def forward(self, num: int) -> int:
"""转子的前向转换函数"""
delta = self.position - self.ring_setting
num = (num + delta) % 26
num = ord(self.wiring[num]) - 65
num = (num - delta) % 26
return num
def backward(self, num: int) -> int:
"""转子的后向转换函数"""
delta = self.position - self.ring_setting
num = (num + delta) % 26
num = self.wiring.index(chr(num + 65))
num = (num - delta) % 26
return num
def rotate(self) -> bool:
"""转子的进位函数"""
flag = self.position == self.notch
self.position = (self.position + 1) % 26
return flag
class Enigma:
"""定义 Enigma 类,用于实际执行加密/解密"""
def __init__(self, reflector: str, rotors: list[Rotor], rings: str, positions: str):
"""初始化函数,传入反射器的转换规则,以及三个转子的转换规则、Ring setting 和初始位置"""
self.rotors = [Rotor(rotors[i], rings[i], positions[i])
for i in range(3)]
self.reflector = reflector
def rotate(self):
"""模拟转子的旋转和进位"""
# 右侧转子旋转
if self.rotors[2].rotate():
# 如果右侧转子进位,中间转子旋转
if self.rotors[1].rotate():
# 如果中间转子进位,左侧转子旋转
self.rotors[0].rotate()
elif self.rotors[1].position == self.rotors[1].notch:
# 机器存在 Double step 问题,模拟这一行为
if self.rotors[1].rotate():
# 如果中间转子进位,左侧转子旋转
self.rotors[0].rotate()
def encrypt_single(self, char: str):
"""加密单个字符"""
# 将明文字符转换为数字
num = ord(char) - 65
# 从右至左依次经过三个转子
for i in range(2, -1, -1):
num = self.rotors[i].forward(num)
# 通过反射器进行转换
num = ord(self.reflector[num]) - 65
# 从左至右依次经过三个转子
for i in range(3):
num = self.rotors[i].backward(num)
# 将数字转换为密文字符
return chr(num + 65)
def encrypt(self, plaintext: str):
"""加密字符串"""
buffer = ""
for char in plaintext:
self.rotate()
buffer += self.encrypt_single(char)
return buffer
def generate_chain(self):
"""对某一机器状态,生成A-Z对应的密文"""
buffer = ""
for i in range(26):
char = chr(i + 65)
buffer += self.encrypt_single(char)
return buffer
def init_positions():
"""生成所有可能的机器状态"""
for i in range(26):
for j in range(26):
for k in range(26):
yield chr(i + 65) + chr(j + 65) + chr(k + 65)
if __name__ == "__main__":
# 检查一下 Enigma 机和题目里的是不是一样的。这里是没有接线板的结果。
enigma = Enigma(REFLECTOR, get_rotors(2, 3, 1), "DES", "AAA")
assert enigma.encrypt("ASTWBKDJXSAXQFCJT") == "NVYEAYJBLRPBIRJOD"
print("请运行 rejewski.py 或 turing.py")
以下代码 rejewski.py
实现了 Rejewski 的破解方法。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67# 使用标准库 itertools 辅助函数
from itertools import permutations
from enigma import Enigma, REFLECTOR, get_rotors, init_positions
def find_chain(alphabet, letters):
"""按照 Rejewski 的策略,在两行密文中找到字母链条"""
groups = []
grouped = alphabet[0]
current_group = alphabet[0]
while len(grouped) < 26:
letter = current_group[-1]
dst = letters[alphabet.index(letter)]
if dst in current_group:
groups.append(current_group)
current_group = ""
for i in range(26):
if not alphabet[i] in grouped:
current_group = alphabet[i]
grouped += current_group
break
else:
grouped += dst
current_group += dst
if current_group:
groups.append(current_group)
return groups
def chain_fingerprint(rows):
"""将字母链条的数量和长度,作为机器状态的指纹"""
chains = []
for i in range(3):
chains.append(find_chain(rows[i], rows[i + 3]))
return [sorted(map(lambda s: len(s), chain)) for chain in chains]
def enigma_fingerprint(enigma):
"""对特定的机器生成指纹"""
rows = []
for _ in range(6):
enigma.rotate()
rows.append(enigma.generate_chain())
return chain_fingerprint(rows)
def rejewski_crack():
"""按照 Rejewski 的策略进行破解"""
# 题目中给出的第 4 5 6 行密文
configs = [
"ELCONWDIAPKSZHFBQTJYRGVXMU",
"MRWJFDVSQEXUCONHBIPLTGAYZK",
"WADFRPOLNTVCHMYBJQIGEUSKZX"
]
# 第 1 2 3 行密文
alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
expected = chain_fingerprint([alphabet] * 3 + configs)
# 固定 Ring setting,恢复转子顺序和 Initial position
for r in permutations(range(1, 4)):
print("Trying rotor order", r)
rotors = get_rotors(*r)
for p in init_positions():
enigma = Enigma(
REFLECTOR, rotors, "DES", p)
# 指纹符合,则认为找到了正确的机器状态
if enigma_fingerprint(enigma) == expected:
print("Solution found:", r, p)
if __name__ == "__main__":
rejewski_crack()
turing.py
中实现了 Turing 的破解方法。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115# 使用 networkx 库,寻找图中的环路
import networkx as nx
# 使用标准库 itertools 辅助函数
from itertools import permutations, product
from enigma import Enigma, REFLECTOR, get_rotors, init_positions
class Cycle():
"""封装一个类,用于表示 Turing 的已知明文攻击的方法中找到的圈"""
def __init__(self, letters) -> None:
"""初始化函数,记录圈中的字母"""
self.letters = letters
self.offsets = []
def __repr__(self):
return f"Cycle({self.letters})"
def find_cycle():
"""由题目中给出的已知明文和密文,找到其中所有的圈"""
a = "ASTWBKDJXSAXQFCJT"
b = "STWTJDJXSAQBJQJOD"
edges = [(a[i], b[i]) for i in range(len(a))]
g = nx.DiGraph(edges)
cycles = []
for c in nx.simple_cycles(g):
cycle = Cycle(c)
for i in range(len(c)):
cycle.offsets.append(edges.index((c[i], c[(i + 1) % len(c)])))
cycles.append(cycle)
cycles.sort(key=lambda c: len(c.letters))
return cycles
def get_plugboard_constraint(cycle, guess):
"""
虽然题目不要求恢复接线板,但在猜测字母圈的过程中,自然会得到接线板存在的规则
它们可以用作约束条件,判断猜测的转轮初值是否正确
"""
mapping = {}
for index, l in enumerate(cycle.letters):
if l in mapping:
if not mapping[l] == guess[index]:
return None
elif guess[index] in mapping:
if not mapping[guess[index]] == l:
return None
else:
mapping[l] = guess[index]
mapping[guess[index]] = l
return mapping
def examine_constraints(constraints):
"""
检查我们猜测的接线板规则是否存在冲突
例如,为了满足第一个圈,我们需要假定 A 连接到了 B
而为了满足第二个圈,我们需要假定 A 连接到了 C
这两个规则是冲突的,因此我们需要放弃这个猜测
"""
for constraint in product(*constraints):
mapping = {}
succeeded = True
for c in constraint:
for k, v in c.items():
if k in mapping:
if not mapping[k] == v:
succeeded = False
elif v in mapping:
if not mapping[v] == k:
succeeded = False
else:
mapping[k] = v
if succeeded:
return True
def apply_cycles(mappings, cycles):
"""检查某种转轮初始值是否满足全部的字母圈"""
constraints = []
for cycle in cycles:
constraint = []
for i in range(26):
guess = [chr(i + 65)]
for j in cycle.offsets:
guess.append(mappings[j][ord(guess[-1]) - 65])
if guess[-1] == guess[0]:
c = get_plugboard_constraint(cycle, guess)
if c:
constraint.append(c)
if not len(constraint):
return False
else:
constraints.append(constraint)
return examine_constraints(constraints)
def turing_crack():
"""按照 Turing 的策略进行破解"""
# 固定 Ring setting,恢复转子顺序和 Initial position
for r in permutations(range(1, 4)):
print("Trying rotor order", r)
rotors = get_rotors(*r)
for p in init_positions():
enigma = Enigma(
REFLECTOR, rotors, "DES", p)
mappings = []
cycles = find_cycle()
for _ in range(max(map(lambda c: max(c.offsets), cycles)) + 1):
enigma.rotate()
mappings.append(enigma.generate_chain())
if apply_cycles(mappings, cycles):
print("Solution found:", r, p)
if __name__ == "__main__":
turing_crack()