用 Python 写一个恩尼格玛机

头图作者:Bob Lord,链接:https://commons.wikimedia.org/w/index.php?curid=88679,以 CC BY-SA 3.0 协议授权。因此,本文也采用该协议。

起因

一天,闲暇之余,我偶然间看到一个视频:

【计算机博物志】战争密码(上集)如何复刻一台恩格玛机_哔哩哔哩_bilibili

对于恩尼格玛(Enigma)机,我虽然有所耳闻,但是并没有深入了解。于是我点了进去。

这种机器的加密方式太为精妙,于是我萌生出了用编程语言复刻它的想法。

简单说一下恩尼格玛机

简单来说,恩尼格玛密码机是德国人研发的一种加密解密机器。二战时,它被德军大规模运用。

这种机器的加密方式非常精妙:加密部件是若干个转子和一个反射器。抛开电学的知识:

  1. 一个字母进入一个转子,会从另一个字母出来,相当于换了一个字母;
  2. 按下一个字母,最右边的转子会转一格,也会带动其他的转子,原理和钟表很像;
  3. 由于转子会转动,从一个转子出来的字母进入另一个转子前,字母也会变化;
  4. 到了反射器,字母会被替换为另一个字母,然后逆向经过转子,相当于又一次进行了加密;
  5. 转子的次序和初始位置可以自定义。

可以看出,它的加密方式相当于给文本中每个字母设置了变化的密码表,一个字母对于一个密码表。理论上说,一台三转子的恩尼格玛密码机有 26 × 25 × 26 = 16900 个组合(虽然实际略小于这个数)。

另外,有些机器还有接线板。把两个字母的插口插上,相当于互换了两个字母。这样一来,组合的情况更多了。

它有以下性质:

  • 输入相同,转子、接线板配置不同,输出可能不同;
  • 输入不等于输出;
  • 转子、接线板配置相同时,输入密文,输出原文。

简单说,你先记下转子的安装方式和初始位置,连续按同一个按钮,记下输出结果。你会发现,输出的字母各不相同,但绝不可能是输入的字母。把转子位置调回去,输入上次的密文,你会发现,输出的都是那个字母。

恩尼格玛密码机工作原理

因为这种性质,英法两国的情报人员都认为它无法破解,但对一个一战战败国,它们并不在意;波兰的情报人员一度找到了破译的方向,但是由于加密方式的更新和德国的闪击战,也没能改变波兰的命运;最后,包括图灵在内的一群英国人,才很好地破解了它,也改变了二战的走向。

定义各个类

一台恩尼格玛机,有输入、输出、固定接口、转子、反射板和接线板组成。

输入和输出非常简单,用方法的传入参数和返回值就行。

固定接口、转子、反射板的结构很相似,都可以模拟为一个输入一个字母、输出一个字母的装置,至少需要对应表和位置信息。对于转子,它每次操作都会转动一格,使其位置发生改变,也可以手动调整位置。所以,我们可以建立一个类,包括这三者,命名为 Plate(圆盘)。然后创建继承于 Plate 的三个类。

接线板每一次连接,都会连接字母表中若干两个字母。

至于各组件与恩尼格玛机的关系,因为一些型号的恩尼格玛机没有接线板;如果有接线板,那么它是机器不可分割的一部分,所以接线板在恩尼格玛机初始化的时候一起初始化。至于圆盘,固定接口在恩尼格玛机初始化的时候一起初始化,而转子和反射器却在之前初始化。

这样一来,我们可以初步定义下面的结构:

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
class Plate:
"""圆盘,包括固定接口、转子、反射板"""
def __init__(self, map_table, position): ...
def encrypt(self, letter): ... # 加密单个字母


class EntryPlate(Plate):
"""固定接口"""
...


class Rotor(Plate):
"""转子"""
def __init__(self, map_table, position): ...
def forward(self): ... # 转动


class Reflector(Plate):
"""反射板"""
...


class Plugboard:
"""接线板"""
def __init__(self, parent): ...
def plug(self, letter_1, letter_2): ... # 接线
def unplug(self, letter): ... # 解除接线


class Enigma:
"""恩尼格玛机"""
def __init__(self, rotor_list, reflector): ...
# 接线板在恩尼格玛机初始化时在里面初始化,故不传入
def input(self, string): ... # 输入字符串
def set_position(self, *position_list): ... # 设定各转子位置

