☰
维吉尼亚密码从实现到破解:卡西斯基与重合指数实战
2026/10/29 14:54:19 网站建设 项目流程

维吉尼亚密码在各种密码学练习题里出现的频率高得离谱,但真正动手写过一遍完整加解密、再亲手把它破掉的人并不多。多数人对它的印象停留在“多表替换、比凯撒难破”这两句话上,遇到一段密文需要还原密钥时,还是只能去翻现成脚本。这篇内容面向的正是这部分人:写过凯撒、知道频率分析,但还没把维吉尼亚从数学定义、代码实现到周期检测、分组还原这一整条链路走通的人。下面我会按照自己实际做题和写工具的路径,把维吉尼亚密码的加密逻辑、代码落地细节、三种破解路径的完整推演,以及不同密钥长度下的量化差异讲清楚,中间穿插几个我在实现时踩过的坑,比如负数取模、非字母字符处理、卡西斯基测试的误判。看完之后,你应该能徒手算出一段短密文,也能在没有密钥的情况下,用一段几百字符的密文把密钥还原出来。

1. 从单表替换到多表替换:维吉尼亚到底改动了哪一处

1.1 单表替换的死穴在于“每个字母永远变成同一个字母”

凯撒密码的本质是一张固定的替换表,位移 3 的时候,所有的 E 都会变成 H,一个不漏。这种一对一映射带来一个致命后果:明文中字母的统计分布被完整地搬到密文里。英文明文里 E 占大约 12.7%,T 占 9%,A 占 8% 左右,那么密文里出现最频繁的那个字符,大概率就是 E 的影子。只要密文长度过了三四十个字符,频率统计配合常见双字母组(TH、HE、IN)就能把明文猜个七八成。

维吉尼亚的改动只有一句话:位移量不再固定,而是由密钥逐位给出。密钥是 LEMON,第 1 位位移 11,第 2 位位移 4,第 3 位位移 12,第 4 位位移 14,第 5 位位移 13,第 6 位又回到 11,如此循环。于是明文中同一个字母,出现在不同位置时会被推到不同的密文字母上,频率分布被“摊平”了。这是它真正的价值所在,不是变得更复杂,而是把单一统计特征打散了。

1.2 加密的数学定义只有一行

把字母映射成 0 到 25 的整数,A=0、B=1、……、Z=25。设明文第 i 个字母为 p,密钥第 i 位(按周期取)为 k,则:

  • 加密:c = (p + k) mod 26
  • 解密:p = (c − k) mod 26

密钥长度为 m 时,k 取 key[i mod m]。整个过程没有非线性变换、没有扩散、没有混淆,就是加法取模。理解这一点很重要,因为它决定了后面所有的破解手段:只要确定密钥长度 m,密文就能按位置拆成 m 组,每组内部就是一次普通的凯撒加密,位移量固定。这就是所谓的“多表”其实可以还原成“多个单表”。

我常拿它做一个类比:凯撒像是把一叠牌整体平移了几张,维吉尼亚则是有 m 副牌轮着用,每副牌平移的张数不同。只要观察者发现你是在轮换用牌,把同一副牌的牌面抽出来单独看,规律立刻回来了。

1.3 手算一遍 LEMON 加密 ATTACKATDAWN

很多人看书看懂了公式,一上手就错,问题通常出在“密钥只对字母推进,不对空格和标点推进”这个约定上。我用经典例子走一遍。

明文:ATTACKATDAWN,密钥:LEMON。先把密钥转成数字:L=11、E=4、M=12、O=14、N=13,周期为 5。

位置明文数字密钥位位移相加密文
1A0L1111L
2T19E423X
3T19M1231→5F
4A0O1414O
5C2N1315P
6K10L1121V
7A0E44E
8T19M1231→5F
9D3O1417R
10A0N1313N
11W22L1133→7H
12N13E417R

密文是 LXFOPVEFRNHR。第 3 位和第 8 位都是 T,但一个变成 F,一个也变成 F,这里恰好相同,第 6 位的 K 变成 V。看第 1 位和第 7 位:明文都是 A,密钥分别是 L 和 E,密文分别是 L 和 E,同一个明文字母对应了两个不同密文字母,这就是维吉尼亚的“摊平”效果在起作用。

