基于 CrypTool-2 源码的密钥长度估计与密钥恢复机制深度剖析
摘要
一个密码被叫了三百年"不可破解",然后被一个退役军官用纸和笔干掉了——这就是 Vigenere 密码的故事。本文翻了一遍 CrypTool-2 (Kopal, 2018) 的源码,把整个攻击链条拆开来看:Kasiski 测试怎么猜密钥长度、Friedman 测试和自相关函数怎么帮忙交叉验证、爬山法怎么把密钥内容一个字母一个字母地恢复出来。顺便聊聊这些招数什么时候会翻车,以及 CrypTool-2 在工程实现上一些有意思的设计选择。
1. 引言:从凯撒到 Vigenere 的演进
凯撒密码是最简单的单表替换——所有字母用同一个移位值加密。弱点一目了然:密文里字母的出现频率跟明文一模一样。英语中 E 占 12.7%,密文里 H 出现得最多?那移位值八成是 3。
Vigenere 的改进思路很暴力:不用一个移位值,用一组移位值轮着来。密钥 KEY 意味着第 1 个字母移 10(K=10),第 2 个移 4(E=4),第 3 个移 24(Y=24),第 4 个又回到 10……同一个明文字母在不同位置会变成不同的密文字母,频率分析直接废了——至少表面上是这样。
就这么个东西,从 16 世纪被提出后,三百年没人能破。直到 1863 年,退役的普鲁士军官 Friedrich Kasiski 在《Die Geheimschriften und die Dechiffrir-kunst》里提了一招:盯着密文里重复出现的 n-gram 片段,算它们之间的距离,反推密钥长度。密钥长度一旦暴露,多表替换就退化成了多个独立的凯撒密码——逐个击破就完事了。
2. Vigenere 密码的数学定义
2.1 加密公式
设明文为 ,密钥为 (长度为 ),密文为 :
Vigenere 加密:
Vigenere 解密:
其中 为字母表大小(标准英文字母表 )。
2.2 CrypTool-2 实现:四种密码模式
CrypTool-2 的 Vigenere.cs 不只实现了标准 Vigenere,还塞了三种变体进去。内部枚举定义了 8 种操作模式(第 37-47 行):
1private enum CipherMode
2{
3 VigenereEncrypt,
4 VigenereDecrypt,
5 VigenereAutokeyEncrypt,
6 VigenereAutokeyDecrypt,
7 BeaufortEncrypt,
8 BeaufortDecrypt,
9 BeaufortAutokeyEncrypt,
10 BeaufortAutokeyDecrypt
11};四种密码模式的数学关系:
| 模式 | 加密公式 | 解密公式 | 密钥循环方式 |
|---|---|---|---|
| Vigenere | 密钥循环重复 | ||
| Vigenere Autokey | 同上(但密钥用尽后用明文续接) | 同上 | 密钥 + 明文 |
| Beaufort | 与加密相同(对合) | 密钥循环重复 | |
| Beaufort Autokey | 同上(但密钥用尽后用明文续接) | 同上 | 密钥 + 明文 |
核心 Crypt() 方法(第 188-395 行)用一个大 switch 搞定所有 8 种模式。来看标准 Vigenere 加密的部分(第 219-231 行):
1case CipherMode.VigenereEncrypt:
2 cpos = (ppos + cfg.ShiftKey[shiftPos]) % alphabet.Length;
3 shiftPos++;
4 if (shiftPos >= cfg.ShiftKey.Length)
5 {
6 shiftPos = 0; // 密钥循环:回到开头
7 }
8 break;Beaufort 有个好玩的地方:加密和解密是同一个公式——数学上叫对合(involution)运算(第 247-259 行):
1case CipherMode.BeaufortEncrypt:
2case CipherMode.BeaufortDecrypt:
3 cpos = Mod((cfg.ShiftKey[shiftPos] - ppos), alphabet.Length);
4 break;注意这里用的是自定义 Mod() 方法(第 397-405 行),而非 C# 原生的 % 运算符——因为 C# 的 % 对负数返回负数,而密码学需要非负模结果:
1private static int Mod(int number, int module)
2{
3 int result = number % module;
4 if ((result < 0 && module > 0) || (result > 0 && module < 0))
5 {
6 result += module;
7 }
8 return result;
9}2.3 Autokey 模式
Autokey 的关键区别:密钥不循环。用完了怎么办?拿明文字母接着当密钥用。来看 Vigenere Autokey 加密的代码(第 261-283 行):
1case CipherMode.VigenereAutokeyEncrypt:
2 if (shiftPos < cfg.ShiftKey.Length)
3 {
4 // 密钥尚未用尽:正常 Vigenere 加密
5 cpos = (ppos + cfg.ShiftKey[shiftPos]) % alphabet.Length;
6 shiftPos++;
7 }
8 else
9 {
10 // 密钥用尽:用明文字母作为密钥
11 int pkey = alphabet.IndexOf(char.ToUpper(_inputString[autopos]));
12 while (pkey < 0)
13 {
14 autopos++;
15 pkey = alphabet.IndexOf(char.ToUpper(_inputString[autopos]));
16 }
17 cpos = (ppos + pkey) % alphabet.Length;
18 autopos++;
19 }
20 break;先记住这个模式——后面聊 Kasiski 攻击什么时候会翻车,它是个关键角色。
3. Kasiski 攻击:第一步——确定密钥长度
3.1 核心洞察
Kasiski 攻击的出发点其实很朴素:
明文里两个一样的片段,如果恰好碰上密钥的同一位置来加密,密文里也会出现一模一样的片段。而这两个重复密文之间的距离,一定是密钥长度的整数倍。
举个例子就清楚了:
1明文: THE SUN AND THE MOON
2密钥: KEY KEY KEY KEY KEYK
3密文: DLC QSX KXH DLC WSKX"THE" 在第 1 位和第 13 位各出现一次,两次都恰好对齐密钥的 "KEY",所以密文中 "DLC" 也重复出现。距离 = 13 - 1 = 12,而 12 = 3 × 4,密钥长度 3 是 12 的因子。
当然也可能撞巧——两段不同的明文被不同密钥位置加密,碰巧搞出了一样的密文。但概率说了算:真正的密钥长度因子,在所有距离的因子里出现频率一定最高。