当然,每个类初始化需要传入的参数不止于此。而且,这些类实际上分布于不同的文件,我在这里放在一起只是为了方便讲解。

实现圆盘的功能

圆盘的基本功能,就是输入一个字母,输出另一个字母。

一般的恩尼格玛机只处理大写字母。为了实现功能,初始化时传入 map_table 作为 A-Z 转换后的结果。

为了探究如何进行转换,我们可以先做一个简单的机器,只用 ASDF 四个字母:

简化的恩尼格玛机

在初始位置,输入 A,线路如图所示:

初始位置输入 A 的线路图

注意:我们现在只是考虑代码怎么写,实际上的恩尼格玛机的原理与现在的简化模型有一些不可忽视的差异,我会在后面说明。

然后,最右边的转子向上转动一格,输入 A,线路如图所示:

右边的转子向上转动一格,输入 A 的线路图

先对比一下两次输入,电流通过最右边的转子的线路:

电流通过最右边的转子的对比图

我们不难发现,可以先写一个表示转子现在状态的方法,然后加密的时候就使用这个方法。

初始化转子的时候,我们会输入转子的输入符号(map_source)和输出结果(map_table),还有它的前后的圆盘(next_plate, prev_plate):

1
2
3
4
5
6
7
8
9
def __init__(self, map_source, map_table, prev_plate=None, next_plate=None):
self.map_source = map_source
dict_table = {}
for input_letter_index, input_letter in enumerate(map_source):
dict_table[input_letter] = map_table[input_letter_index]
self.dict_table = dict_table
self.prev_plate = prev_plate
self.next_plate = next_plate
self.position = 0

self.dict_table 表达了输入和输出对应关系。对于最右边的转子,如下:

1
{'A': 'D', 'S': 'A', 'D': 'S', 'F': 'F'}

试着输出 self.position 变化前后,转子的状态(current_state):

1
2
3
4
5
6
7
8
9
@property
def current_state(self):
right = list(self.dict_table.keys())
left = list(self.dict_table.values())
current_right = right[-self.position:] + right[:-self.position]
current_left = left[-self.position:] + left[:-self.position]
return {'left': current_left, 'right': current_right}
# self.position == 0: {'left': ['S', 'D', 'A', 'F'], 'right': ['A', 'S', 'D', 'F']}
# self.position == 1: {'left': ['F', 'S', 'D', 'A'], 'right': ['F', 'A', 'S', 'D']}

我们可以注意到,电流进入转子时的字母,与上一个圆盘的输出不一定一样。所以我们还需要找到进入转子时的字母:

1
2
3
def transform_letter(self, letter):
letter_index = self.prev_plate.current_state['right'].index(letter)
transformed_letter = self.current_state['right'][letter_index]

然后加密:

1
2
def encrypt(self, letter):
return self.dict_table[self.transform_letter(letter)]

上面的方法一直能够用到电流走出反射器。但是,如果电流从反射器出来,就要改代码了。这次我们不用 next_plateprev_plate,而是改用 left_plateright_plate,免得接下来糊涂。顺便把最左端和最右端的情况考虑一下。

另外,顺便加上调整位置的方法:

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
def __init__(self, map_source, map_table, left_plate=None, right_plate=None):
self.map_source = map_source
dict_table = {}
for input_letter_index, input_letter in enumerate(map_source):
dict_table[input_letter] = map_table[input_letter_index]
self.dict_table = dict_table
self.left_plate = left_plate
self.right_plate = right_plate
self.position = 0

def transform_letter(self, letter, from_plate):
letter_index = from_plate.current_state['right'].index(letter)
transformed_letter = self.current_state['right'][letter_index]

def encrypt(self, letter, letter_from='right'):
if letter_from == 'left':
from_plate = self.left_plate
else:
from_plate = self.right_plate
if from_plate:
letter = self.transform_letter(letter, from_plate)
if letter_from == 'left':
key_index = list(self.dict_table.values()).index(letter)
left_letter = list(self.dict_table.keys())[key_index]
return left_letter
else:
return self.dict_table[letter]

def set_position(self, position):
position_int = 0
if isinstance(position, str):
if position in self.map_source:
position_int = self.map_source.index(position)
elif isinstance(position, int):
if 0 <= position < len(self.map_source):
position_int = position
self.position = position_int