注意:如果明文长度不是密钥长度的整数倍,最后一轮密钥只用一部分,这不算异常。真正需要注意的是密钥里不能有非字母字符,否则位移量无定义。

2. 二十行代码跑通加解密,但四个约定必须先定死

2.1 实现之前必须先明确边界条件

写这类古典密码工具,代码量从来不是问题,问题在于约定不统一导致的“我的加密结果和标准答案对不上”。我在第一版实现时就吃过这个亏,网上找的测试向量和我的输出差了整整一位,排查半天发现是对方把空格也当字母推进了密钥。所以在动手之前,有四件事必须先写清楚:

  1. 密钥推进规则:只有明文字母消耗密钥位,空格、数字、标点不消耗。
  2. 大小写处理:通常约定保留原文大小写,密钥统一转小写参与计算。
  3. 非字母字符:原样透传,不参与加解密,也不推进密钥。
  4. 多字节字符:中文、emoji 这类直接透传,不要尝试ord()做模运算,否则会出现负索引或者越界。

这四条看似琐碎,但它们直接决定了两份实现能不能互相验证。如果你要跟别人的工具做交叉比对,第一条对不上,后面全错。

2.2 加解密的 Python 实现

下面这版是我目前一直在用的,逻辑直白,没有引入任何第三方库,密钥校验也加上了。

def _check_key(key: str) -> str: if not isinstance(key, str) or not key: raise ValueError("密钥不能为空") k = key.lower() if not k.isalpha() or not k.isascii(): raise ValueError("密钥只能由英文字母组成") return k def vigenere_encrypt(text: str, key: str) -> str: k = _check_key(key) out = [] ki = 0 # 只统计字母,密钥指针独立推进 for ch in text: if 'a' <= ch <= 'z' or 'A' <= ch <= 'Z': base = ord('A') if ch.isupper() else ord('a') # 关键点一:先把字符转成 0-25 的相对值再相加 shift = ord(k[ki % len(k)]) - ord('a') out.append(chr((ord(ch) - base + shift) % 26 + base)) ki += 1 else: out.append(ch) # 关键点二:非字母原样透传,不推进 ki return ''.join(out) def vigenere_decrypt(text: str, key: str) -> str: k = _check_key(key) out = [] ki = 0 for ch in text: if 'a' <= ch <= 'z' or 'A' <= ch <= 'Z': base = ord('A') if ch.isupper() else ord('a') shift = ord(k[ki % len(k)]) - ord('a') # 关键点三:减法后 Python 的 % 会返回非负结果,不用额外修正 out.append(chr((ord(ch) - base - shift) % 26 + base)) ki += 1 else: out.append(ch) return ''.join(out)

验证一下:

c = vigenere_encrypt("ATTACKATDAWN", "LEMON") print(c) # LXFOPVEFRNHR print(vigenere_decrypt(c, "LEMON")) # ATTACKATDAWN # 带标点和大小写的用例 c2 = vigenere_encrypt("Attack at dawn, 凌晨三点!", "lemon") print(c2) # Lxfopv ef rnhr, 凌晨三点! print(vigenere_decrypt(c2, "LEMON"))

2.3 两个必然踩到的坑

坑一:ASCII 偏移量忘了减 base。如果直接写(ord(ch) + shift) % 26,得到的是 0 到 25 之间的数字,再chr()出来是一堆不可见控制字符。正确做法是先减去ord('A')或ord('a')归零,运算完再加回来。这个错误在初学阶段几乎人人犯一次。

坑二:负数取模的跨语言差异。Python 里-3 % 26等于 23,是我们要的结果;但 C、Java、Go 里-3 % 26等于 -3。如果你的解密函数要移植到其他语言,必须写成((c - k) % 26 + 26) % 26。我见过一份 Java 实现就是因为这个,解密出来的结果里夹杂着大量下划线和问号,作者还以为是校验逻辑写错了。

