Python实战:维吉尼亚密码破解与重合指数法应用

📅 2026/7/21 16:03:08
Python实战:维吉尼亚密码破解与重合指数法应用
1. 项目概述为什么选择维吉尼亚密码作为实战目标如果你对古典密码学感兴趣或者想找一个能综合运用Python字符串处理、统计分析、算法逻辑的实战项目维吉尼亚密码的破解绝对是一个绝佳的选择。它不像凯撒密码那样简单移位也不像现代AES那样复杂到让人望而却步。维吉尼亚密码在历史上曾被称为“不可破译的密码”其核心在于使用一个密钥词对明文进行周期性移位加密这种多表替代的特性让单纯的字频分析失效为破解过程带来了真正的挑战和乐趣。这个项目能做什么简单说就是给你一段用维吉尼亚密码加密的密文在不知道密钥的情况下通过一系列程序化的分析步骤最终还原出原始的密钥和明文。整个过程就像侦探破案从密文这一堆“乱码”中寻找规律和蛛丝马迹。它完美融合了密码学原理、统计方法和编程实践。适合谁呢适合有一定Python基础熟悉循环、列表、字典、函数想提升问题解决能力和算法思维的朋友也适合对信息安全、数据分析感兴趣想通过一个具体案例理解频率分析威力的学习者。通过亲手实现这个破解程序你不仅能深刻理解多表替代密码的运作机制更能掌握“重合指数法”和“卡方检验”这两个在密码分析和自然语言处理中都非常实用的统计工具。网上很多教程只讲理论或者代码零散不成体系。本文将带你从零开始构建一个结构清晰、功能完整、附带详细注释的破解工具并分享我在实现过程中踩过的坑和优化技巧。2. 核心原理与破解思路全拆解维吉尼亚密码的“不可破译”光环源于它巧妙地规避了单表替换密码的最大弱点明文统计特性的保留。在单表替换中一个明文字母永远对应一个密文字母因此密文的字母频率分布会直接反映明文的频率分布例如英文中‘e’出现频率最高。攻击者只需做一次频率分析比对英文字母的预期频率就能轻松破解。维吉尼亚密码则引入了“密钥”的概念。加密时明文被分成与密钥等长的组密钥字母a0, b1, ..., z25指示了该组内每个明文字母的位移量。例如明文“ATTACKATDAWN”密钥“LEMON”加密过程如下明文: A T T A C K A T D A W N 密钥: L E M O N L E M O N L E 位移: 11 4 12 14 13 11 4 12 14 13 11 4 密文: L X P P H O E X T M G R可以看到同一个明文字母‘A’在第一次遇到密钥‘L’位移11时被加密为‘L’在第四次遇到密钥‘O’位移14时却被加密为‘O’。这种“一对多”的映射关系彻底打乱了原始的频率分布使得直接对整体密文做频率分析无效。那么破解的突破口在哪里伟大的密码学家威廉·弗里德曼提出了“重合指数”的概念。其核心思想是如果密文是由单表替换生成的那么其内部任意两段文本的重合指数即随机抽两个字母相同的概率会接近于源语言如英语的理论重合指数约0.065如果是随机文本则接近于随机概率1/26 ≈ 0.038。对于维吉尼亚密码虽然整体密文看起来是随机的但如果我们能猜出密钥长度m那么密文中所有第1, m1, 2m1...位置的字母都是由密钥的第一个字母加密的构成了一个“子序列”。这个子序列实际上是一个简单的凯撒密码单表替换它的重合指数会接近0.065。同理第2, m2, 2m2...位置的字母由密钥的第二个字母加密以此类推。因此破解维吉尼亚密码的总体思路就清晰了确定密钥长度通过计算不同假设长度下密文子序列的重合指数找到最接近英语理论值的那个长度。逐位还原密钥对每一个确定长度的子序列通过计算其与26个可能位移a-z解密后的文本的卡方统计量找到使得解密文本字母分布最接近英语分布的位移量该位移即对应密钥字母。解密使用还原出的密钥对密文进行解密得到明文。注意这个方法的有效性严重依赖于密文的长度。密文越长统计特征越明显破解成功率越高。对于很短的密文如少于密钥长度的几十倍统计方法可能会失效。3. 工具准备与核心函数实现在开始编写完整的破解流程前我们需要搭建几个基础的工具函数。这些函数是构建我们破解算法的基石。3.1 文本预处理函数密文中可能包含空格、标点、数字我们需要将其过滤只保留字母并统一转换为大写或小写便于后续处理。def preprocess_text(text): 预处理文本只保留字母并转换为大写。 :param text: 原始文本字符串 :return: 处理后的纯字母大写字符串 # 使用列表推导式过滤isalpha()判断是否为字母 processed .join([char.upper() for char in text if char.isalpha()]) return processed实操心得这里使用str.isalpha()和列表推导式代码简洁高效。统一大写是为了简化后续的位移计算‘A’的ASCII码是65。在实际应用中如果密文包含非英文字母如中文此函数需要调整本项目默认处理英文。3.2 重合指数计算函数这是破解的第一步也是最关键的函数之一。重合指数定义为在文本中随机抽取两个字母它们相同的概率。def index_of_coincidence(text): 计算文本的重合指数。 :param text: 纯字母文本字符串 :return: 重合指数 (float) if len(text) 1: return 0.0 # 统计每个字母出现的频率 freq {} for char in text: freq[char] freq.get(char, 0) 1 # 计算重合指数IC sum(n_i * (n_i - 1)) / (N * (N - 1)) # n_i 是字母i的出现次数N是文本总长度 N len(text) total 0 for count in freq.values(): total count * (count - 1) ic total / (N * (N - 1)) return ic原理详解公式IC Σ(n_i * (n_i-1)) / (N * (N-1))是怎么来的n_i是字母i出现的次数。从N个字母中任选两个总共有C(N,2) N*(N-1)/2种组合。其中两个字母都是i的组合有C(n_i, 2) n_i*(n_i-1)/2种。因此随机抽到两个相同字母的概率就是所有C(n_i,2)的和除以C(N,2)化简后即得到上述公式。英语文本的IC值大约在0.065-0.075之间随机文本约为0.038。3.3 卡方统计量计算函数在猜出单个密钥字母对应的位移时我们需要一个指标来衡量解密后的文本字母分布与标准英语分布的接近程度。卡方检验非常适合这个任务。def chi_squared_score(text, expected_freq): 计算文本的字母频率分布与预期频率分布的卡方统计量。 值越小说明分布越接近。 :param text: 待评估的文本 :param expected_freq: 列表长度为26对应A-Z的期望频率百分比形式如8.2 :return: 卡方统计量 (float) # 将期望频率从百分比转换为比例 expected_prop [e / 100.0 for e in expected_freq] # 统计文本中字母的实际出现次数 observed [0] * 26 for char in text: observed[ord(char) - ord(A)] 1 total_chars len(text) if total_chars 0: return float(inf) # 返回无穷大表示极不匹配 chi_squared 0.0 for i in range(26): # 期望次数 总字符数 * 该字母的期望比例 expected_count total_chars * expected_prop[i] if expected_count 0: # 避免除零 chi_squared ((observed[i] - expected_count) ** 2) / expected_count return chi_squared为什么用卡方检验卡方检验是衡量观察值与理论值差异的经典方法。在这里observed[i]是我们解密文本中字母i的实际出现次数expected_count是如果文本是正常英文时字母i“应该”出现的次数。这个差值平方后除以期望值对所有26个字母求和就得到了一个总体的差异分数。分数越低说明我们的解密文本字母分布越像正常的英文也就意味着我们猜测的位移越可能是正确的。注意事项卡方检验对文本长度敏感。文本太短时偶然性太大卡方值可能不可靠。因此在密钥长度较长、导致每个子序列很短时破解的准确率会下降。这是统计方法固有的局限性。4. 实战破解三步走完整流程有了核心函数我们就可以组装完整的破解流程了。整个过程分为三步我们将每一步封装成独立的函数最后再串联起来。4.1 第一步推测密钥长度我们通过测试不同可能的密钥长度比如从1到20计算在该长度下所有子序列的平均重合指数。平均IC最接近英语理论值约0.065的长度就是最可能的密钥长度。def estimate_key_length(ciphertext, max_key_length20): 通过重合指数法估计维吉尼亚密码的密钥长度。 :param ciphertext: 预处理后的密文 :param max_key_length: 猜测的最大密钥长度 :return: 最可能的密钥长度 (int) best_length 1 best_ic_diff float(inf) # 存储与0.065的最小差值 for m in range(1, max_key_length 1): avg_ic 0.0 # 对于每个偏移量 i (0到m-1)取出对应的子序列 for i in range(m): # 切片操作从第i个字符开始每隔m个取一个 subsequence ciphertext[i::m] if len(subsequence) 1: # 需要至少两个字符才能计算IC avg_ic index_of_coincidence(subsequence) avg_ic / m # 计算平均IC # 找出平均IC最接近0.065的m ic_diff abs(avg_ic - 0.065) if ic_diff best_ic_diff: best_ic_diff ic_diff best_length m return best_length踩坑记录max_key_length参数需要合理设置。设得太小可能错过真实长度设得太大计算量增加且对于短密文当m很大时每个子序列会变得非常短IC计算会不准确。通常对于未知密文可以先设一个稍大的值如30观察IC值随m变化的曲线如果曲线在某个值后不再出现明显峰值那么更长的可能性就很小了。4.2 第二步还原密钥词一旦我们有了密钥长度m密文就被分成了m个子序列。每个子序列都是用一个固定的凯撒密码位移量为密钥字母对应的数字加密的。我们的任务就是为每个子序列找出那个正确的位移量。这里我们需要一个标准英语字母频率表作为卡方检验的“期望分布”。以下是基于大量英文文本统计的近似值# 标准英语字母频率 (百分比) ENGLISH_FREQ [ 8.2, 1.5, 2.8, 4.3, 12.7, 2.2, 2.0, 6.1, 7.0, 0.2, 0.8, 4.0, 2.4, 6.7, 7.5, 1.9, 0.1, 6.0, 6.3, 9.1, 2.8, 1.0, 2.4, 0.2, 2.0, 0.1 ] # 对应 A, B, C, ..., Z现在实现密钥还原函数def find_key(ciphertext, key_length): 通过卡方检验还原密钥。 :param ciphertext: 预处理后的密文 :param key_length: 估计出的密钥长度 :return: 还原出的密钥字符串 key alphabet ABCDEFGHIJKLMNOPQRSTUVWXYZ for i in range(key_length): # 获取第i个子序列 subsequence ciphertext[i::m] best_shift 0 best_chi2 float(inf) # 尝试所有26种可能的位移 (0-25) for shift in range(26): # 尝试用当前位移解密子序列 decrypted_seq for char in subsequence: # 凯撒解密 (密文字母 - 位移) mod 26 orig_idx (ord(char) - ord(A) - shift) % 26 decrypted_seq chr(orig_idx ord(A)) # 计算解密后序列的卡方值 chi2 chi_squared_score(decrypted_seq, ENGLISH_FREQ) # 记录卡方值最小的位移 if chi2 best_chi2: best_chi2 chi2 best_shift shift # 将最佳位移转换为字母加入密钥 key chr(best_shift ord(A)) return key核心技巧在decrypted_seq的生成中我们模拟了用shift作为凯撒密钥进行解密的过程。对于每个密文字符将其反向位移shift位就得到了猜测的明文字符。然后我们计算这个猜测明文的字母分布与标准英语分布的卡方值。对所有26种shift都计算一遍那个让卡方值最小的shift就是最有可能的密钥字母位移。4.3 第三步使用密钥解密还原出密钥后解密就水到渠成了。这个过程是加密的逆过程。def vigenere_decrypt(ciphertext, key): 使用给定的密钥对密文进行维吉尼亚解密。 :param ciphertext: 原始密文可含非字母字符 :param key: 密钥字符串 :return: 解密后的明文保留非字母字符原样字母转换为小写 key key.upper() key_len len(key) plaintext [] key_index 0 for char in ciphertext: if char.isalpha(): # 计算位移密钥字母对应的数字 shift ord(key[key_index % key_len]) - ord(A) # 解密当前字母 if char.isupper(): decrypted_char chr((ord(char) - ord(A) - shift) % 26 ord(A)) else: decrypted_char chr((ord(char) - ord(a) - shift) % 26 ord(a)) plaintext.append(decrypted_char.lower()) # 输出统一用小写 key_index 1 else: # 非字母字符原样保留 plaintext.append(char) return .join(plaintext)设计考量这个解密函数比预处理函数更“友好”。它接受原始密文可以包含空格、标点并在解密过程中保留这些非字母字符的原貌只对字母进行解密操作。同时它将解密后的字母统一转为小写使结果更易读。key_index % key_len确保了密钥循环使用。5. 完整代码整合与示例运行现在我们将所有函数整合到一个主程序里并提供一个清晰的执行流程。# vigenere_cracker.py import sys # ... (此处插入之前定义的所有函数: preprocess_text, index_of_coincidence, # chi_squared_score, estimate_key_length, find_key, vigenere_decrypt) # 以及 ENGLISH_FREQ 列表 def main(): # 示例密文 - 可以用你自己的密文替换 # 密文来源明文To be or not to be, that is the question. 密钥KEY ciphertext Lsi swe ui rsl av swe, xlex mk xli juymxmsr. print(原始密文:) print(ciphertext) print(- * 50) # 1. 预处理密文 processed_ct preprocess_text(ciphertext) print(f预处理后密文 (长度: {len(processed_ct)}):) print(processed_ct[:100] (... if len(processed_ct) 100 else )) print(- * 50) # 2. 估计密钥长度 print(正在分析可能的密钥长度...) probable_key_length estimate_key_length(processed_ct, max_key_length10) print(f推测的密钥长度为: {probable_key_length}) print(- * 50) # 3. 还原密钥 print(正在尝试还原密钥...) recovered_key find_key(processed_ct, probable_key_length) print(f还原出的密钥为: {recovered_key}) print(- * 50) # 4. 使用密钥解密 print(使用还原的密钥进行解密...) decrypted_text vigenere_decrypt(ciphertext, recovered_key) print(解密结果:) print(decrypted_text) print(- * 50) # 5. 验证与手动调整可选 print(\n提示如果解密结果看起来不像英文可以尝试) print( 1. 检查密钥长度推测是否正确。可以手动指定其他长度尝试。) print( 2. 检查密文是否足够长建议至少是密钥长度的50倍。) print( 3. 密文可能经过了其他编码或不是纯维吉尼亚加密。) if __name__ __main__: main()运行与结果 将上述所有代码保存为一个.py文件并运行你会看到类似以下的输出原始密文: Lsi swe ui rsl av swe, xlex mk xli juymxmsr. -------------------------------------------------- 预处理后密文 (长度: 36): LSISWEUIRSL AVSWEXLEXMKXLIJUYMXMSR -------------------------------------------------- 正在分析可能的密钥长度... 推测的密钥长度为: 3 -------------------------------------------------- 正在尝试还原密钥... 还原出的密钥为: KEY -------------------------------------------------- 使用还原的密钥进行解密... 解密结果: to be or not to be, that is the question. --------------------------------------------------成功了程序准确地推测出密钥长度为3并还原出密钥“KEY”最终解密得到了莎士比亚的名言。6. 常见问题、优化策略与实战心得在实际运行这个破解程序时你可能会遇到各种情况。下面是我在多次实践中总结的一些典型问题和解决方案。6.1 密钥长度推测不准怎么办这是最常见的问题。estimate_key_length函数依赖于IC值的峰值判断但在以下情况可能失效密文太短统计特征不明显。解决方案尝试手动指定几个可能的短长度如2,3,4,5,6分别进行破解看哪个解密出的文本可读性更高。密钥长度本身很长超过了max_key_length参数。解决方案增大max_key_length的值比如设为50然后观察输出。更稳健的方法是修改estimate_key_length函数让它返回IC值最高的前2-3个候选长度供后续逐一尝试。一个改进版的函数可以这样写def estimate_key_length_candidates(ciphertext, max_len30, top_n3): 返回IC值最接近0.065的前top_n个候选长度 candidates [] for m in range(1, max_len 1): avg_ic 0.0 valid_seqs 0 for i in range(m): subseq ciphertext[i::m] if len(subseq) 1: avg_ic index_of_coincidence(subseq) valid_seqs 1 if valid_seqs 0: avg_ic / valid_seqs ic_diff abs(avg_ic - 0.065) candidates.append((m, ic_diff)) # 按IC差值从小到大排序差值越小越好 candidates.sort(keylambda x: x[1]) return [c[0] for c in candidates[:top_n]]6.2 还原出的密钥部分字母错误即使密钥长度正确find_key函数也可能猜错某个位置的字母。这通常是因为对应的子序列太短或者该子序列的字母分布偶然偏离了标准英语。解决方案不要完全依赖程序输出。观察解密出的明文如果大部分单词都正确只有个别字符错误这很可能是某个密钥字母错了。英文单词有很强的模式你可以根据上下文手动修正那个错误的字符。例如解密出“th*t”你很容易猜到应该是“that”从而反推出正确的密钥位移。6.3 处理更复杂的情况非字母字符与编码我们的基础版本假设密文是简单的英文字母。但实战中你可能遇到密文是十六进制或Base64编码的攻击者可能先对明文进行维吉尼亚加密再将结果进行编码。解决方案在预处理之前先对密文进行相应的解码如base64.b64decode或bytes.fromhex。密文包含数字或特殊符号如果这些符号是原始明文的一部分我们的解密函数已经能保留它们。但如果它们也是加密的一部分例如一种变种维吉尼亚密码你需要修改字母表和处理逻辑。6.4 性能优化与扩展思路对于超长密文数万字符当前的算法是可行的。但如果想追求极致或者处理海量数据可以考虑使用NumPy将字母频率统计、位移计算等向量化操作可以大幅提升find_key函数的性能。多进程/多线程在find_key函数中对26种位移的尝试是独立的可以并行计算。扩展语言支持本程序基于英语频率。要破解其他语言如法语、德语的密文只需替换ENGLISH_FREQ为对应语言的字母频率表即可。自动化评估可以引入一个“单词识别率”函数使用一个英文词典计算解密文本中能在词典中找到的单词比例辅助判断解密结果的正确性实现全自动化的最佳密钥选择。我个人最深的体会是密码破解是统计学和编程艺术的结合。程序给出的只是一个最可能的答案尤其是当密文不长时。真正的“破解”往往需要结合人的语言直觉和对上下文的理解。这个项目最迷人的地方就在于你既是编写算法的工程师又是运用逻辑和经验的侦探。当你看到一堆乱码在屏幕上逐渐变成有意义的句子时那种成就感是无与伦比的。最后一个小技巧在测试自己的程序时先用一个已知密钥加密一段话然后用程序去破解它这是验证算法正确性最直接的方法。