以上是放在 Plate 类中的方法。接下来,按需配置 auto_rotatable 的属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Plate:
def __init__(self, map_source, map_table, left_plate=None, right_plate=None, auto_rotatable=False):
self.map_source = map_source
dict_table = {}
for input_letter_index, input_letter in enumerate(map_source):
dict_table[input_letter] = map_table[input_letter_index]
self.dict_table = dict_table
self.left_plate = left_plate
self.right_plate = right_plate
self.position = 0
self.auto_rotatable = auto_rotatable

class Rotor(Plate):
def __init__(self, map_source, map_table, left_plate=None, right_plate=None, ...):
super().__init__(map_source, map_table, left_plate, right_plate, auto_rotatable=True)

class Reflector(Plate):
def __init__(self, map_source, map_table, right_plate=None, ...):
super().__init__(map_source, map_table, left_plate=None, right_plate=right_plate, auto_rotatable=False)

class EntryPlate(Plate):
def __init__(self, map_source, map_table, left_plate=None, ...):
super().__init__(map_source, map_table, left_plate, right_plate=None, auto_rotatable=False)

设置这个属性,是要给 Rotor 类添加方法 forward(),实现转动一格,连带着处理进位:

1
2
3
4
5
6
7
8
9
10
def forward(self):
original_position = self.position
if original_position == len(self.map_source) - 1:
self.position = 0
else:
self.position += 1
if self.left_plate and self.left_plate.position == len(self.map_source) - 1:
if self.left_plate.auto_rotatable:
self.left_plate.forward()
return self.position

圆盘的部分算是告一段落了。我们接下来开始构建接线板。

构建接线板

接线板的构建比较简单,就是建立一个对应关系表。

1
2
3
4
5
6
7
8
9
10
11
12
13
class Plugboard:
"""接线板"""
def __init__(self):
self.map_dict = {}

def plug(self, letter_1, letter_2):
self.map_dict[letter_1] = letter_2
self.map_dict[letter_2] = letter_1

def unplug(self, letter):
another_letter = self.map_dict[letter]
del self.map_dict[letter]
del self.map_dict[another_letter]

组装恩尼格玛机

由于之前提到的初始化的顺序,我们在初始化恩尼格玛机时,要做以下操作:

  1. 传入转子组成的列表(这个列表中的转子是从右到左的)、反射器;
  2. 定义反射器右边的圆盘为最左边的转子;
  3. 定义固定接口;
  4. 定义每个转子的左右圆盘;
  5. 初始化插线板。

故有下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Enigma:
def __init__(self, rotor_list, reflector):
self.rotors = rotor_list
self.reflector = reflector
self.reflector.right_plate = self.rotors[-1]
self.entry_plate = EntryPlate(
map_source=self.reflector.map_source, map_table=self.reflector.map_source, left_plate=self.rotors[0]
)
for rotor_index, rotor in enumerate(self.rotors):
if rotor_index < len(self.rotors) - 1:
rotor.left_plate = self.rotors[rotor_index + 1]
else:
rotor.left_plate = self.reflector
if rotor_index > 0:
rotor.right_plate = self.rotors[rotor_index - 1]
else:
rotor.right_plate = self.entry_plate
self.plugboard = Plugboard()

我们写一下设置各转子的位置的方法 set_position()。如果未填参数,都设置为 0(我们把初始位置定义为 0,这与实际的恩尼格玛机不同,这种情况下转子上面对应的数字是 01):

1
2
3
4
5
6
7
def set_position(self, *position_list):
if not position_list:
position_list = (0, ) * len(self.rotors)
elif len(position_list) != len(self.rotors):
raise AttributeError('Position list length is not equal to rotors number')
for rotor, position in zip(self.rotors, position_list):
rotor.set_position(position)

然后就是输入的方法 input()。终于到了最后的环节了,不过没太多好说的,之前的原理说的比较清楚了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def input(self, string):
new_string_list = []
for letter in string:
if letter in self.plugboard.map_dict:
letter = self.plugboard.map_dict[letter]
for rotor in self.rotors:
letter = rotor.encrypt(letter, 'right')
letter = self.reflector.encrypt(letter, 'right')
for rotor in reversed(self.rotors):
letter = rotor.encrypt(letter, 'left')
letter = self.entry_plate.encrypt(letter, 'left')
if letter in self.plugboard.map_dict:
letter = self.plugboard.map_dict[letter]
new_string_list.append(letter)
self.rotors[0].forward()
return ''.join(new_string_list)