坑三(更隐蔽):isalpha()会把中文也判为 True。Python 的str.isalpha()对“中文”返回 True,如果拿它来做“是不是字母”的判断,中文会被纳入加解密流程,ord('凌')是 20940,减去 65 再加位移,模 26 之后会生成一个完全不相干的 ASCII 字符,直接污染输出。正确的判断是显式的范围比较,或者用ch.isascii() and ch.isalpha()。

3. 它为什么不够安全:三种攻击路径的完整推演

3.1 卡西斯基测试:重复片段暴露了周期

维吉尼亚密码最直接的漏洞是密钥周期。密钥长度为 m 时,相距 m 的整数倍的两个位置使用同一个位移量。如果明文中恰好出现重复的词,比如“THE”在文中间隔 15 个字母出现两次,而 15 又恰好是 m 的倍数,那么这两处密文也会完全相同。

1863 年提出的卡西斯基测试就是抓这一点:统计密文中所有长度不小于 3 的重复片段,记录它们出现位置之间的间距,然后对这些间距求最大公约数,公约数的约数里就有很大概率包含真实的密钥长度。为什么是“很大概率”而不是“一定”?因为重复片段的间距可能刚好是 m 的倍数加上一点偏移,也可能纯属巧合。所以实际使用时不能只看一个公约数,要把所有间距的频率统计出来,出现次数最多的那个因子才最可信。

我在一段约 400 字符的密文上跑这套逻辑,重复片段 37 组,间距做质因数分解后,5 出现了 21 次,真实密钥长度正好是 5。但也遇到过密钥长度 8 的样本,最高频因子是 4,因为 8 的倍数必然也是 4 的倍数,而样本量不足以把 8 顶上去。这就需要第二步验证。

from collections import Counter from math import gcd from functools import reduce def kasiski_spacings(cipher: str, min_len: int = 3): c = ''.join(ch.lower() for ch in cipher if ch.isascii() and ch.isalpha()) seen = {} for i in range(len(c) - min_len + 1): seen.setdefault(c[i:i + min_len], []).append(i) spacings = [] for gram, pos in seen.items(): for a, b in zip(pos, pos[1:]): spacings.append(b - a) return spacings def factor_guess(spacings, top_k: int = 5): cnt = Counter() for d in spacings: for f in range(2, 21): # 只看 20 以内,密钥通常不会太长 if d % f == 0: cnt[f] += 1 return cnt.most_common(top_k)

3.2 弗里德曼重合指数:用一个数字直接估出周期

卡西斯基是“找重复”,弗里德曼重合指数(IC)则是“看统计”。它的定义很朴素:从文本中随机抽两个字符,它们相同的概率。英文自然文本的 IC 大约在 0.065 到 0.068 之间,因为 E、T、A 这些字母出现得多,撞上的概率就高;而完全均匀随机的 26 个字母,IC 只有 1/26 ≈ 0.0385。

维吉尼亚加密把频率摊平了,整体 IC 会掉到 0.038 附近。但如果我们按假定的密钥长度 m 把密文分组,每一组内部其实是同一位移的凯撒,如果 m 猜对了,每组单独算 IC 会明显回升到 0.06 以上;如果 m 猜错了,组分得不纯,IC 就还在 0.04 徘徊。这个判据比卡西斯基稳定得多,因为它用上了全部字符,不依赖是否出现重复片段。

def index_of_coincidence(s: str) -> float: n = len(s) if n < 2: return 0.0 freq = Counter(s) return sum(v * (v - 1) for v in freq.values()) / (n * (n - 1)) def guess_key_length(cipher: str, max_len: int = 20): c = ''.join(ch.lower() for ch in cipher if ch.isascii() and ch.isalpha()) result = [] for m in range(1, max_len + 1): groups = [c[i::m] for i in range(m)] avg = sum(index_of_coincidence(g) for g in groups) / m result.append((m, round(avg, 5), round(abs(avg - 0.066), 5))) result.sort(key=lambda x: x[2]) return result

