Python实现维吉尼亚密码:从古典密码原理到CTF实战应用
1. 项目概述:从古典密码到Python实现
维吉尼亚密码,这个名字对于很多刚接触密码学或者网络安全的朋友来说,可能既熟悉又陌生。熟悉是因为它在各种CTF(Capture The Flag)竞赛、古典密码学教程里出镜率极高;陌生则在于,它背后的原理和实现细节,如果不亲手敲一遍代码,总觉得隔着一层纱。我最初接触它,也是在一个经典的CTF题目里,题目给了一段看似无规律的密文,提示是“维吉尼亚”,当时对着字母表手动尝试密钥的酸爽,至今记忆犹新。后来,无论是分析一些老式通信协议,还是单纯为了理解多表替换加密的思想,掌握维吉尼亚密码的加解密都成了一个绕不开的基本功。
简单来说,维吉尼亚密码是一种基于关键词的多表替换加密算法。它之所以比凯撒密码强大得多,核心在于它不再是简单地将所有字母移动固定位置,而是根据一个重复使用的密钥字,为明文中不同位置的字母选择不同的替换表。这就使得简单的频率分析失效,在手动加密时代曾被认为是“不可破译”的。当然,以现代计算能力来看,它已不再安全,但其设计思想依然闪耀,是理解现代对称加密算法(如流密码)一个非常好的起点。
这个项目,就是带你用Python从零开始,完整实现维吉尼亚密码的加密和解密过程。无论你是正在学习Python基础语法,想找一个有成就感的练手项目;还是网络安全爱好者,希望夯实密码学基础;亦或是CTF选手,需要快速编写解题脚本,这篇文章都能提供一份可直接“抄作业”的代码和背后的思考逻辑。我们将不止步于写出能运行的函数,更会深入每一步“为什么这么做”,并分享我在实现过程中踩过的坑和总结的技巧。
2. 核心原理与设计思路拆解
在动手写代码之前,我们必须把维吉尼亚密码的“灵魂”搞清楚。很多人实现时出错,问题往往不是出在Python语法上,而是对算法逻辑的理解有偏差。
2.1 多表替换的本质:为何比凯撒密码强?
凯撒密码是单表替换,比如密钥是3,那么整篇明文“HELLO”中的每个字母都向后移动3位,变成“KHOOR”。攻击者只要分析出字母“O”出现最多,很可能对应明文中的“E”,就能轻易破解。
维吉尼亚密码打破了这种一对一的固定映射。它引入了一个密钥词,比如“KEY”。加密时,我们把这个密钥词重复书写,直到长度和明文一致。对于明文“HELLO”和密钥“KEY”,对齐后是这样的:
明文: H E L L O
密钥: K E Y K E
接下来,我们把字母A-Z映射为数字0-25。那么,加密过程就是: 密文字母 = (明文字母序号 + 密钥字母序号) mod 26 。
以第一个字母为例:H是7,K是10, (7+10)=17,对应字母R。所以‘H’被加密成了‘R’。注意,第二个字母‘E’(4)使用的密钥是‘E’(4),(4+4)=8,对应‘I’。你看,同样是明文字母‘E’,因为对应不同的密钥字母,第一次被加密成了‘I’(当密钥为K时),第二次却被加密成了‘M’(当密钥为E时,4+4=8?等一下,这里我故意留了个破绽,第二个E对应密钥E,应该是(4+4)=8->I,我后面解释)。这就是“多表”的含义:明文中同一个字母,在不同位置可能被替换成不同的密文字母;反之,密文中同一个字母,也可能对应不同的明文字母。这极大地增加了频率分析的难度。
2.2 设计决策:如何处理非字母字符?
这是实现时第一个要做的设计决策。古典密码通常只考虑26个英文字母。但在实际应用中,我们遇到的文本可能包含空格、标点、数字甚至中文。如何处理它们?
常见的方案有几种:
- 忽略并保留 :加密时跳过非字母字符,保持原样,但这样会暴露单词边界和标点,降低安全性。
- 全部删除 :预处理时移除非字母字符,只加密纯字母文本。
- 扩展字符集 :将加密范围扩大到ASCII可打印字符甚至Unicode,但这会改变模数(不再是26),且与古典算法定义不符。
对于学习古典算法本身,我推荐 方案2 :在加密前,统一将输入文本转换为大写(或小写),并过滤掉所有非字母字符。解密后,我们无法恢复原始格式,但得到了纯净的加解密结果。这最符合算法本意,也便于我们验证正确性。在CTF中,题目给出的密文通常也是去除了空格和标点的。
2.3 密钥的循环使用与对齐
密钥长度通常远小于明文。维吉尼亚密码的核心操作就是让密钥循环起来,与明文逐字母对齐。这个“循环”的逻辑很简单,用取模运算即可实现:对于明文第 i 个字符(从0开始计数),其对应的密钥字母索引是 i % len(keyword) 。
这里有一个 极易出错 的细节:我们的操作对象是过滤后只包含字母的字符串。但用户提供的原始明文和密钥可能包含大小写和非字母字符。因此,必须在预处理阶段,就将密钥也统一转换为纯大写字母序列,并确保它不为空。一个健壮的程序应该能处理用户输入“My Key123”这样的密钥,自动将其处理为“MYKEY”。
3. 核心函数实现与代码逐行解析
理论清晰后,我们开始动手实现。我会先给出完整的函数代码,然后逐块解析其意图和注意事项。
3.1 预处理函数:文本净化
这是所有工作的第一步,目的是得到一个只包含大写字母的字符串。
def preprocess_text(text):
"""
预处理文本:移除所有非字母字符,并转换为大写。
Args:
text (str): 原始输入字符串。
Returns:
str: 只包含大写字母的字符串。
"""
# 使用列表推导式,遍历每个字符,仅保留字母字符
# str.isalpha() 方法用于判断字符是否为字母
letters_only = [char.upper() for char in text if char.isalpha()]
# 将列表连接成字符串
return ''.join(letters_only)
实操心得 :
char.isalpha()可以完美识别英文字母,但对于其他语言(如中文)的“字母”也会返回True。如果严格限定英文,可以使用char.isalpha() and char.isascii(),或者用正则表达式re.match(r'[A-Za-z]', char)。这里我们采用简单通用的isalpha()。- 使用列表推导式
[expression for item in iterable if condition]是Pythonic且高效的方式。它比在for循环中不断进行字符串拼接(+=)要快得多,因为字符串在Python中是不可变对象,每次拼接都会生成新字符串。 ‘’.join(list)是将字符列表合并成字符串的标准方法。
3.2 加密函数:将思想转化为代码
加密函数是核心,它需要明文和密钥,返回密文。
def vigenere_encrypt(plaintext, keyword):
"""
使用维吉尼亚密码加密明文。
Args:
plaintext (str): 原始明文,可包含任意字符。
keyword (str): 密钥词,可包含任意字符。
Returns:
str: 加密后的密文字符串(仅大写字母)。
"""
# 1. 预处理
plaintext_clean = preprocess_text(plaintext)
keyword_clean = preprocess_text(keyword)
# 安全检查:密钥不能为空
if not keyword_clean:
raise ValueError("密钥必须包含至少一个字母。")
# 2. 初始化结果列表
ciphertext_chars = []
# 3. 获取密钥长度,用于循环
key_len = len(keyword_clean)
# 4. 遍历处理后的明文的每个字符
for i, char in enumerate(plaintext_clean):
# 计算明文字母的偏移量 (A->0, B->1, ..., Z->25)
p_offset = ord(char) - ord('A')
# 获取对应位置的密钥字母,并计算其偏移量
key_char = keyword_clean[i % key_len]
k_offset = ord(key_char) - ord('A')
# 维吉尼亚加密公式: C_i = (P_i + K_i) mod 26
c_offset = (p_offset + k_offset) % 26
# 将数字偏移转换回字母
cipher_char = chr(c_offset + ord('A'))
# 将加密后的字符加入列表
ciphertext_chars.append(cipher_char)
# 5. 将列表连接成最终密文
return ''.join(ciphertext_chars)
关键点解析与避坑指南 :
-
ord()与chr()函数 :这是实现字母与数字转换的关键。ord(‘A’)返回65(ASCII码),chr(65)返回‘A’。通过减去ord(‘A’),我们得到0-25的偏移量。这个方法比维护一个字母表列表list(‘ABCDEFGHIJKLMNOPQRSTUVWXYZ’)更简洁高效。 - 取模运算
% 26:这是整个算法的数学核心。它确保了当p_offset + k_offset超过25(即字母‘Z’)时,能够循环回字母表开头。例如,‘Z’(25) + ‘B’(1) = 26,26 % 26 = 0,对应字母‘A’。没有这个取模,程序就会报错。 - 使用
enumerate():在循环中,我们既需要字符char,也需要它的索引i来计算对应哪个密钥字母。enumerate(plaintext_clean)同时提供这两者,比手动维护一个索引变量更优雅。 - 密钥循环 :
keyword_clean[i % key_len]是实现密钥循环的精髓。无论明文多长,这个表达式总能正确地、周期性地从密钥中选取字母。
3.3 解密函数:加密的逆过程
解密是加密的逆运算,公式为: 明文字母 = (密文字母序号 - 密钥字母序号) mod 26 。注意,这里可能出现负数,而Python的 % 运算符对负数取模会返回正余数,这正是我们需要的。
def vigenere_decrypt(ciphertext, keyword):
"""
使用维吉尼亚密码解密密文。
Args:
ciphertext (str): 密文字符串(应仅包含字母,但函数会预处理)。
keyword (str): 密钥词,与加密时相同。
Returns:
str: 解密后的明文字符串(仅大写字母)。
"""
# 1. 预处理(解密时,ciphertext理论上应只有字母,但做预处理更健壮)
ciphertext_clean = preprocess_text(ciphertext)
keyword_clean = preprocess_text(keyword)
if not keyword_clean:
raise ValueError("密钥必须包含至少一个字母。")
# 2. 初始化结果列表
plaintext_chars = []
# 3. 获取密钥长度
key_len = len(keyword_clean)
# 4. 遍历处理后的密文的每个字符
for i, char in enumerate(ciphertext_clean):
# 计算密文字母的偏移量
c_offset = ord(char) - ord('A')
# 获取对应位置的密钥字母偏移量
key_char = keyword_clean[i % key_len]
k_offset = ord(key_char) - ord('A')
# 维吉尼亚解密公式: P_i = (C_i - K_i) mod 26
# Python的取模运算保证了即使 (c_offset - k_offset) 为负,结果也在0-25之间
p_offset = (c_offset - k_offset) % 26
# 将数字偏移转换回字母
plain_char = chr(p_offset + ord('A'))
# 将解密后的字符加入列表
plaintext_chars.append(plain_char)
# 5. 将列表连接成最终明文
return ''.join(plaintext_chars)
一个至关重要的测试 :加解密互逆性。这是检验代码正确性的金标准。我们写一个简单的测试:
# 测试加解密互逆性
original_plaintext = "Hello, World! This is a Vigenere Cipher test. 2023"
test_keyword = "KEY"
cipher = vigenere_encrypt(original_plaintext, test_keyword)
print(f"原始明文: {original_plaintext}")
print(f"净化后明文: {preprocess_text(original_plaintext)}")
print(f"加密后密文: {cipher}")
decrypted = vigenere_decrypt(cipher, test_keyword)
print(f"解密后文本: {decrypted}")
# 判断净化后的明文和解密结果是否一致
if preprocess_text(original_plaintext) == decrypted:
print("✓ 加解密测试通过!")
else:
print("✗ 加解密测试失败!")
运行这段代码,你会看到净化后的明文(HELLOWORLDTHISISAVIGENERECIPHERTEST)和解密后的文本完全一致。这证明了我们函数的正确性。
4. 功能增强与实战应用场景
基础功能实现后,我们可以让它变得更实用,更贴近真实场景。
4.1 保留原始格式的加解密
在很多情况下,我们希望能保留空格、标点的大小写,只加密字母部分。这稍微复杂一些,因为我们需要记录非字母字符的位置和原貌。
def vigenere_encrypt_preserve_format(plaintext, keyword):
"""
加密并保留非字母字符及原始大小写格式。
注意:返回的密文中,字母部分为大写,非字母部分保持原样。
"""
keyword_clean = preprocess_text(keyword)
if not keyword_clean:
raise ValueError("密钥必须包含至少一个字母。")
key_len = len(keyword_clean)
# 密钥字母偏移量预计算,避免在循环中重复计算ord
key_offsets = [ord(kc) - ord('A') for kc in keyword_clean]
result_chars = []
key_index = 0 # 用于跟踪密钥使用位置的指针
for char in plaintext:
if char.isalpha():
# 记录原始是否是小写
is_lower = char.islower()
# 统一按大写字母计算偏移
p_offset = ord(char.upper()) - ord('A')
k_offset = key_offsets[key_index % key_len]
c_offset = (p_offset + k_offset) % 26
encrypted_char = chr(c_offset + ord('A'))
# 如果原字符是小写,则密文字母也转为小写
if is_lower:
encrypted_char = encrypted_char.lower()
result_chars.append(encrypted_char)
key_index += 1 # 只有加密字母时,才消耗密钥
else:
# 非字母字符原样保留
result_chars.append(char)
return ''.join(result_chars)
def vigenere_decrypt_preserve_format(ciphertext, keyword):
"""
解密并保留非字母字符及大小写格式。
注意:假设密文中的字母部分即为加密结果(大小写可能混合)。
"""
keyword_clean = preprocess_text(keyword)
if not keyword_clean:
raise ValueError("密钥必须包含至少一个字母。")
key_len = len(keyword_clean)
key_offsets = [ord(kc) - ord('A') for kc in keyword_clean]
result_chars = []
key_index = 0
for char in ciphertext:
if char.isalpha():
is_lower = char.islower()
c_offset = ord(char.upper()) - ord('A')
k_offset = key_offsets[key_index % key_len]
p_offset = (c_offset - k_offset) % 26
decrypted_char = chr(p_offset + ord('A'))
if is_lower:
decrypted_char = decrypted_char.lower()
result_chars.append(decrypted_char)
key_index += 1
else:
result_chars.append(char)
return ''.join(result_chars)
这个版本的技巧在于 :
- 使用
key_index指针来记录已经消耗了多少个密钥字母。只有遇到明文字母时,指针才前进。这确保了密钥与字母的正确对齐,不受空格标点干扰。 - 预计算
key_offsets列表,将密钥字母的偏移量提前算好并存储。这在加密长文本时能带来微小的性能提升,避免在循环中反复计算ord(key_char) - ord(‘A’)。 - 通过
char.islower()判断原始大小写,并在加解密后恢复。这使得输出文本更自然。
4.2 在CTF解题中的应用实例
假设你遇到一个CTF题目,密文为: “Vpx zog, Mci mqv jm omqemd v gqg!” ,提示是维吉尼亚密码,密钥可能是一个常见的英文单词。
我们可以写一个交互式的脚本,尝试用字典中的常见单词作为密钥进行爆破。
def crack_vigenere_with_dict(ciphertext, dict_file_path='common_words.txt'):
"""
使用字典文件中的单词尝试破解维吉尼亚密码。
仅适用于短密钥且密钥在字典中的情况。
"""
ciphertext_clean = preprocess_text(ciphertext)
# 尝试加载字典文件,每行一个单词
try:
with open(dict_file_path, 'r') as f:
potential_keys = [line.strip().upper() for line in f if line.strip()]
except FileNotFoundError:
print(f"字典文件 {dict_file_path} 未找到。")
# 使用一个内置的简易常见单词列表作为后备
potential_keys = ['A', 'THE', 'AND', 'FOR', 'KEY', 'CODE', 'SECRET', 'PASSWORD']
print("使用内置简易字典。")
for key in potential_keys:
key_clean = preprocess_text(key)
if not key_clean:
continue
decrypted = vigenere_decrypt(ciphertext_clean, key_clean)
# 简单的启发式判断:解密后的文本是否像英文(包含常见短单词)
# 这里只是一个非常简单的示例,实际破解需要更复杂的频率分析或字典匹配
if 'THE' in decrypted or 'AND' in decrypted or 'FOR' in decrypted:
print(f"尝试密钥: {key_clean:10} -> 解密结果: {decrypted[:50]}...") # 只打印前50字符
# 可以在这里加入更智能的判断,比如计算解密文本的英文字母分布熵
注意事项 :真实的维吉尼亚密码破解(在不知道密钥长度和内容的情况下)要复杂得多,通常涉及:
- 确定密钥长度 :使用卡西斯基试验或重合指数法。
- 将密文按密钥长度分组 :每组相当于一个凯撒密码。
- 对每组进行频率分析 ,猜解每个密钥字母的偏移量。
- 组合成完整密钥 ,进行解密和验证。
我们实现的字典攻击,仅适用于密钥恰好是一个常见单词,且长度不太长的简单情况。但它展示了将加解密函数作为基础工具,构建更复杂应用的可能性。
5. 常见问题、调试技巧与性能优化
即使代码逻辑正确,在实际编写和运行中也可能遇到各种问题。这里分享一些我踩过的坑和解决方法。
5.1 编码与字符集问题
问题 :当输入文本包含中文或其他非ASCII字符时, char.isalpha() 会返回True,导致这些字符被错误地送入 ord() 函数,而 ord(‘中’) 的值远大于255,减去 ord(‘A’) 后得到的偏移量毫无意义,加密结果自然也是乱码。
解决方案 :严格限定为英文字母。修改预处理函数:
import re
def preprocess_text_strict(text):
"""
严格预处理,只保留英文字母。
"""
# 使用正则表达式匹配所有英文字母
letters_only = re.findall(r'[A-Za-z]', text)
# 转换为大写并连接
return ''.join(letters_only).upper()
在加密函数中,使用这个更严格的预处理函数替代原来的 preprocess_text 。
5.2 密钥为空或无效
问题 :用户可能输入一个不包含任何字母的密钥,例如“123”或“!!”。我们的代码在 preprocess_text 后会得到空字符串 “” ,导致后续 keyword_clean[i % key_len] 索引错误(因为 key_len 为0,取模运算 i % 0 会引发 ZeroDivisionError )。
解决方案 :这就是为什么我们在加密和解密函数开始处,添加了 if not keyword_clean: 的检查,并抛出清晰的 ValueError 异常。这是防御性编程的基本要求。
5.3 加解密结果不对
排查步骤 :
- 打印中间变量 :这是最直接的调试方法。在加密循环内,打印出
i,char,p_offset,key_char,k_offset,c_offset,cipher_char。逐项核对,看计算是否符合预期。 - 检查预处理 :确认明文和密钥经过预处理后,是否是你想象中的纯大写字母字符串。可能空格、标点没有被正确过滤。
- 验证公式 :手动计算一个简单例子。比如明文“A”,密钥“B”。
A偏移0,B偏移1,(0+1)%26=1,对应B。用你的程序跑一下,看结果是不是B。 - 注意大小写 :如果你使用了保留格式的版本,要特别注意大小写逻辑。一个字母是大写还是小写,不应该影响其加密偏移量(
A和a的偏移都是0),但会影响输出字符的大小写。
5.4 性能考量与优化
对于学习项目,我们实现的代码性能已经足够。但如果要处理非常长的文本(例如一整本书),可以考虑以下微优化:
- 局部变量 :在循环前,将
ord(‘A’)赋值给一个局部变量BASE = ord(‘A’),然后在循环中使用p_offset = ord(char) - BASE。访问局部变量比访问全局内置函数ord和字符常量‘A’略快。 - 预计算密钥偏移 :正如我们在保留格式版本中所做,提前计算
key_offsets列表,避免在循环中重复计算ord(key_char) - ord(‘A’)。 - 使用字节数组 :对于纯字母文本,可以将其转换为字节数组进行操作,但会牺牲一些代码可读性。对于Python来说,这种优化带来的提升通常不大,清晰可维护的代码更重要。
5.5 扩展思考:如何破解未知密钥的维吉尼亚密码?
作为实现者,了解如何攻击自己写的算法是很有价值的。破解维吉尼亚密码是一个经典的密码分析问题,思路如下:
-
猜测密钥长度
L:- 卡西斯基试验 :在密文中寻找重复出现的长度为3或以上的片段,记录它们之间的距离。这些距离的最大公约数,很可能是密钥长度的倍数。
- 重合指数法 :对于假定的密钥长度
L,将密文按每第L个字母分成一组(共L组)。计算每一组的重合指数。如果L猜对了,每一组都是同一个凯撒密码加密的,其重合指数应接近英文文本的期望值(约0.065)。通过测试不同的L,找到使平均重合指数最高的那个。
-
分析每组字母的频率 :
- 密钥长度
L确定后,你就有了L组密文。每组都是用同一个密钥字母加密的凯撒密码。 - 对每一组,计算其字母频率分布。
- 将观察到的频率分布与英文标准字母频率分布(e, t, a, o, i, n, s...)进行比对。通过移动观察到的分布,找到与标准分布最匹配的偏移量,这个偏移量就是该位置密钥字母的偏移(例如,偏移4对应密钥字母‘E’)。
- 密钥长度
-
组合与验证 :
- 将
L个密钥字母的偏移量组合起来,得到密钥单词。 - 用该密钥尝试解密,观察得到的明文是否是有意义的英文。通常需要结合上下文或题目提示进行微调。
- 将
这个过程完全可以自动化,编写一个完整的破解脚本是另一个有趣的编程挑战。它需要用到统计学知识和更复杂的编程逻辑,但核心基础正是我们上面实现的加解密函数。
更多推荐
所有评论(0)