测试

然后我们试着运行吧!先不要管插线板。

1
2
3
4
5
6
7
8
9
10
11
if __name__ == '__main__':
main_map_source = 'ASDF'
e = Enigma(
[
Rotor(map_source=main_map_source, map_table='DASF'),
Rotor(map_source=main_map_source, map_table='SDAF'),
Rotor(map_source=main_map_source, map_table='DFAS'),
],
Reflector(map_source=main_map_source, map_table='DFAS')
)
print(e.input('AA')) # 'DS'

输出:

1
DS

和预计的一样。

但是,之前的简化模型毕竟是简化模型,我们找一个实际的例子吧。

我找到了一个恩尼格玛机的模拟器:Enigma Simulator。它附带了文档,这样就能更好地还原真实情况。

但是文档里面提到的连线方式不太方便输入:

文档里面提到的连线方式

不过我又找到了另一个介绍恩尼格玛机的网页:Enigma Rotor and Umkehrwalze Wirings,它把连线方式放到一行,比较容易输入。

这种连线方式就比较容易了

然后:

1
2
3
4
5
6
7
8
9
10
11
if __name__ == '__main__':
main_map_source = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
e = Enigma(
[
Rotor(map_source=main_map_source, map_table='BDFHJLCPRTXVZNYEIWGAKMUSQO'),
Rotor(map_source=main_map_source, map_table='AJDKSIRUXBLHWTMCQGZNPYFVOE'),
Rotor(map_source=main_map_source, map_table='EKMFLGDQVZNTOWYHXUSPAIBRCJ'),
],
Reflector(map_source=main_map_source, map_table='YRUHQSLDPXNGOKMIEBFZCWVJAT')
)
print(e.input('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'))

输出结果:

1
UOBEFMBPLDCMTBSKTLXCWOGZDBUOBE

用模拟器对比结果:

模拟器的结果

1
BDZGOWCXLTKSBTMCDLPBMUQOFXYHCX

不能说是一模一样吧,至少也可以说是毫无关联。

怎么回事呢?

不能忽视的几个点

其实在写程序时,我踩了不知道多少坑。首先是如何判断上一个圆盘输出到下一个圆盘输入的走向。上面只写了结果,但是我当时花了三天时间,才从一个网站上得到了启发:

那个网站的录屏

然后就是之前说的输出不一致了。

翻阅 恩尼格玛密码机 - 维基百科,自由的百科全书 时,我发现里面有一句话:

当按下一个键后,转子会在电路接通之前转动。

也就是说,是先转动转子,后接通电路。

于是修改代码,将转子转动的指令调到翻译之前,再运行,输出:

1
OBEFMBPLDCMTBSKTLXCWOGZDBUOBEF

还是不一样啊。别急,我们打开模拟器的盖板:

打开模拟器的盖板

可以发现,从这个视角观看,恩尼格玛机的转子是向下转动的。而之前我们画的图,是向上转动的。

这就比较麻烦了。我想兼容之前的情况,所以修改代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Plate:
def __init__(self, map_source, map_table, left_plate=None, right_plate=None, auto_rotatable=False, rotate_up=False):
...
# 加上
self.rotate_up = rotate_up
...
@property
def current_state(self):
right = list(self.dict_table.keys())
left = list(self.dict_table.values())
transform_position = (-2 * self.rotate_up + 1) * self.position
current_right = right[transform_position:] + right[:transform_position]
current_left = left[transform_position:] + left[:transform_position]
return {'left': current_left, 'right': current_right}

这里面的 -2 * self.rotate_up + 1,当 self.rotate_upTrue 时,值为 -1;当 self.rotate_upFalse 时,值为 1

再运行:

1
BDZGOWCXLTKSBTMCDLPBMFEBOUBDZG

看上去正确了。等等:

1
2
BDZGOWCXLTKSBTMCDLPBMFEBOUBDZG  # 代码
BDZGOWCXLTKSBTMCDLPBMUQOFXYHCX # 模拟器

从第 22 个字母开始,就不一样了。这又是怎么回事?

看一下模拟器:

模拟器运行情况

看到没?进位并不是在一圈转完时进位的。至于怎么进位,之前说的文档有:

进位方式