实际跑下来,正确长度的那一行 IC 通常在 0.062 到 0.072 之间,而错误长度的 IC 大多低于 0.05。有一个例外:当 m 是真实长度的整数倍时(比如真实是 4,你试 8),每组内部仍然同位,IC 也会偏高。所以候选列表里排前面的往往包含 m、2m、3m,需要用真正的破解成功率来最终确定。

3.3 分组卡方:把多表拆回单表之后就是老熟人的地盘

密钥长度确定之后,剩下的工作就非常机械了。把密文按c[i::m]分成 m 组,第 j 组的所有字母都只受到 key[j] 这一个位移的影响,所以它等价于位移为 key[j] 的凯撒密文。对每一组,枚举 26 种可能的位移,把每种位移下的字母分布与英文字母标准频率做卡方检验,取卡方值最小的那个位移。

卡方统计量的含义是“观测频数与期望频数的偏差平方,除以期望频数”,越小说明分布越贴近英文。这里有个细节:样本少的时候(每组只有二三十个字母),卡方非常不稳定,经常解出一两个错误字符。解决办法是把候选结果按卡方值排序,人工看前几名,或者用已知的明文结构(比如常见的开头的 THE)做交叉验证。

ENG_FREQ = { 'a': .0817, 'b': .0149, 'c': .0278, 'd': .0425, 'e': .1270, 'f': .0223, 'g': .0202, 'h': .0609, 'i': .0697, 'j': .0015, 'k': .0077, 'l': .0403, 'm': .0241, 'n': .0675, 'o': .0751, 'p': .0193, 'q': .0010, 'r': .0599, 's': .0633, 't': .0906, 'u': .0276, 'v': .0098, 'w': .0236, 'x': .0015, 'y': .0197, 'z': .0007, } def chi_square(seg: str) -> float: n = len(seg) if n == 0: return float('inf') freq = Counter(seg) total = 0.0 for ch, exp in ENG_FREQ.items(): obs = freq.get(ch, 0) expect = exp * n total += (obs - expect) ** 2 / expect return total def crack_by_length(cipher: str, m: int) -> str: c = ''.join(ch.lower() for ch in cipher if ch.isascii() and ch.isalpha()) key = [] for j in range(m): seg = c[j::m] best_shift, best_score = 0, float('inf') for s in range(26): shifted = ''.join(chr((ord(ch) - 97 - s) % 26 + 97) for ch in seg) score = chi_square(shifted) if score < best_score: best_score, best_shift = score, s key.append(chr(best_shift + 97)) return ''.join(key)

3.4 把三段拼起来跑一次完整破解

准备一段足够长的密文,先用 IC 找候选长度,再用卡西斯基做交叉验证,最后分组求解:

def full_crack(cipher: str, max_len: int = 20): candidates = guess_key_length(cipher, max_len)[:3] print("IC 候选密钥长度:", candidates) print("卡西斯基因子统计:", factor_guess(kasiski_spacings(cipher))) for m, avg_ic, _ in candidates: k = crack_by_length(cipher, m) plain = vigenere_decrypt(cipher, k) print(f"长度 {m} -> 密钥 {k} -> 前 60 字:{plain[:60]}")

这里有一个很关键的经验:不要只信一次输出。三个候选长度都跑一遍,人工扫一眼解出来的文本可读性,几乎一眼就能定。机器判断分布,人判断语义,两者结合比任何单一指标都可靠。我早期的版本尝试用打分函数自动挑最优解,结果在一段密钥长度 7、长度不到 300 字符的样本上,把 7 和 14 弄反了,最后还是人工看出来的。

4. 密钥长度到底怎么选:一份能对照的量化参考

4.1 重合指数的判据表

判断密钥长度时,光看“接近 0.066”不够,还要看样本量带来的波动范围。下面是我在若干测试样本上总结的经验区间,供参考对照:

平均 IC 范围含义下一步动作
0.060 以上极可能猜对,或猜成了真实长度的整数倍直接分组求解并人工验证
0.050 – 0.060长度可能对,但样本偏短用卡西斯基交叉验证
0.040 – 0.050基本可以排除跳过
0.038 附近分错组,或密钥长度超出搜索上限扩大搜索范围
明显低于 0.036密文可能含大量干扰字符先清洗文本