3.2 CrypTool-2 实现:KasiskiTest.cs
源文件:CrypPlugins/KasiskiTest/KasiskiTest.cs(293 行)
整个流程三步走:清洗文本 → 找重复子串 → 距离因子分解。
Step 1:文本预处理(第 109-140 行)
先把文本收拾干净——要不要丢掉非字母字符、要不要统一大小写,都看用户设置:
1string workString = stringInput;
2string workstring2 = "";
3
4if (settings.unknownSymbolHandling == 1) // 丢弃未知符号
5{
6 char[] validchars = new char[] { 'a', 'b', 'c', ... 'Z' };
7 string strValidChars = new string(validchars);
8 StringBuilder workstring1 = new StringBuilder();
9 foreach (char c in workString.ToCharArray())
10 {
11 if (strValidChars.IndexOf(c) >= 0)
12 {
13 workstring1.Append(c);
14 }
15 }
16 workstring2 = workstring1.ToString();
17}
18
19if (settings.caseSensitivity == 0)
20{
21 workstring2 = workstring2.ToUpper();
22}Step 2:重复子串搜索(第 154-174 行)
这才是干活的部分。三层嵌套循环暴力搜:外层扫 n-gram 长度(从 3 到用户设的最大值),中层扫起始位置,内层往后找有没有一样的 n-gram:
1for (int d = 3; d <= settings.grammLength; d++) // n-gram 长度
2{
3 for (int i = 0; i <= workstring2.Length - settings.grammLength; i++) // 起始位置
4 {
5 grammToSearch = workstring2.Substring(i, d); // 取出当前 n-gram
6
7 for (int n = i + settings.grammLength; n <= workstring2.Length - settings.grammLength; n++)
8 {
9 if (grammToSearch == workstring2.Substring(n, d)) // 找到重复
10 {
11 distances.Add(n - i); // 记录距离
12 checkedGramms.Add(grammToSearch); // 记录 n-gram
13 break; // 只记第一个匹配
14 }
15 }
16 }
17}注意那个 break——找到第一个匹配就跳出了。也就是说,如果同一个 n-gram 出现了三次(位置 A、B、C),只会记录 A→B 的距离,A→C 和 B→C 就被扔掉了。简单粗暴,少算了一些信息但也减少了噪声。
Step 3:距离因子分解(第 190-212 行)
这里有个有意思的地方——CrypTool-2 不做标准的质因数分解,而是做整除检测:
1for (int z = 0; z <= distances.Count - 1; z++) // 遍历每个距离
2{
3 int numberToFactorize = Convert.ToInt32(distances[z]);
4 x = 0;
5
6 for (int y = 2; y <= settings.factorSize; y++) // 从 2 到最大因子
7 {
8 if (numberToFactorize == 0) { break; }
9 if (numberToFactorize % y == 0) // 能整除就算一个因子
10 {
11 factors[z, x] = y;
12 x++;
13 factorCounter[y]++; // 该因子计数 +1
14 }
15 }
16}留意这段被注释掉的代码(第 204-207 行):
1// while (numberToFactorize % y == 0)
2// {
3// numberToFactorize = numberToFactorize / y;
4// }标准做法是找到因子后反复除,直到除不动为止。但 CrypTool-2 把这段注释掉了,改成了单纯的整除检测。拿距离 12 来说:
- 质因数分解得到:2, 2, 3(12 = 2² × 3)
- 整除检测得到:2, 3, 4, 6(12 能被这几个数整除)
为什么整除检测反而更好?因为密钥长度又不一定是质数。假设密钥长度是 6,那距离 12、18、24 都能被 6 整除,"6" 这个因子会在 factorCounter 里不断攒分。要是只做质因数分解,分解出来的是 2 和 3,你还得再组合一步才能猜到密钥可能是 6——多此一举。
最后,factorCounter 里得分最高的那个因子,就是最可能的密钥长度。结果以直方图输出(第 226-231 行),在 CrypTool-2 界面上各因子的得分高低一目了然。
4. Friedman 测试:统计学来帮忙
4.1 数学原理
Friedman 测试(也叫 Kappa 测试),1922 年 William Friedman 提出的,思路跟 Kasiski 完全不同——用重合指数(Index of Coincidence, IC)来估密钥长度。
所谓重合指数,就是从文本里随机摸两个字母出来,它们恰好一样的概率:
- 自然语言英文:
- 随机均匀分布:(26 字母)
密钥长度为 的 Vigenere 密文,IC 值大概介于自然语言和随机分布之间。从这个关系可以反推密钥长度:
4.2 CrypTool-2 实现:FriedmanTest.cs
源文件:CrypPlugins/FriedmanTest/FriedmanTest.cs(186 行)
多语言 Kappa 值(第 110-118 行)——直接硬编码了 6 种语言的 IC 参考值:
1switch (settings.Kappa)
2{
3 case 1: Kp = 0.0762; break; // 德语
4 case 2: Kp = 0.0778; break; // 法语
5 case 3: Kp = 0.0770; break; // 西班牙语
6 case 4: Kp = 0.0738; break; // 意大利语
7 case 5: Kp = 0.0745; break; // 葡萄牙语
8 default: Kp = 0.0667; break; // 英语
9}核心计算(第 129-136 行)——公式直接套:
1double cipherTextLength = arrayInput.Sum();
2double countDoubleCharacters = arrayInput.Sum(i => i * ((double)i - 1));
3
4double Keqdist = 1.0 / 26; // 随机等概分布的 IC
5kappaCiphertext = countDoubleCharacters / (cipherTextLength * (cipherTextLength - 1));
6keyLength = ((Kp - Keqdist) * cipherTextLength) /
7 ((cipherTextLength - 1) * kappaCiphertext - (Keqdist * cipherTextLength) + Kp);单表/多表判定(第 138 行):
1string ciphermode = (Math.Abs(Kp - kappaCiphertext) > 0.01)
2 ? "polyalphabetic"
3 : "monoalphabetic/cleartext";逻辑很粗暴:密文 IC 跟目标语言 IC 差了超过 0.01?那就是多表替换。没差那么多?单表替换或者根本就是明文。0.01 这个阈值纯经验值。
4.3 Friedman 测试的定位
Friedman 给出的是一个连续的估计值——可能蹦出个 4.73 这样的小数。而 Kasiski 给的是离散的候选集——2、3、6 这种。两个放一起交叉验证,靠谱程度就上去了:Friedman 说"大概在 5 附近",Kasiski 说"6 的得分最高",那密钥长度大概率就是 5 或 6。
但 Friedman 测试也有它的软肋:它假设密钥字母均匀分布、明文各位置的字母频率一致。密文一短,估计值就飘得厉害,参考价值大打折扣。
5. 自相关函数:第三条路
5.1 算法原理
自相关函数的思路比前两个都直觉:把密文跟自己的移位版本对齐,数有多少个位置字母一样。
为什么管用?如果移位量刚好等于密钥长度(或其倍数),对齐的两段文本在每个位置用的是同一个密钥字母——等于在比较两段单表替换密文。字母相同的概率会回到自然语言的 IC 水平(约 0.067),比随机情况(约 0.038)高出一大截。
5.2 CrypTool-2 实现:AutocorrelationFunction.cs
源文件:CrypPlugins/AutocorrelationFunction/AutocorrelationFunction.cs(227 行)
最大测试移位量硬编码为 31(第 32 行):
1private const int MAX_TESTED_SHIFTS = 31;文本预处理(第 183-196 行)——老规矩,扔掉非字母字符、转大写:
1private string PrepareForAnalysis(string text)
2{
3 StringBuilder stringBuilder = new StringBuilder();
4 text = text.ToUpper();
5 for (int offset = 0; offset < text.Length; offset++)
6 {
7 if (Alphabet.Contains(text.Substring(offset, 1)))
8 {
9 stringBuilder.Append(text[offset]);
10 }
11 }
12 return stringBuilder.ToString();
13}移位匹配计算(第 103-124 行):
1for (int shift = 0; shift < MAX_TESTED_SHIFTS; shift++)
2{
3 int match = 0;
4 for (int offset = 0; offset < _inputText.Length - shift; offset++)
5 {
6 if (_inputText[offset] == _inputText[offset + shift])
7 {
8 match++;
9 }
10 }
11 _autocorrelationValues[shift] = match;
12}找最大峰值(第 128-136 行)——从 shift=1 开始找(shift=0 是自己跟自己比,100% 匹配,没意义):
1for (int i = 1; i < MAX_TESTED_SHIFTS; i++)
2{
3 if (_autocorrelationValues[i] > _probablekorr)
4 {
5 _probablekorr = _autocorrelationValues[i];
6 _probableLength = i;
7 }
8}匹配数最高的移位量就是估计的密钥长度。画成直方图,峰值在哪儿一眼就看出来了。
5.3 局限性
最大移位量写死了 31,也就是说密钥长度超过 30 就检测不到了。大多数教学场景够用,但碰上长密钥就没辙了。
6. 密钥恢复:爬山法搜索
密钥长度拿到了,接下来就是把密钥具体内容挖出来。CrypTool-2 的 VigenereAnalyzer 用的是爬山法(Hill Climbing)。
6.1 爬山法概览
源文件:CrypPlugins/VigenereAnalyzer/VigenereAnalyzer.cs(816 行)
核心逻辑在 Hillclimb() 方法里(第 246-390 行)。思路很直白:对每个候选密钥长度,随机生成一把初始密钥,然后一个位置一个位置地试——把第 i 位换成字母 j,解密一下,用 N-gram 打分。分数变高了就留着,没变高就换回去。所有字母都试完一轮还能找到更好的就继续,找不到就停。这个过程重复好多次(每次换个随机起点),最好的结果全部丢进一个排行榜。
6.2 初始密钥生成
第 263-267 行——每个位置随机从字母表里摸一个字母出来:
1for (int i = 0; i < runkey.Length; i++)
2{
3 runkey[i] = numalphabet[random.Next(alphabetlength)];
4}6.3 逐位优化
第 296-363 行——两层循环,外层扫密钥位置,内层扫字母表:
1do
2{
3 foundbetter = false;
4 for (int i = 0; i < keylength; i++) // 密钥的每个位置
5 {
6 for (int j = 0; j < alphabetlength; j++) // 字母表的每个字母
7 {
8 int oldLetter = runkey[i];
9 runkey[i] = j;
10 plaintext = decryptFunction(plaintext, runkey, numvigalphabet, i, ciphertext);
11
12 double costvalue = 0;
13 if(_settings.KeyStyle == KeyStyle.Random)
14 {
15 costvalue = _grams.CalculateCost(plaintext);
16 }
17 else
18 {
19 var plaintext_and_key = AppendIntegerArrays(plaintext, runkey);
20 costvalue = _grams.CalculateCost(plaintext_and_key);
21 }
22
23 if (costvalue > bestkeycost)
24 {
25 bestkeycost = costvalue;
26 bestkey = (int[])runkey.Clone();
27 foundbetter = true;
28 }
29 else
30 {
31 runkey[i] = oldLetter; // 恢复原密钥
32 if (j == alphabetlength - 1)
33 {
34 // 所有字母都试完了没有改善,恢复原始解密结果
35 plaintext = decryptFunction(plaintext, runkey, numvigalphabet, i, ciphertext);
36 }
37 }
38 }
39 }
40 runkey = (int[])bestkey.Clone();
41} while (foundbetter);6.4 "InPlace" 优化:别全部重算
这里有个挺精巧的优化。你改了密钥的第 位,难道要把整段密文重新解密一遍?没必要——只有明文中第 这些位置会受影响,其他位置纹丝不动。
来看标准 Vigenere 的 InPlace 解密(第 542-557 行):
1public static int[] DecryptVigenereOwnAlphabetInPlace(
2 int[] plaintext, int[] key, int[] alphabet, int offset, int[] oldciphertext)
3{
4 int plaintextlength = plaintext.Length;
5 int keylength = key.Length;
6 int alphabetlength = alphabet.Length;
7 int[] lookup = new int[alphabetlength];
8 for (int position = 0; position < alphabetlength; position++)
9 {
10 lookup[alphabet[position]] = position;
11 }
12 // 关键:只更新 offset, offset+keylength, offset+2*keylength, ... 位置
13 for (int position = offset; position < plaintextlength; position += keylength)
14 {
15 plaintext[position] = alphabet[
16 (lookup[oldciphertext[position]] - lookup[key[position % keylength]] + alphabetlength) % alphabetlength];
17 }
18 return plaintext;
19}跟完整解密方法(第 490-506 行)比一下就明白了:循环步长从 1 变成了 keylength。密文 1000 个字符、密钥长度 5,InPlace 每次只算 200 个位置,完整版要算 1000 个。爬山法内层循环要跑"密钥位置 × 字母表大小"这么多轮,每轮都省 80% 的解密量——效果相当可观。
6.5 密钥也是"人话"?KeyStyle 的妙用
VigenereAnalyzerSettings.cs 第 38-42 行定义了两种密钥风格:
1public enum KeyStyle
2{
3 Random = 0,
4 NaturalLanguage = 1
5}当 KeyStyle 设为 NaturalLanguage 时,评分的对象就不只是明文了——密钥也得一起打分(第 315-319 行):
1var plaintext_and_key = AppendIntegerArrays(plaintext, runkey);
2costvalue = _grams.CalculateCost(plaintext_and_key);为什么这么做?很多时候密钥本身就是个有意义的词——"LEMON"、"SECRET" 之类的。把明文和密钥拼到一起打分,相当于多了一个信号源。短密文场景下尤其有用:光看明文的统计量可能飘来飘去不够稳定,加上密钥的语言特征,爬山法更容易找对方向。
6.6 多次重启与 BestList
爬山法的老毛病:容易卡在局部最优出不来。怎么办?多跑几次呗。第 261 行的 while (restarts > 0) 循环,每次换个随机起点重新爬。
所有跑出来的好结果丢进一个有序列表(第 412-481 行),最多留 100 个(MaxBestListEntries = 100,第 41 行)。按 N-gram 评分从高到低排好,用户在界面上翻一翻,自己挑对的。
插入逻辑(第 461-462 行)保持有序:
1int insertIndex = _presentation.BestList.TakeWhile(e => e.Value > entry.Value).Count();
2_presentation.BestList.Insert(insertIndex, entry);7. 什么时候这套东西会翻车?
Kasiski 攻击看着挺漂亮,但它能干活是有前提条件的。一旦这些条件不满足,整套攻击链就废了。
7.1 密文太短——统计量撑不住
Kasiski 测试得靠密文里出现足够多的重复 n-gram。密文就几十个字符?3-gram 重复出现的概率低得可怜,根本凑不够样本做因子分析。
Friedman 也一样——IC 值需要统计量撑着。短密文算出来的 IC 飘来飘去,估出的密钥长度可能差好几倍。
自相关函数在短密文下更惨,到处都是"峰值",真信号和噪声混在一起分不清。
7.2 密钥跟明文一样长——那就是 One-Time Pad
密钥长度 等于明文长度 ?恭喜,这就是 One-Time Pad(一次性密码本)——Shannon (1949) 证明过的,信息论意义上不可破解的完美保密系统。
密钥根本不会循环,所以"相同密钥位置加密相同明文"这个前提就不存在了。Kasiski 找不到有意义的重复 n-gram,Friedman 的公式在 时直接退化成不确定形式。
哪怕密钥只是"接近"明文长度(比如 ),也够呛。每个密钥位置只覆盖 1-2 个字符,频率分析完全没戏。
7.3 明文不是"人话"——压缩数据/随机数据
这些统计攻击——Kasiski、Friedman、自相关、N-gram 爬山——全都有一个隐含假设:明文是自然语言,字母分布不均匀,N-gram 模式可预测。
要是明文本身就是:
- 压缩数据(近似随机分布,IC ≈ 0.038)
- 已加密数据(先用别的算法加密过一轮再套 Vigenere)
- 随机字节序列
——那就尴尬了。就算你知道了密钥长度,爬山法也分不出正确密钥和错误密钥。因为不管用什么密钥解密,出来的东西在统计上都一样"均匀"。
7.4 Autokey 模式——密钥不循环了
还记得前面说的 Autokey 吗(Vigenere.cs 第 261-283 行)?密钥用完之后拿明文字母接着当密钥。有效密钥序列变成了 ——没有周期性了。
Kasiski 的命根子——"同一个明文碰上同一个密钥位置会产生重复密文"——在 Autokey 下直接断了。第 个字母之后的"密钥"就是明文本身,没有循环可言。
CrypTool-2 的 VigenereAnalyzer 还是可以用爬山法试着攻击 Autokey(有专门的 DecryptVigenereAutokeyOwnAlphabet 方法),但 Kasiski 和 Friedman 估密钥长度这一步就彻底没用了。
7.5 非标准字母表——大字母表把频率特征冲淡了
CrypTool-2 支持自定义字母表(Vigenere.cs 第 96-108 行的 AlphabetSymbols 属性),用户想传啥字符都行。
但要是用了一个 256 字符的字母表(比如完整 ASCII),每个字符的出现频率就被摊得很薄了。IC 趋近 ,跟随机分布几乎没区别。N-gram 统计更要命——数组大小是字母表长度的 N 次方, 直接炸内存,高阶 N-gram 评分根本跑不起来。
7.6 密钥跟明文等长且随机
哪怕不是严格的 One-Time Pad(密钥可能有点结构),只要密钥长度等于明文长度、内容又是随机的,攻击就基本没戏了。说白了就是 7.2 的一般化版本。
7.7 语言搞错了或者混着来
N-gram 评分必须对上正确的语言模型。CrypTool-2 的 FriedmanTest 提供了 6 种语言的 IC 参考值,LanguageStatisticsLib 支持 15 种语言的 N-gram 数据。但明文要是用的语言不在里面(阿拉伯语、中文之类),或者混了好几种语言,评分函数就会给出误导性的结果。
最典型的翻车场景:你用英语 N-gram 去评分一段实际上是德语的密文,爬山法兴高采烈地收敛到一个"看起来像英语"的密钥——然后完全是错的。
8. CrypTool-2 的工程细节
8.1 四种密码模式,一套代码搞定
Vigenere.cs 用一个 Crypt() 方法加一个 CipherMode 枚举就搞定了 8 种加密/解密操作。分析器这边也是同一个思路——VigenereAnalyzer.cs 用 DecryptFunction 委托(第 33 行)加 Mode 枚举(VigenereAnalyzerSettings.cs 第 23-29 行)实现统一调度:
1// 委托定义(VigenereAnalyzer.cs 第 33 行)
2public delegate int[] DecryptFunction(
3 int[] plaintext, int[] key, int[] alphabet, int offset, int[] oldciphertext);
4
5// 枚举(VigenereAnalyzerSettings.cs 第 23-29 行)
6public enum Mode
7{
8 Vigenere = 0,
9 VigenereAutokey = 1,
10 Beaufort,
11 BeaufortAutokey,
12};爬山法里,解密函数通过委托动态绑定(第 275-294 行),同一套爬山逻辑直接切换四种密码模式,不用改一行代码——典型的策略模式(Strategy Pattern)。
8.2 自定义字母表——置换字母表也能打
VigenereAnalyzer 接受外部传入的字母表(第 78-79 行的 VigenereAlphabet 属性),Execute() 里做去重排序(第 152 行):
1Alphabet = string.Join("", VigenereAlphabet.Distinct().OrderBy(q => q).ToArray());
2_grams.ReduceAlphabet(Alphabet);
3_grams.Normalize(10_000_000);这就意味着:如果 Vigenere 用了置换字母表(比如 QWERTYUIOPASDFGHJKLZXCVBNM 而不是标准的 ABCDEFGHIJKLMNOPQRSTUVWXYZ),只要把正确的字母表喂进去,分析器照样能跑。
8.3 15 种语言的 N-gram 统计库
VigenereAnalyzer.cs 第 147 行加载目标语言的 N-gram 数据:
1_grams = LanguageStatistics.CreateGrams(
2 _settings.Language,
3 DirectoryHelper.DirectoryLanguageStatistics,
4 (GramsType)(_settings.GramsType + 1),
5 false);覆盖了大部分欧洲语言(英、德、法、西、意、葡、荷、瑞典、波兰、捷克、匈牙利、拉丁、俄、希腊、土耳其),每种语言都有独立的 N-gram 频率文件,从 1-gram 到 6-gram 都有。
8.4 InPlace 解密优化
前面 6.4 节已经聊过了。每种密码模式都有两套解密方法:
- 完整版(如
DecryptVigenereOwnAlphabet,第 490-506 行):把整段密文解一遍 - InPlace 版(如
DecryptVigenereOwnAlphabetInPlace,第 542-557 行):只动密钥变更影响到的位置
完整版管初始化和最终输出,InPlace 版给爬山法内层循环用。两个版本逻辑完全一样,就是循环步长不同(1 vs keylength),不会算出不同结果。
8.5 BestList:只留 Top 100
MaxBestListEntries = 100(第 41 行)卡住了列表大小。AddNewBestListEntry 方法(第 412-481 行)负责插入和裁剪:
1// 跳过不够好的结果
2if (_presentation.BestList.Count > 0 && value <= _presentation.BestList.Last().Value)
3{
4 return;
5}
6
7// 有序插入
8int insertIndex = _presentation.BestList.TakeWhile(e => e.Value > entry.Value).Count();
9_presentation.BestList.Insert(insertIndex, entry);
10
11// 裁剪
12if (_presentation.BestList.Count > MaxBestListEntries)
13{
14 _presentation.BestList.RemoveAt(MaxBestListEntries);
15}列表始终按 N-gram 评分从高到低排着,超过 100 条就把最差的踢掉。用户在界面上翻 Top 100,自己挑哪个是对的。
8.6 Kasiski 因子分解的设计选择
前面 3.2 节说过了:CrypTool-2 用整除检测而不是质因数分解。从被注释掉的代码(KasiskiTest.cs 第 204-207 行)来看,开发者试过标准做法,最后有意识地选了整除检测——更适合密钥长度估计这个场景。
另外那个 break(第 167 行)也不是偷懒。每个 n-gram 只匹配第一次出现,实际上起到了降噪的效果——假如 "THE" 在密文里出现了 10 次(可能大部分是巧合重复),不 break 的话会产生 45 个距离值,把因子统计搞得乱七八糟。
8.7 Lookup 数组——用空间换时间
VigenereAnalyzer 所有解密方法都用了 lookup 数组来加速字母索引查询。来看标准 Vigenere 的版本(第 496-503 行):
1int[] lookup = new int[alphabetlength];
2for (int position = 0; position < alphabetlength; position++)
3{
4 lookup[alphabet[position]] = position;
5}
6for (int position = 0; position < plaintextlength; position++)
7{
8 plaintext[position] = alphabet[
9 (lookup[ciphertext[position]] - lookup[key[position % keylength]] + alphabetlength) % alphabetlength];
10}lookup 数组把字母值到索引的映射预先算好,循环里直接查数组就行,不用反复调 IndexOf。O(1) 的数组访问 vs O(n) 的线性搜索——在爬山法的热循环里,这个差距可不小。
9. 完整攻击流程总结
把前面这些零件拼到一起,CrypTool-2 攻击 Vigenere 密码的完整流程大概是这样的:
拿到密文之后,先同时扔给三个工具跑:Kasiski 测试输出因子分布直方图,Friedman 测试给出一个连续的密钥长度估计值,自相关函数画出移位匹配峰值。三个结果交叉验证,定下密钥长度。
密钥长度一确定,就把密文和候选长度交给 VigenereAnalyzer。爬山法开始干活:随机起点、逐位试字母、N-gram 打分、保留最优、多次重启。跑完之后 BestList 里攒着 Top 100 的候选结果,用户翻一翻就能找到正确的密钥和明文。
但这套流程能跑通,有几个前提条件必须同时满足:
- 密文足够长——给 Kasiski/Friedman 提供统计样本
- 密钥循环重复——Autokey 和 One-Time Pad 不适用
- 明文是自然语言——N-gram 评分才有区分度
- 语言得猜对——成本函数需要正确的语言模型
- 字母表是标准的——N-gram 统计数据得能用上
10. 关键源文件索引
| 文件路径 | 行数 | 核心功能 |
|---|---|---|
CrypPlugins/Vigenere/Vigenere.cs | 502 | 四种 Vigenere 变体的加密/解密实现 |
CrypPlugins/KasiskiTest/KasiskiTest.cs | 293 | Kasiski 测试:重复 n-gram 搜索 + 整除因子检测 |
CrypPlugins/FriedmanTest/FriedmanTest.cs | 186 | Friedman 测试:IC 计算 + 密钥长度估计(6 种语言) |
CrypPlugins/AutocorrelationFunction/AutocorrelationFunction.cs | 227 | 自相关函数:移位匹配计数(最大移位 31) |
CrypPlugins/VigenereAnalyzer/VigenereAnalyzer.cs | 816 | 爬山法密钥恢复:InPlace 优化 + BestList Top 100 |
CrypPlugins/VigenereAnalyzer/VigenereAnalyzerSettings.cs | — | 分析器配置:Mode/KeyStyle/GramsType 枚举 |
LibSource/LanguageStatisticsLib/LanguageStatistics.cs | — | 语言统计门面类:15 种语言字母表 + N-gram 工厂 |
LibSource/LanguageStatisticsLib/Grams/Grams.cs | — | N-gram 评分抽象基类 |
CrypPlugins/CostFunction/CostFunction.cs | — | 通用代价函数(IoC/Entropy/NGram/RegEx) |
参考文献
Friedman, W. F. (1922). The index of coincidence and its applications in cryptography (Riverbank Publication No. 22). Riverbank Laboratories.
Kasiski, F. W. (1863). Die Geheimschriften und die Dechiffrir-Kunst. E. S. Mittler und Sohn.
Kopal, N. (2018). Solving classical ciphers with CrypTool 2. In Proceedings of the 1st International Conference on Historical Cryptology (HistoCrypt 2018) (Vol. 149, pp. 29–38). Linköping University Electronic Press.
Shannon, C. E. (1949). Communication theory of secrecy systems. Bell System Technical Journal, 28(4), 656–715.