改吧。

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
class Plate:
def __init__(self, map_source, map_table, left_plate=None, right_plate=None, auto_rotatable=False, rotate_up=False):
self.map_source = map_source
dict_table = {}
for input_letter_index, input_letter in enumerate(map_source):
dict_table[input_letter] = map_table[input_letter_index]
self.dict_table = dict_table
self.left_plate = left_plate
self.right_plate = right_plate
self.position = 0
self.auto_rotatable = auto_rotatable
self.rotate_up = rotate_up
self.turnover = []


class Rotor(Plate):

def turnover_single_check_and_standardize(self, turnover_single, map_source):
if isinstance(turnover_single, str):
return map_source.index(turnover_single)
elif isinstance(turnover_single, int):
return turnover_single

def __init__(self, map_source, map_table, left_plate=None, right_plate=None, rotate_up=False, turnover=None):
super().__init__(map_source, map_table, left_plate, right_plate, auto_rotatable=True, rotate_up=rotate_up)
if turnover is None:
turnover = map_source[-1]
if isinstance(turnover, list) or isinstance(turnover, tuple):
turnover_list = []
for turnover_item in turnover:
turnover_list.append(self.turnover_single_check_and_standardize(turnover_item, map_source))
else:
turnover_list = [self.turnover_single_check_and_standardize(turnover, map_source)]
self.turnover = turnover_list

def forward(self):
original_position = self.position
if original_position == len(self.map_source) - 1:
self.position = 0
else:
self.position += 1
if original_position in self.turnover:
if self.left_plate:
if self.left_plate.auto_rotatable:
self.left_plate.forward()
return self.position

运行,和模拟器的结果相比:

1
2
BDZGOWCXLTKSBTMCDLPBMFEBOXYHCX  # 代码
BDZGOWCXLTKSBTMCDLPBMUQOFXYHCX # 模拟器

中间有一小段不一样。哦,测试的转子没有设置进位,再改:

1
2
3
4
5
6
7
8
9
10
11
if __name__ == '__main__':
main_map_source = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
e = Enigma(
[
Rotor(map_source=main_map_source, map_table='BDFHJLCPRTXVZNYEIWGAKMUSQO', turnover='V'),
Rotor(map_source=main_map_source, map_table='AJDKSIRUXBLHWTMCQGZNPYFVOE', turnover='E'),
Rotor(map_source=main_map_source, map_table='EKMFLGDQVZNTOWYHXUSPAIBRCJ', turnover='Q'),
],
Reflector(map_source=main_map_source, map_table='YRUHQSLDPXNGOKMIEBFZCWVJAT')
)
print(e.input('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'))

运行:

1
2
BDZGOWCXLTKSBTMCDLPBMUQOFXYHCX  # 代码
BDZGOWCXLTKSBTMCDLPBMUQOFXYHCX # 模拟器

总算一样了。

试着看输出的结果能否返回最初的结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
if __name__ == '__main__':
main_map_source = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
e = Enigma(
[
Rotor(map_source=main_map_source, map_table='BDFHJLCPRTXVZNYEIWGAKMUSQO', turnover='V'),
Rotor(map_source=main_map_source, map_table='AJDKSIRUXBLHWTMCQGZNPYFVOE', turnover='E'),
Rotor(map_source=main_map_source, map_table='EKMFLGDQVZNTOWYHXUSPAIBRCJ', turnover='Q'),
],
Reflector(map_source=main_map_source, map_table='YRUHQSLDPXNGOKMIEBFZCWVJAT')
)
print(e.input('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'))
e.set_position()
print(e.input('BDZGOWCXLTKSBTMCDLPBMUQOFXYHCX'))

运行:

1
2
BDZGOWCXLTKSBTMCDLPBMUQOFXYHCX
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA

一切正常。

同样可以测试一下插上插线板的结果。因为前面写的太多了,这里就留给大家去做吧。

项目

实际上,我们还要考虑输入数据的正确性之类的信息。不过这里就不写了,太占地方。感兴趣的可以看看我在 GitHub 上的项目:DingJunyao/myenigma: Python-based Enigma.,欢迎去 Fork、Star。

我已经把项目写成了一个 Python 包,大家可以通过下面的命令行安装:

1
pip install myenigma

只支持 Python 3.10 之后的版本,文档:myenigma - 阿啊阿吖丁

实际上我本来想注册为 enigma 的,一开始在 PyPI 上也没发现有重名的。直到后来我偶然发现一个重名的,但是不知道为什么,它不在搜索记录中。