顺便说一句,密文里如果夹杂着大量空格、数字或者人为插入的随机符号,一定要先清洗。我曾经在一段“每隔几个字母插一个随机符号”的密文上折腾了半小时,以为是密钥长度判断失败,后来发现是脏数据把分组位置全部错位了。清洗规则很简单:只保留英文字母并统一小写。

4.2 密钥长度与破解难度的关系

很多人直觉认为密钥越长越安全,这对维吉尼亚来说方向正确但细节有讲究。安全性取决于两个因素的乘积:密钥的未知信息量,以及每个分组能拿到多少统计样本。

密钥长度典型可破所需密文长度主要障碍实操难度
130 字符左右无极低
2 – 4100 字符左右分组样本少,卡方抖动低
5 – 10200 – 400 字符长度判断容易翻倍中
11 – 20500 字符以上IC 峰值不突出中高
20 以上视情况,可能上千候选长度多,逐个人工验证耗时高

这里的规律可以概括成一句话:密钥在变长,但密文长度是固定的,所以每个分组能分到的样本在以 m 的速度减少。当每组只剩十几个字母时,频率分析就失效了,因为统计规律需要样本量支撑。但如果密钥长度接近明文长度(一次性密码本),这种加密在信息论意义上就是不可破的,这也是为什么后来的密码学强调“密钥不能复用”。

5. 它在教学和实战里到底该怎么用

5.1 练习题里它通常以什么形态出现

在各类密码学练习题中,维吉尼亚一般不会直接把密钥给你,而是给出三种情况:一种是有提示的(比如“密钥是一个常见英文单词”),这时可以拿字典对着候选长度做暴力匹配;一种是只给密文,那就要走完整的 IC 加卡西斯基流程;还有一种更刁钻的,密钥本身也是动态的,比如密钥是明文的某个函数,那已经偏离标准维吉尼亚了,需要单独分析。

我遇到最多的一种“变体”是密钥长度恰好等于明文长度的一部分,导致分组极短,IC 在各种长度上都看不出明显峰值。这时候可以反过来利用已知明文攻击:如果能猜到明文里有某个高频词(比如 “the”“and”),把它在密文的每个可能位置平移一遍,倒推密钥片段,再拼出完整周期。

5.2 作为教学工具,它最值得讲的三点

第一是多表替换的思路本身。维吉尼亚是第一个把“位置”引入加密的经典方案,这个思想在后来的分组密码、流密码里都有影子,只是实现方式从加减法变成了复杂的非线性运算。

第二是统计特性的脆弱性。它看起来把频率摊平了,但只要周期性存在,摊平就是可逆的。这引出了现代密码学的一条基本原则:好的密码必须把统计规律彻底打散,靠的是充分的混淆和扩散,而不是简单的轮换。

第三是已知明文攻击的价值。在维吉尼亚的破解中,一段已知明文能大幅缩短破解时间,因为可以同时确定密钥片段的位移。这一点在实际攻防里同样成立,任何能泄露明文结构的信息都是突破口。

5.3 别把它当成“能用的加密工具”

这一点我必须强调:维吉尼亚密码在今天只能作为教学模型,任何真实场景下的数据保护都不能用它。它的密钥空间是 26 的 m 次方,看似很大,m=10 时已经超过 1.4e14,但攻击者根本不需要穷举密钥,因为统计攻击的复杂度远低于密钥空间。用更直白的说法:它的“安全强度”不是由密钥空间决定的,而是被结构漏洞压得很低。

如果你在做一个需要隐藏内容的趣味小工具,比如给朋友发一段谜题,用它没问题,但要在说明里讲清楚这只是个游戏。真实的敏感数据,交给经过公开评审的现代对称或非对称算法,这是底线。

最后分享一个我在调试这类脚本时养成的小习惯:每次写完加解密,先拿 LEMON 和 ATTACKATDAWN 这组标准向量跑一遍,结果对不上,八成是密钥推进规则或者 ASCII 偏移写错了,两分钟就能定位问题,不用从头到尾读代码。这个小检查帮我省下的时间,比任何调试器都多。

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

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

立即咨询