Python 密码破解指南:20~21

391 阅读53分钟

协议:CC BY-NC-SA 4.0

译者:飞龙

本文来自【OpenDocCN 饱和式翻译计划】,采用译后编辑(MTPE)流程来尽可能提升效率。

收割 SB 的人会被 SB 们封神,试图唤醒 SB 的人是 SB 眼中的 SB。——SB 第三定律

二十、破解维吉尼亚密码

原文:inventwithpython.com/cracking/ch…

“隐私权是一项与生俱来的人权,是维护人类尊严和尊重的必要条件。”

——布鲁斯·施奈尔,密码学家,2006 年

Images

有两种方法可以破解维吉尼亚密码。一种方法使用强力字典攻击来尝试将字典文件中的每个单词作为维吉尼亚密钥,只有当该密钥是英语单词时才有效,如 RAVEN 或 DESK。第二种更复杂的方法是 19 世纪数学家查尔斯·巴贝奇使用的,即使密钥是一组随机的字母,如 VUWFE 或 PNFJ,它也能工作。在本章中,我们将使用这两种方法编写程序来破解维吉尼亚密码。

本章涵盖的主题

  • 字典攻击

  • 卡西斯基检查

  • 计算因数

  • set数据类型和set()函数

  • extend()列表方法

  • itertools.product()函数

使用字典攻击暴力破解维吉尼亚密码

我们将首先使用字典攻击来破解维吉尼亚密码。字典文件dictionary.txt (可在本书网站www.nostarch.com/crackingcodes)约有 45000 个英语单词。我的电脑只需不到五分钟就能完成对一个长段落大小的信息的所有解密。这意味着,如果使用一个英语单词来加密一个维吉尼亚密文,该密文容易受到字典攻击。让我们来看一个使用字典攻击来破解维吉尼亚密码的程序的源代码。*

维吉尼亚字典破解程序的源代码

选择文件 -> 新文件,打开新文件编辑器窗口。在文件编辑器中输入以下代码,然后保存为vigeneredictionaryhacker.py。确保将detectEnglish.pyvigenereCipher.pypyperclip.py文件与vigeneredictionaryhacker.py文件放在同一目录下。然后按F5运行程序。

守护 字典 黑客. py

# Vigenere Cipher Dictionary Hacker
# https://www.nostarch.com/crackingcodes/ (BSD Licensed)

import detectEnglish, vigenereCipher, pyperclip

def main():
    ciphertext = """Tzx isnz eccjxkg nfq lol mys bbqq I lxcz."""
    hackedMessage = hackVigenereDictionary(ciphertext)

    if hackedMessage != None:
        print('Copying hacked message to clipboard:')
        print(hackedMessage)
        pyperclip.copy(hackedMessage)
    else:
        print('Failed to hack encryption.')


def hackVigenereDictionary(ciphertext):
    fo = open('dictionary.txt')
    words = fo.readlines()
    fo.close()

    for word in lines:
        word = word.strip() # Remove the newline at the end.
        decryptedText = vigenereCipher.decryptMessage(word, ciphertext)
        if detectEnglish.isEnglish(decryptedText, wordPercentage=40):
            # Check with user to see if the decrypted key has been found:
            print()
            print('Possible encryption break:')
            print('Key ' + str(word) + ': ' + decryptedText[:100])
            print()
            print('Enter D for done, or just press Enter to continue
                  breaking:')
            response = input('> ')

            if response.upper().startswith('D'):
                return decryptedText

if __name__ == '__main__':
    main()

维吉尼亚字典破解程序的运行示例

当您运行vigeneredictionaryhacker.py程序时,输出应该是这样的:

Possible encryption break:
Key ASTROLOGY: The recl yecrets crk not the qnks I tell.
Enter D for done, or just press Enter to continue breaking:
>
Possible encryption break:
Key ASTRONOMY: The real secrets are not the ones I tell.
Enter D for done, or just press Enter to continue breaking:
> d
Copying hacked message to clipboard:
The real secrets are not the ones I tell.

程序建议的第一个关键字(ASTROLOGY)不起作用,所以用户按Enter让破解程序继续,直到找到正确的解密密钥(ASTRONOMY)。

关于维吉尼亚字典破解程序

因为vigeneredictionaryhacker.py程序的源代码与本书中之前的破解程序类似,所以我不会逐行解释。简单来说,hackVigenereDictionary()函数试图使用字典文件中的每个单词来解密密文,当解密后的文本看起来像英语时(根据detectEnglish模块),它打印解密并提示用户退出或继续。

注意,这个程序对从open()返回的文件对象使用了readlines()方法:

    words = fo.readlines()

与将文件的全部内容作为单个字符串返回的read()方法不同,readlines()方法返回一个字符串列表,其中每个字符串是文件中的一行。因为字典文件的每一行都有一个单词,所以words变量包含了从AarhusZurich的每一个英语单词的列表。

程序的其余部分,从第 23 行到第 36 行,类似于第 12 章中的换位密码破解程序。一个for循环将迭代words列表中的每个单词,以单词为密钥解密消息,然后调用detectEnglish.isEnglish()查看结果是否是可理解的英文文本。

现在,我们已经编写了一个使用字典攻击来破解维吉尼亚密码的程序,让我们看看如何破解维吉尼亚密码,即使密钥是一组随机的字母而不是字典中的单词。

使用卡西斯基检查来查找密钥的长度

卡西斯基检查是一个我们可以用来确定用于加密密文的维吉尼亚密钥长度的过程。然后,我们可以使用频率分析来单独破解每个子密钥。查尔斯·巴贝奇是第一个用这种方法破解维吉尼亚尔密码的人,但他从未公布过他的结果。他的方法后来被 20 世纪早期的数学家弗雷德里希·卡西斯基发表,他成为了这种方法的同名者。我们来看看卡西斯基考试涉及的步骤。这些是我们的维吉尼亚黑客程序将采取的步骤。

寻找重复序列

卡西斯基检查的第一步是找到密文中至少三个字母的每个重复集。这些重复的序列可以是使用维吉尼亚密钥的相同子密钥加密的相同明文字母。例如,如果你用密钥 SPILLTHEBEANS 加密明文,你会得到:

THECATISOUTOFTHEBAG
SPILLTHEBEANSSPILLT
LWMNLMPWPYTBXLWMMLZ

注意,字母LWM重复了两次。原因是在密文中,LWM是使用与密钥相同的字母(SPI)加密的明文,因为密钥恰好在第二次加密时重复。从第一个LWM开始到第二个LWM开始的字母数,我们称之为间距,是 13。这表明用于该密文的密钥有 13 个字母长。只要看看重复的序列,你就能算出密钥的长度。

然而,在大多数密文中,密钥不会方便地与重复的字母序列对齐,或者密钥可能在重复序列之间重复多次,这意味着重复字母之间的字母数量将等于密钥的倍数,而不是密钥本身。为了解决这些问题,让我们看一个更长的例子,在这个例子中,我们不知道密钥是什么。

当我们去掉密文中的非字母PPQCA XQVEKG YBNKMAZU YBNGBAL JON I TSZM JYIM. VRAG VOHT VRAU C TKSG. DDWUO XITLAZU VAVV RAZ C VKB QP IWPOU,它看起来像图 20-1 所示的字符串。该图还显示了该字符串中的重复序列——VRAAZUYBN——以及每个序列对之间的字母数。

Images

图 20-1:示例字符串中的重复序列

在这个例子中,有几个潜在的密钥长度。卡西斯基检查的下一步是计算这些计数的所有因数,以缩小潜在的密钥长度。

获取间隔因数

在示例中,序列之间的间隔是 8、8、24、32 和 48。但是间距的因数比间距更重要。

要了解原因,请看表 20-1 中的消息“密码”并尝试用九个字母的密钥ABCDEFGHI和三个字母的密钥XYZ对其加密。每个密钥在消息长度内重复。

表 20-1: 用两个不同的密钥加密密码

ABCDEFGHI加密XYZ加密
明文消息DOGANDCATDOGANDCAT
密钥(重复)ABCDEFGHIABCDEFXYZXYZ
密文TIGGSLGULTIGFEY QFDAMFXLCZYS

正如所料,这两个密钥产生了两种不同的密文。当然,黑客不会知道原始消息或密钥,但他们会在TIGGSLGULTIGFEY密文中看到序列TIG出现在索引 0 和索引 9 处。因为9 – 0 = 9,这些序列之间的间距是 9,这似乎表明原始密钥是一个九个字母的密钥;在这种情况下,指示是正确。

然而,QFDAMFXLCQFDZYS密文也会产生一个重复序列(QFD),出现在索引 0 和索引 9 处。这些序列之间的间距也是 9,这表明该密文中使用的密钥也是 9 个字母长。但是我们知道密钥只有三个字母长:XYZ

当消息中的相同字母(在我们的示例中为)用密钥中的相同字母(在我们的示例中为ABCXYZ)加密时,会出现重复序列,这发生在消息和密钥中的相似字母“排列”并加密到相同序列时。这种对齐可以发生在任意倍数的真实密钥长度上(比如 3、6、9、12 等等),这就是为什么三个字母的密钥可以产生间隔为 9 的重复序列。

因此,可能的密钥长度不仅取决于间距,还取决于间距的任何因数。9 的因数是 9、3 和 1。因此,如果您发现间距为 9 的重复序列,您必须考虑密钥的长度可能是 9 或 3。我们可以忽略 1,因为只有一个字母密钥的维吉尼亚密码就是凯撒密码。

卡西斯基检验的第 2 步包括找出每个间距的因数(不包括 1),如表 20-2 所示。

表 20-2: 各间距的因数

间距因数
82, 4, 8
242, 4, 6, 8, 12, 24
322, 4, 8, 16
482, 4, 6, 8, 12, 24, 48

数字 8、8、24、32 和 48 合起来有以下因数:2、2、2、2、4、4、4、6、6、8、8、8、12、12、16、24、24 和 48。

密钥很可能是最常出现的因数,你可以通过计数来确定。因为 2、4 和 8 是最常出现的间距因数,所以它们是最有可能的维吉尼亚调长度。

从字符串中每隔 N 个字母获取一个

现在我们有了维吉尼亚密钥的可能长度,我们可以使用这个信息一次解密一个子密钥。对于这个例子,让我们假设密钥长度是 4。如果我们不能破解这个密文,我们可以假设密钥长度为 2 或 8 再试一次。

因为密钥是循环加密明文的,所以密钥长度为 4 意味着从第一个字母开始,密文中的每四个字母使用第一个子密钥加密,从明文的第二个字母开始的每四个字母使用第二个子密钥加密,依此类推。使用这些信息,我们将从由同一个子密钥加密的字母的密文中形成字符串。首先,让我们确定如果我们从不同的字母开始,字符串中的第四个字母会是什么。然后我们将这些字母组合成一个字符串。在这些例子中,我们将每第四个字母加粗。

识别从第一个字母开始的每第四个字母:

PPQCAXQVEKGYBNKMAZUYBNGBALJONITSZMJYIMVRAGVOHTVRAUCTKSGDDWUOXITLAZUVAVVRAZCV
KBQPIWPOU

接下来,我们找到从第二个字母开始的第四个字母:

PPQCAXQVEKGYBNKMAZUYBNGBALJONITSZMJYIMVRAGVOHTVRAUCTKSGDDWUOXITLAZUVAVVRAZCV
KBQPIWPOU

然后我们从第三个字母和第四个字母开始做同样的事情,直到我们达到我们正在测试的子密钥的长度。表 20-3 显示了每次迭代的粗体字母组合字符串。

表 20-3: 每隔四个字母的字符串

起始字符串
第一个字母PAEBABANZIAHAKDXAAAKIU
第二个字母PXKNZNLIMMGTUSWIZVZBW
第三个字母QQGKUGJTJVVVCGUTUVCQP
第四个字母CVYMYBOSYRORTDOLVRVPO

利用频率分析破解每个子密钥

如果我们猜测了正确的密钥长度,那么我们在上一节中创建的四个字符串中的每一个都将使用一个子密钥进行加密。这意味着当用正确的子密钥解密字符串并进行频率分析时,解密的字母很可能具有高的英语频率匹配分数。以第一个字符串PAEBABANZIAHAKDXAAAKIU为例,让我们看看这个过程是如何工作的。

首先,我们使用第 18 章、vigenereCipher.decryptMessage()中的维吉尼亚解密函数对字符串解密 26 次(26 个可能的子密钥中的每一个一次)。然后我们使用第 19 章、freqAnalysis.englishFreqMatchScore()中的英文频率分析函数测试每个解密的字符串。在交互式 shell 中运行以下代码:

>>> import freqAnalysis, vigenereCipher
>>> for subkey in 'ABCDEFGHJIJKLMNOPQRSTUVWXYZ':
...   decryptedMessage = vigenereCipher.decryptMessage(subkey, 
        'PAEBABANZIAHAKDXAAAKIU')
...   print(subkey, decryptedMessage, 
        freqAnalysis.englishFreqMatchScore(decryptedMessage))
...
A PAEBABANZIAHAKDXAAAKIU 2
B OZDAZAZMYHZGZJCWZZZJHT 1
--snip--

表 20-4 显示了结果。

表 20-4: 每次解密的英文频率匹配得分

子密钥解密英语频率匹配分数
'A''PAEBABANZIAHAKDXAAAKIU'2
'B''OZDAZAZMYHZGZJCWZZZJHT'1
'C''NYCZYZYLXGYFYIBVYYYIGS'1
'D''MXBYXYXKWFXEXHAUXXXHFR'0
'E''LWAXWXWJVEWDWGZTWWWGEQ'1
'F''KVZWVWVIUDVCVFYSVVVFDP'0
'G''JUYVUVUHTCUBUEXRUUUECO'1
'H''ITXUTUTGSBTATDWQTTTDBN'1
'I''HSWTSTSFRASZSCVPSSSCAM'2
'J''GRVSRSREQZRYRBUORRRBZL'0
'K''FQURQRQDPYQXQATNQQQAYK'1
'L''EPTQPQPCOXPWPZSMPPPZXJ'0
'M''DOSPOPOBNWOVOYRLOOOYWI'1
'N''CNRONONAMVNUNXQKNNNXVH'2
'O''BMQNMNMZLUMTMWPJMMMWUG'1
'P''ALPMLMLYKTLSLVOILLLVTF'1
'Q''ZKOLKLKXJSKRKUNHKKKUSE'0
'R''YJNKJKJWIRJQJTMGJJJTRD'1
'S''XIMJIJIVHQIPISLFIIISQC'1
'T''WHLIHIHUGPHOHRKEHHHRPB'1
'U''VGKHGHGTFOGNGQJDGGGQOA'1
'V''UFJGFGFSENFMFPICFFFPNZ'1
'W''TEIFEFERDMELEOHBEEEOMY'2
'X''SDHEDEDQCLDKDNGADDDNLX'2
'Y''RCGDCDCPBKCJCMFZCCCMKW'0
'Z''QBFCBCBOAJBIBLEYBBBLJV'0

产生与英语最接近的频率匹配的解密的子密钥最有可能是真正的子密钥。在表 20-4 中,子项'A''I''N''W''X'导致第一串的最高频率匹配分数。请注意,这些分数通常很低,因为没有足够的密文来给我们一个大的文本示例,但对于这个例子来说,它们已经足够好了。

下一步是对其他三个字符串重复这个过程,以找到它们最可能的子项。表 20-5 显示了最终结果。

表 20-5: 最可能的子密钥为示例字符串

密文串最有可能的子项
PAEBABANZIAHAKDXAAAKIUAINWX
PXKNZNLIMMGTUSWIZVZBWIZ
QQGKUGJTJVVVCGUTUVCQPC
CVYMYBOSYRORTDOLVRVPOKNRVY

因为第一个子密钥有五个可能的子密钥,第二个子密钥有两个,第三个子密钥有一个,第四个子密钥有五个,所以组合的总数是 50(我们将所有可能的子密钥相乘得到5 × 2 × 1 × 5)。换句话说,我们需要暴力破解 50 个可能的密钥。但是这比暴力破解26 × 26 × 26 × 26(或 456,976)个可能的密钥要好得多,如果我们没有缩小可能的子项列表的话。如果维吉尼亚调更长,这种差异会变得更大!

暴力破解可能的密钥

为了暴力破解密钥,我们将尝试所有可能的子密钥组合。所有 50 种可能的子项组合如下所示:

Images

我们的维吉尼亚破解程序的最后一步将是在完整的密文上测试所有 50 个解密密钥,看哪一个产生可读的英语明文。这样做应该可以揭示PPQCA XQVEKG…密文的密钥是WICK

维吉尼亚破解程序的源代码

选择文件 -> 新文件,打开新文件编辑器窗口。确保detectEnglish.pyfreqAnalysis.pyvigenereCipher.pypyperclip.py文件与vigenereHacker.py文件在同一目录下。然后在文件编辑器中输入以下代码,保存为vigenereHacker.py。按F5运行程序。

这个程序中第 17 行的密文很难从书上复制。为了避免错别字,请从该书的网站www.nostarch.com/crackingcodes复制并粘贴。您可以使用本书网站上的在线比较工具来检查您的程序文本和本书中的程序文本之间的任何差异。

vigenereHacker.py

# Vigenere Cipher Hacker
# https://www.nostarch.com/crackingcodes/ (BSD Licensed)

import itertools, re
import vigenereCipher, pyperclip, freqAnalysis, detectEnglish

LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
MAX_KEY_LENGTH = 16 # Will not attempt keys longer than this.
NUM_MOST_FREQ_LETTERS = 4 # Attempt this many letters per subkey.
SILENT_MODE = False # If set to True, program doesn't print anything.
NONLETTERS_PATTERN = re.compile('[^A-Z]')


def main():
    # Instead of typing this ciphertext out, you can copy & paste it
    # from https://www.nostarch.com/crackingcodes/:
    ciphertext = """Adiz Avtzqeci Tmzubb wsa m Pmilqev halpqavtakuoi,
           lgouqdaf, kdmktsvmztsl, izr xoexghzr kkusitaaf. Vz wsa twbhdg
           ubalmmzhdad qz
           --snip--
           azmtmd'g widt ion bwnafz tzm Tcpsw wr Zjrva ivdcz eaigd yzmbo
           Tmzubb a kbmhptgzk dvrvwz wa efiohzd."""
    hackedMessage = hackVigenere(ciphertext)

    if hackedMessage != None:
        print('Copying hacked message to clipboard:')
        print(hackedMessage)
        pyperclip.copy(hackedMessage)
    else:
        print('Failed to hack encryption.')


def findRepeatSequencesSpacings(message):
    # Goes through the message and finds any 3- to 5-letter sequences
    # that are repeated. Returns a dict with the keys of the sequence and
    # values of a list of spacings (num of letters between the repeats).

    # Use a regular expression to remove non-letters from the message:
    message = NONLETTERS_PATTERN.sub('', message.upper())

    # Compile a list of seqLen-letter sequences found in the message:
    seqSpacings = {} # Keys are sequences; values are lists of int spacings.
    for seqLen in range(3, 6):
        for seqStart in range(len(message) - seqLen):
            # Determine what the sequence is and store it in seq:
            seq = message[seqStart:seqStart + seqLen]

            # Look for this sequence in the rest of the message:
            for i in range(seqStart + seqLen, len(message) - seqLen):
                if message[i:i + seqLen] == seq:
                    # Found a repeated sequence:
                    if seq not in seqSpacings:
                        seqSpacings[seq] = [] # Initialize blank list.

                    # Append the spacing distance between the repeated
                    # sequence and the original sequence:
                    seqSpacings[seq].append(i - seqStart)
    return seqSpacings


def getUsefulFactors(num):
    # Returns a list of useful factors of num. By "useful" we mean factors
    # less than MAX_KEY_LENGTH + 1 and not 1\. For example,
    # getUsefulFactors(144) returns [2, 3, 4, 6, 8, 9, 12, 16].

    if num < 2:
        return [] # Numbers less than 2 have no useful factors.

    factors = [] # The list of factors found.

    # When finding factors, you only need to check the integers up to
    # MAX_KEY_LENGTH:
    for i in range(2, MAX_KEY_LENGTH + 1): # Don't test 1: it's not useful.
        if num % i == 0:
            factors.append(i)
            otherFactor = int(num / i)
            if otherFactor < MAX_KEY_LENGTH + 1 and otherFactor != 1:
                factors.append(otherFactor)
    return list(set(factors)) # Remove duplicate factors.


def getItemAtIndexOne(x):
    return x[1]


def getMostCommonFactors(seqFactors):
    # First, get a count of how many times a factor occurs in seqFactors:
    factorCounts = {} # Key is a factor; value is how often it occurs.

    # seqFactors keys are sequences; values are lists of factors of the
    # spacings. seqFactors has a value like {'GFD': [2, 3, 4, 6, 9, 12,
    # 18, 23, 36, 46, 69, 92, 138, 207], 'ALW': [2, 3, 4, 6, ...], ...}.
    for seq in seqFactors:
        factorList = seqFactors[seq]
        for factor in factorList:
            if factor not in factorCounts:
                factorCounts[factor] = 0
            factorCounts[factor] += 1

    # Second, put the factor and its count into a tuple and make a list
    # of these tuples so we can sort them:
    factorsByCount = []
    for factor in factorCounts:
        # Exclude factors larger than MAX_KEY_LENGTH:
        if factor <= MAX_KEY_LENGTH:
            # factorsByCount is a list of tuples: (factor, factorCount).
            # factorsByCount has a value like [(3, 497), (2, 487), ...].
            factorsByCount.append( (factor, factorCounts[factor]) )

    # Sort the list by the factor count:
    factorsByCount.sort(key=getItemAtIndexOne, reverse=True)

    return factorsByCount


def kasiskiExamination(ciphertext):
    # Find out the sequences of 3 to 5 letters that occur multiple times
    # in the ciphertext. repeatedSeqSpacings has a value like
    # {'EXG': [192], 'NAF': [339, 972, 633], ... }:
    repeatedSeqSpacings = findRepeatSequencesSpacings(ciphertext)

    # (See getMostCommonFactors() for a description of seqFactors.)
    seqFactors = {}
    for seq in repeatedSeqSpacings:
        seqFactors[seq] = []
        for spacing in repeatedSeqSpacings[seq]:
            seqFactors[seq].extend(getUsefulFactors(spacing))

    # (See getMostCommonFactors() for a description of factorsByCount.)
    factorsByCount = getMostCommonFactors(seqFactors)

    # Now we extract the factor counts from factorsByCount and
    # put them in allLikelyKeyLengths so that they are easier to
    # use later:
    allLikelyKeyLengths = []
    for twoIntTuple in factorsByCount:
        allLikelyKeyLengths.append(twoIntTuple[0])

    return allLikelyKeyLengths


def getNthSubkeysLetters(nth, keyLength, message):
    # Returns every nth letter for each keyLength set of letters in text.
    # E.g. getNthSubkeysLetters(1, 3, 'ABCABCABC') returns 'AAA'
    #      getNthSubkeysLetters(2, 3, 'ABCABCABC') returns 'BBB'
    #      getNthSubkeysLetters(3, 3, 'ABCABCABC') returns 'CCC'
    #      getNthSubkeysLetters(1, 5, 'ABCDEFGHI') returns 'AF'

    # Use a regular expression to remove non-letters from the message:
    message = NONLETTERS_PATTERN.sub('', message.upper())

    i = nth - 1
    letters = []
    while i < len(message):
        letters.append(message[i])
        i += keyLength
    return ''.join(letters)


def attemptHackWithKeyLength(ciphertext, mostLikelyKeyLength):
    # Determine the most likely letters for each letter in the key:
    ciphertextUp = ciphertext.upper()
    # allFreqScores is a list of mostLikelyKeyLength number of lists.
    # These inner lists are the freqScores lists:
    allFreqScores = []
    for nth in range(1, mostLikelyKeyLength + 1):
        nthLetters = getNthSubkeysLetters(nth, mostLikelyKeyLength,
               ciphertextUp)

        # freqScores is a list of tuples like
        # [(<letter>, <Eng. Freq. match score>), ... ]
        # List is sorted by match score. Higher score means better match.
        # See the englishFreqMatchScore() comments in freqAnalysis.py.
        freqScores = []
        for possibleKey in LETTERS:
            decryptedText = vigenereCipher.decryptMessage(possibleKey,
                   nthLetters)
            keyAndFreqMatchTuple = (possibleKey,
                   freqAnalysis.englishFreqMatchScore(decryptedText))
            freqScores.append(keyAndFreqMatchTuple)
        # Sort by match score:
        freqScores.sort(key=getItemAtIndexOne, reverse=True)

        allFreqScores.append(freqScores[:NUM_MOST_FREQ_LETTERS])

    if not SILENT_MODE:
        for i in range(len(allFreqScores)):
            # Use i + 1 so the first letter is not called the "0th" letter:
            print('Possible letters for letter %s of the key: ' % (i + 1),
                   end='')
            for freqScore in allFreqScores[i]:
                print('%s ' % freqScore[0], end='')
            print() # Print a newline.

    # Try every combination of the most likely letters for each position
    # in the key:
    for indexes in itertools.product(range(NUM_MOST_FREQ_LETTERS),
           repeat=mostLikelyKeyLength):
        # Create a possible key from the letters in allFreqScores:
        possibleKey = ''
        for i in range(mostLikelyKeyLength):
            possibleKey += allFreqScores[i][indexes[i]][0]

        if not SILENT_MODE:
            print('Attempting with key: %s' % (possibleKey))

        decryptedText = vigenereCipher.decryptMessage(possibleKey,
               ciphertextUp)

        if detectEnglish.isEnglish(decryptedText):
            # Set the hacked ciphertext to the original casing:
            origCase = []
            for i in range(len(ciphertext)):
                if ciphertext[i].isupper():
                    origCase.append(decryptedText[i].upper())
                else:
                    origCase.append(decryptedText[i].lower())
            decryptedText = ''.join(origCase)

            # Check with user to see if the key has been found:
            print('Possible encryption hack with key %s:' % (possibleKey))
            print(decryptedText[:200]) # Only show first 200 characters.
            print()
            print('Enter D if done, anything else to continue hacking:')
            response = input('> ')

            if response.strip().upper().startswith('D'):
                return decryptedText

    # No English-looking decryption found, so return None:
    return None


def hackVigenere(ciphertext):
    # First, we need to do Kasiski examination to figure out what the
    # length of the ciphertext's encryption key is:
    allLikelyKeyLengths = kasiskiExamination(ciphertext)
    if not SILENT_MODE:
        keyLengthStr = ''
        for keyLength in allLikelyKeyLengths:
            keyLengthStr += '%s ' % (keyLength)
        print('Kasiski examination results say the most likely key lengths
               are: ' + keyLengthStr + '\n')
    hackedMessage = None
    for keyLength in allLikelyKeyLengths:
        if not SILENT_MODE:
            print('Attempting hack with key length %s (%s possible keys)...'
                   % (keyLength, NUM_MOST_FREQ_LETTERS ** keyLength))
        hackedMessage = attemptHackWithKeyLength(ciphertext, keyLength)
        if hackedMessage != None:
            break

    # If none of the key lengths found using Kasiski examination
    # worked, start brute-forcing through key lengths:
    if hackedMessage == None:
        if not SILENT_MODE:
            print('Unable to hack message with likely key length(s). Brute-
                   forcing key length...')
        for keyLength in range(1, MAX_KEY_LENGTH + 1):
            # Don't recheck key lengths already tried from Kasiski:
            if keyLength not in allLikelyKeyLengths:
                if not SILENT_MODE:
                    print('Attempting hack with key length %s (%s possible
                           keys)...' % (keyLength, NUM_MOST_FREQ_LETTERS **
                           keyLength))
                hackedMessage = attemptHackWithKeyLength(ciphertext,
                       keyLength)
                if hackedMessage != None:
                    break
    return hackedMessage


# If vigenereHacker.py is run (instead of imported as a module), call
# the main() function:
if __name__ == '__main__':
    main()

维吉尼亚破解程序的运行示例

当您运行vigenereHacker.py程序时,输出应该如下所示:

Kasiski examination results say the most likely key lengths are: 3 2 6 4 12
Attempting hack with key length 3 (27 possible keys)...
Possible letters for letter 1 of the key: A L M
Possible letters for letter 2 of the key: S N O
Possible letters for letter 3 of the key: V I Z
Attempting with key: ASV
Attempting with key: ASI
--snip--
Attempting with key: MOI
Attempting with key: MOZ
Attempting hack with key length 2 (9 possible keys)...
Possible letters for letter 1 of the key: O A E
Possible letters for letter 2 of the key: M S I
Attempting with key: OM
Attempting with key: OS
--snip--
Attempting with key: ES
Attempting with key: EI
Attempting hack with key length 6 (729 possible keys)...
Possible letters for letter 1 of the key: A E O
Possible letters for letter 2 of the key: S D G
Possible letters for letter 3 of the key: I V X
Possible letters for letter 4 of the key: M Z Q
Possible letters for letter 5 of the key: O B Z
Possible letters for letter 6 of the key: V I K
Attempting with key: ASIMOV
Possible encryption hack with key ASIMOV:
ALAN MATHISON TURING WAS A BRITISH MATHEMATICIAN, LOGICIAN, CRYPTANALYST, AND
COMPUTER SCIENTIST. HE WAS HIGHLY INFLUENTIAL IN THE DEVELOPMENT OF COMPUTER
SCIENCE, PROVIDING A FORMALISATION OF THE CON
Enter D for done, or just press Enter to continue hacking:
> d
Copying hacked message to clipboard:
Alan Mathison Turing was a British mathematician, logician, cryptanalyst, and
computer scientist. He was highly influential in the development of computer
--snip--

导入模块并设置main()函数

让我们看看维吉尼亚破解程序的源代码。破解程序导入了许多不同的模块,包括一个名为itertools的新模块,您将很快了解到更多信息:

# Vigenere Cipher Hacker
# https://www.nostarch.com/crackingcodes/ (BSD Licensed)

import itertools, re
import vigenereCipher, pyperclip, freqAnalysis, detectEnglish

此外,程序在第 7 行到第 11 行设置了几个常量,稍后当它们在程序中使用时我会解释。

破解程序的main()函数类似于之前破解程序中的main()函数:

def main():
    # Instead of typing this ciphertext out, you can copy & paste it
    # from https://www.nostarch.com/crackingcodes/:
    ciphertext = """Adiz Avtzqeci Tmzubb wsa m Pmilqev halpqavtakuoi,
           lgouqdaf, kdmktsvmztsl, izr xoexghzr kkusitaaf. Vz wsa twbhdg
           ubalmmzhdad qz
           --snip--
           azmtmd'g widt ion bwnafz tzm Tcpsw wr Zjrva ivdcz eaigd yzmbo
           Tmzubb a kbmhptgzk dvrvwz wa efiohzd."""
    hackedMessage = hackVigenere(ciphertext)

    if hackedMessage != None:
        print('Copying hacked message to clipboard:')
        print(hackedMessage)
        pyperclip.copy(hackedMessage)
    else:
        print('Failed to hack encryption.')

密文被传递给hackVigenere()函数,如果破解成功,该函数返回解密后的字符串,如果破解失败,则返回None值。如果成功,该程序将被破解的消息打印到屏幕上,并复制到剪贴板上。

寻找重复序列

findRepeatSequencesSpacings()函数通过定位message字符串中所有重复的字母序列并计算序列之间的间距来完成卡西斯基检查的第一步:

def findRepeatSequencesSpacings(message):
         --snip--
    # Use a regular expression to remove non-letters from the message:
    message = NONLETTERS_PATTERN.sub('', message.upper())

    # Compile a list of seqLen-letter sequences found in the message:
    seqSpacings = {} # Keys are sequences; values are lists of int spacings.

第 34 行将消息转换成大写,并使用sub()正则表达式方法从message中删除任何非字母字符。

第 37 行的seqSpacings字典保存重复的序列字符串作为它的密钥,并保存一个列表,其中的整数表示该序列的所有出现之间的字母数作为它的值。例如,如果我们将'PPQCAXQV...'示例字符串作为message传递,findRepeatSequenceSpacings()函数将返回{'VRA': [8, 24, 32], 'AZU': [48], 'YBN': [8]}

第 38 行的for循环通过查找message中的序列并计算重复序列之间的间隔来检查每个序列是否重复:

    for seqLen in range(3, 6):
        for seqStart in range(len(message) - seqLen):
            # Determine what the sequence is and store it in seq:
            seq = message[seqStart:seqStart + seqLen]

Images

图 20-2:来自消息的序列的值取决于seqStart中的值

在循环的第一次迭代中,代码找到正好三个字母长的序列。在下一次迭代中,它找到正好四个字母长的序列,然后是五个字母长的序列。您可以通过修改第 38 行的range(3, 6)调用来改变代码搜索的序列长度;然而,寻找长度为 3、4 和 5 的重复序列似乎对大多数密文都有效。原因是它们足够长,使得密文中的重复不太可能是巧合,但又足够短,使得重复很可能被发现。for循环当前检查的序列长度存储在seqLen中。

第 39 行的for循环将message分割成每个可能的长度为seqLen的子串。我们将使用这个for循环来确定切片的开始,并将message切片成一个长度为seqLen个字符的子串。例如,如果seqLen3并且消息是'PPQCAXQ',我们将希望从第一个索引开始,也就是0,并且切分三个字符以获得子串'PPQ'。然后,我们将转到下一个索引,即1,并切分三个字符以获得子串'PQC'。我们需要对每个索引都这样做,直到最后三个字符,这是相当于len(message) – seqLen的索引。这样做,你将得到如图 20-2 所示的序列。

第 39 行的for循环遍历到len(message) – seqLen的每个索引,并将当前索引分配给变量seqStart作为子串片的开始。有了起始索引后,第 41 行将seq变量设置为子字符串片。

我们将使用第 44 行的for循环在消息中搜索该片段的重复。

            # Look for this sequence in the rest of the message:
            for i in range(seqStart + seqLen, len(message) - seqLen):
                if message[i:i + seqLen] == seq:

第 44 行的for循环位于第 39 行的for循环内,并将i设置为message中长度为seqLen的每个可能序列的索引。这些索引从seqStart + seqLen开始,或者在当前seq中的序列之后,一直到len(message) - seqLen,这是可以找到长度为seqLen的序列的最后一个索引。例如,如果message'PPQCAXQVEKGYBNKMAZUYBN'seqStart11seqLen3,第 41 行将seq设置为'YBN'for循环将从索引14开始查看message

第 45 行的表达式message[i:i + seqLen]计算出message的子串,与seq进行比较,检查子串是否是seq的重复。如果是,第 46 到 52 行计算间距并将其添加到seqSpacings字典中。在第一次迭代中,第 45 行比较'KMA'seq,然后在下一次迭代中比较'MAZ'seq,然后在下一次迭代中比较'AZU'seq,依此类推。当i19时,第 45 行发现'YBN'等于seq,执行运行第 46 至 52 行:

                    # Found a repeated sequence:
                    if seq not in seqSpacings:
                        seqSpacings[seq] = [] # Initialize blank list.

                    # Append the spacing distance between the repeated
                    # sequence and the original sequence:
                    seqSpacings[seq].append(i - seqStart)

第 47 和 48 行检查seq变量是否作为键存在于seqSpacings中。如果没有,seqSpacings[seq]被设置为一个键,其值为一个空白列表。

message[i:i + seqLen]的序列和在message[seqStart:seqStart+seqLen]的原始序列之间的字母数就是i - seqStart。注意iseqStart是冒号前的开始索引。因此,i - seqStart计算的整数是两个序列之间的字母数,我们将其添加到存储在seqSpacings[seq]的列表中。

当所有这些for循环完成后,seqSpacings字典应该包含长度为 3、4 和 5 的每个重复序列,以及重复序列之间的字母数。seqSpacings字典从第 53 行的findRepeatSequencesSpacings()返回:

    return seqSpacings

现在,您已经看到了程序如何通过查找密文中的重复序列并计算它们之间的字母数量来执行卡西斯基检查的第一步,让我们看看程序如何进行卡西斯基检查的下一步。

计算间隔系数

回想一下,卡西斯基检查的下一步包括寻找间距的因数。我们正在寻找长度在2MAX_KEY_LENGTH之间的因数。为此,我们将创建getUsefulFactors()函数,该函数采用一个num参数并返回一个只包含符合该标准的因数的列表。

def getUsefulFactors(num):
         --snip--
    if num < 2:
        return [] # Numbers less than 2 have no useful factors.

    factors = [] # The list of factors found.

第 61 行检查num小于2的特殊情况。在这种情况下,第 62 行返回空列表,因为如果num小于2,它将没有任何有用的因数。

如果num大于2,我们需要计算num的所有因数,并将它们存储在一个列表中。在第 64 行,我们创建一个名为factors的空列表来存储因数。

第 68 行的for循环遍历从2MAX_KEY_LENGTH的整数,包括MAX_KEY_LENGTH中的值。记住,因为range()导致for循环迭代到但不包括第二个参数,所以我们传递MAX_KEY_LENGTH + 1以便包含MAX_KEY_LENGTH。这个循环找到了num的所有因数。

    for i in range(2, MAX_KEY_LENGTH + 1): # Don't test 1: it's not useful.
        if num % i == 0:
            factors.append(i)
            otherFactor = int(num / i)

第 69 行测试num % i是否等于0;如果是,我们知道inum平均分,没有余数,也就是说inum的一个因数。在这种情况下,第 70 行将i添加到factors变量的因数列表中。因为我们知道num / i也必须平分num,所以第 71 行将它的整数形式存储在otherFactor中。(记住/操作符总是计算浮点值,比如21 / 7计算浮点值3.0而不是整数3。)如果结果值是1,程序不将它包含在factors列表中,所以第 72 行检查这种情况:

            if otherFactor < MAX_KEY_LENGTH + 1 and otherFactor != 1:
                factors.append(otherFactor)

如果它不是1,第 73 行追加该值。我们排除了1,因为如果维吉尼亚密钥的长度为 1,那么维吉尼亚密码将与凯撒密码没有什么不同!

set()函数删除重复

回想一下,作为卡西斯基检查的一部分,我们需要知道最常见的因数,因为最常见的因数几乎肯定是维吉尼亚密钥的长度。然而,在我们分析每个因数的频率之前,我们需要使用set()函数从factors列表中删除任何重复的因数。例如,如果getUsefulFactors()被传递给num参数的9,那么9 % 3 == 0将是True,并且iotherFactor都将被附加到factors。但是iint(num / i)都等于3,所以3会被追加到列表中两次。为了防止重复的数字,我们可以将列表传递给set(),它返回一个列表作为设置的数据类型。集合数据类型类似于列表数据类型,除了集合值只能包含唯一值。

您可以将任何列表值传递给set()函数,以获得一个没有任何重复值的集合值。相反,如果您将一个集合值传递给list(),它将返回该集合的列表值版本。要查看这方面的示例,请在交互式 shell 中输入以下内容:

>>> set([1, 2, 3, 3, 4])
set([1, 2, 3, 4])
>>> spam = list(set([2, 2, 2, 'cats', 2, 2]))
>>> spam
[2, 'cats']

当列表转换为集合时,任何重复的列表值都将被删除。即使将从列表转换的集合重新转换为列表,它也不会有任何重复的值。

去除重复因数并排序列表

第 74 行将factors中的列表值传递给set()以删除任何重复的因数:

    return list(set(factors)) # Remove duplicate factors.

第 77 行的函数getItemAtIndexOne()几乎与你在第 19 章中编写的freqAnalysis.py程序中的getItemAtIndexZero(参见第 268 页上的获取元组的第一个成员):

def getItemAtIndexOne(x):
    return x[1]

该函数将在程序的后面被传递给sort()以基于被排序的项目的索引1处的项目进行排序。

寻找最常见的因数

为了找到最常见的因数,也就是最可能的密钥长度,我们需要编写getMostCommonFactors()函数,从第 81 行开始。

def getMostCommonFactors(seqFactors):
   # First, get a count of how many times a factor occurs in seqFactors:
   factorCounts = {} # Key is a factor; value is how often it occurs.

第 81 行的seqFactors参数接受一个使用kasiskiExamination()函数创建的字典值,我将很快对此进行解释。该字典将序列字符串作为键,将整数因数列表作为每个键的值。(这些是findRepeatSequencesSpacings ()之前返回的间隔整数的因数。)例如,seqFactors可能包含如下所示的字典值:

{'VRA': [8, 2, 4, 2, 3, 4, 6, 8, 12, 16, 8, 2, 4], 'AZU': [2, 3, 4, 6, 8, 12,
 16, 24], 'YBN': [8, 2, 4]}

getMostCommonFactors()函数对seqFactors中最常见的因数进行排序,从最频繁出现的到最不频繁出现的,并将它们作为双整数元组列表返回。元组中的第一个整数是因数,第二个整数是它在seqFactors中出现了多少次。

例如,getMostCommonFactors()可能返回一个列表值,如下所示:

[(3, 556), (2, 541), (6, 529), (4, 331), (12, 325), (8, 171), (9, 156), (16,
105), (5, 98), (11, 86), (10, 84), (15, 84), (7, 83), (14, 68), (13, 52)]

这个列表显示,在传递给getMostCommonFactors ()seqFactors字典中,因数 3 出现了 556 次,因数 2 出现了 541 次,因数 6 出现了 529 次,以此类推。注意,3 出现在列表的第一位,因为它是最常见的因数;13 是最不常见的因数,因此在列表中排在最后。

对于getMostCommonFactors()的第一步,我们将在第 83 行设置factorCounts字典,我们将使用它来存储每个因数的计数。factorCounts的键将是因数,与键相关联的值将是那些因数的计数。

接下来,第 88 行的for循环遍历seqFactors中的每个序列,在每次迭代中将其存储在一个名为seq的变量中。用于seqseqFactors中的因数列表存储在第 89 行名为factorList的变量中:

    for seq in seqFactors:
        factorList = seqFactors[seq]
        for factor in factorList:
            if factor not in factorCounts:
                factorCounts[factor] = 0
            factorCounts[factor] += 1

该列表中的因数在第 90 行用for循环。如果一个因数在factorCounts中不作为关键字存在,它会在第 92 行添加一个值0。第 93 行增加了factorCounts[factor],这是factorCounts中因数的值。

对于getMostCommonFactors()的第二步,我们需要按照计数对factorCounts字典中的值进行排序。但是因为字典值没有排序,我们必须首先将字典转换成两个整数元组的列表。(我们在freqanalysis.py模块的getFrequencyOrder()函数中的第 19 章做了类似的事情。)我们将这个列表值存储在一个名为factorsByCount的变量中,这个变量从第 97 行的空列表开始:

    factorsByCount = []
    for factor in factorCounts:
        # Exclude factors larger than MAX_KEY_LENGTH:
        if factor <= MAX_KEY_LENGTH:
            # factorsByCount is a list of tuples: (factor, factorCount).
            # factorsByCount has a value like [(3, 497), (2, 487), ...].
            factorsByCount.append( (factor, factorCounts[factor]) )

然后,第 98 行的for循环遍历factorCounts中的每个因数,只有当因数小于或等于MAX_KEY_LENGTH时,才将这个(factor, factorCounts[factor])元组追加到factorsByCount列表中。

for循环完成将所有元组添加到factorsByCount之后,第 106 行将factorsByCount排序为getMostCommonFactors()函数的最后一步。

    factorsByCount.sort(key=getItemAtIndexOne, reverse=True)

    return factorsByCount

因为getItemAtIndexOne函数是为key关键字参数传递的,而True是为reverse关键字参数传递的,所以列表按因数计数降序排序。第 108 行返回factorsByCount中的排序列表,它应该指出哪些因数出现得最频繁,因此最有可能是维吉尼亚密钥长度。

寻找最可能的密钥长度

在我们弄清楚密文可能的子密钥是什么之前,我们需要知道有多少个子密钥。也就是我们需要知道密钥的长度。第 111 行的kasiskiExamination()函数返回给定ciphertext参数的最可能的密钥长度列表。

def kasiskiExamination(ciphertext):
         --snip--
    repeatedSeqSpacings = findRepeatSequencesSpacings(ciphertext)

密钥长度是列表中的整数;列表中的第一个整数是最可能的密钥长度,第二个整数是第二可能的长度,依此类推。

寻找密钥长度的第一步是找到密文中重复序列之间的间隔。这是从函数findRepeatSequencesSpacings()返回的,作为一个字典,序列字符串作为它的键,一个列表,间隔作为整数作为它的值。第 294 页的寻找重复序列中已经描述了findRepeatSequencesSpacings函数。

在继续下一行代码之前,您需要了解一下extend()列表方法。

append()列表方法

当你需要在列表末尾添加多个值时,有一种比在循环中调用append()更简单的方法。与append()列表方法类似,extend()列表方法可以将值添加到列表的末尾。当传递一个列表时,append()方法将整个列表作为一个条目添加到另一个列表的末尾,如下所示:

>>> spam = ['cat', 'dog', 'mouse']
>>> eggs = [1, 2, 3]
>>> spam.append(eggs)
>>> spam
['cat', 'dog', 'mouse', [1, 2, 3]]

相反,extend()方法将列表参数中的每一项添加到列表的末尾。在交互式 shell 中输入以下内容以查看示例:

>>> spam = ['cat', 'dog', 'mouse']
>>> eggs = [1, 2, 3]
>>> spam.extend(eggs)
>>> spam
['cat', 'dog', 'mouse', 1, 2, 3]

如您所见,eggs ( 123)中的所有值都作为离散项追加到spam中。

扩展repeatedSeqSpacings字典

虽然repeatedSeqSpacings是一个将序列字符串映射到整数间隔列表的字典,但我们实际上需要一个将序列字符串映射到那些整数间隔的因数列表的字典。(原因请参见第 283 页的上的获取间距系数。)第 118 到 122 行是这样做的:

    seqFactors = {}
    for seq in repeatedSeqSpacings:
        seqFactors[seq] = []
        for spacing in repeatedSeqSpacings[seq]:
            seqFactors[seq].extend(getUsefulFactors(spacing))

第 118 行从seqFactors中的一个空字典开始。第 119 行的for循环遍历字典repeatedSeqSpacings中的每个键,这是一个序列字符串。对于每个键,第 120 行将一个空白列表设置为seqFactors中的值。

第 121 行的for循环通过将每个间隔整数传递给一个getUsefulFactors()调用来迭代所有的间隔整数。使用extend()方法将从getUsefulFactors()返回的列表中的每个项目添加到seqFactors[seq]。当所有的for循环完成后,seqFactors应该是一个将序列字符串映射到整数间隔的因数列表的字典。这允许我们有间距的因数,而不仅仅是间距。

第 125 行将seqFactors字典传递给getMostCommonFactors()函数,并返回一个双整数元组列表,其第一个整数代表因数,第二个整数显示该因数在seqFactors中出现的频率。然后元组被存储在factorsByCount中。

    factorsByCount = getMostCommonFactors(seqFactors)

但是我们希望kasiskiExamination()函数返回整数因数的列表,而不是包含因数和因数出现频率的元组列表。因为这些因数在factorsByCount中存储为双整数元组列表的第一项,所以我们需要从元组中提取这些因数,并将它们放在一个单独的列表中。

factorsByCount中获取因数

第 130 到 134 行在allLikelyKeyLengths中存储了单独的因数列表。

    allLikelyKeyLengths = []
    for twoIntTuple in factorsByCount:
        allLikelyKeyLengths.append(twoIntTuple[0])

    return allLikelyKeyLengths

第 131 行的for循环遍历factorsByCount中的每个元组,并将元组的索引0项附加到allLikelyKeyLengths的末尾。在这个for循环完成后,allLikelyKeyLengths变量应该包含factorsByCount中的所有整数因数,这些因数作为一个列表从kasiskiExamination()返回。

尽管我们现在有能力找到消息加密时可能使用的密钥长度,但我们需要能够从消息中分离出使用相同子密钥加密的字母。回想一下,用密钥'XYZ'加密'THEDOGANDTHECAT'最终会使用密钥中的'X'来加密索引036912处的消息字母。因为来自原始英文消息的这些字母是用相同的子密钥('X')加密的,所以解密的文本应该具有类似于英文的字母频率计数。我们可以使用这些信息来找出子密钥。

获取用相同子密钥加密的字母

为了从用相同的子密钥加密的密文中提取出字母,我们需要编写一个函数,使用消息的第一、第二或第n个字母创建一个字符串。在函数有了起始索引、密钥长度和传递给它的消息之后,第一步是使用第 145 行的正则表达式对象及其sub()方法从message中删除非字母字符。

正则表达式在第 230 页的“使用正则表达式查找字符”中讨论。

这个只有字母的字符串然后作为新值存储在message中:

def getNthSubkeysLetters(nth, keyLength, message):
         --snip--
    message = NONLETTERS_PATTERN.sub('', message.upper())

接下来,我们通过将字母字符串附加到一个列表来构建一个字符串,然后使用join()将列表合并成一个字符串:

    i = nth - 1
    letters = []
    while i < len(message):
        letters.append(message[i])
        i += keyLength
    return ''.join(letters)

i变量指向message中要添加到字符串构建列表中的字母的索引,该列表存储在名为letters的变量中。i变量从第 147 行的值nth - 1开始,letters变量从第 148 行的空白列表开始。

只要i小于message的长度,第 149 行的while循环就会继续运行。在每次迭代中,message[i]处的字母被添加到letters中的列表中。然后通过将第 151 行上的keyLength加到i来更新i以指向子密钥中的下一个字母。

这个循环结束后,第 152 行将letters列表中的单字母字符串值连接成一个字符串,这个字符串从getNthSubkeysLetters()返回。

现在我们可以取出用相同的子密钥加密的字母,我们可以使用getNthSubkeysLetters()尝试用一些潜在的密钥长度解密。

尝试用可能的密钥长度解密

回想一下,kasiskiExamination()函数并不保证返回维吉尼亚密钥的实际长度,而是返回几个可能长度的列表,按照最可能的密钥长度到最不可能的密钥长度排序。如果代码确定了错误的密钥长度,它将使用不同的密钥长度重试。当传递了密文和确定的密钥长度时,attemptHackWithKeyLength()函数会这样做。如果成功,该函数将返回被攻击消息的字符串。如果破解失败,函数返回None

黑客代码只对大写字母起作用,但是我们想要返回任何解密的字符串和它原来的大小写,所以我们需要保留原来的字符串。为此,我们将大写形式的ciphertext字符串存储在第 157 行名为ciphertextUp的单独变量中。

def attemptHackWithKeyLength(ciphertext, mostLikelyKeyLength):
    # Determine the most likely letters for each letter in the key:
    ciphertextUp = ciphertext.upper()

如果我们假设mostLikelyKeyLength中的值是正确的密钥长度,黑客算法为每个子密钥调用getNthSubkeysLetters(),然后在 26 个可能的字母中寻找产生解密文本的字母,其字母频率与该子密钥的英语字母频率最接近。

首先,在第 160 行的allFreqScores中存储一个空列表,它将存储由freqAnalysis.englishFreqMatchScore()返回的频率匹配分数:

    allFreqScores = []
    for nth in range(1, mostLikelyKeyLength + 1):
        nthLetters = getNthSubkeysLetters(nth, mostLikelyKeyLength,
               ciphertextUp)

第 161 行的for循环将nth变量设置为从1mostLikelyKeyLength值的每个整数。回想一下,当range()被传递了两个参数时,范围上升到第二个参数,但不包括第二个参数。将+ 1放入代码中,使mostLikelyKeyLength中的整数值包含在返回的范围对象中。

n个子项的字母从第 162 行的getNthSubkeysLetters()返回。

接下来,我们需要用所有 26 个可能的子密钥来解密第n个子密钥的字母,看看哪一个产生了类似英语的字母频率。英语频率匹配分数列表存储在名为freqScores的变量列表中。这个变量从第 168 行的空列表开始,然后第 169 行的for循环遍历LETTERS字符串中的 26 个大写字母:

        freqScores = []
        for possibleKey in LETTERS:
            decryptedText = vigenereCipher.decryptMessage(possibleKey,
                   nthLetters)

possibleKey值通过调用第 170 行的vigenereCipher.decryptMessage()来解密密文。在possibleKey中的子密钥只有一个字母,但是在nthLetters中的字符串只由来自message的字母组成,如果代码已经正确地确定了密钥长度,那么这些字母已经用那个子密钥加密了。

然后,解密后的文本被传递给freqAnalysis.englishFreqMatchScore(),以查看decryptedText中的字母频率与常规英语的字母频率有多接近。正如你在第 19 章中了解到的,返回值是一个介于012之间的整数:回想一下,更大的数字意味着更接近的匹配。

第 171 行将这个频率匹配分数和用于解密的密钥放入一个元组,并将其存储在keyAndFreqMatchTuple变量中。这个元组被附加到第 172 行的freqScores的末尾:

            keyAndFreqMatchTuple = (possibleKey,
                   freqAnalysis.englishFreqMatchScore(decryptedText))
            freqScores.append(keyAndFreqMatchTuple)

在第 169 行的for循环完成之后,freqScores列表应该包含 26 个关键字和频率匹配分数元组:26 个子关键字中的每一个都有一个元组。我们需要对这个列表进行排序,使具有最大英语频率匹配分数的元组排在最前面。这意味着我们希望按照索引1的值对freqScores中的元组进行反向(降序)排序。

我们在freqScores列表上调用sort()方法,为key关键字参数传递函数值getItemAtIndexOne。注意,我们不是在调用函数,这可以从缺少括号中看出。值True被传递给reverse关键字参数,以便按降序排序。

        freqScores.sort(key=getItemAtIndexOne, reverse=True)

最初,NUM_MOST_FREQ_LETTERS常量被设置为第 9 行的整数值4。在以逆序对freqScores中的元组排序之后,第 176 行将仅包含前三个元组或者具有三个最高英语频率匹配分数的元组的列表附加到allFreqScores。因此,allFreqScores[0]包含第一个子密钥的频率分数,allFreqScores[1]包含第二子密钥的频率分数,依此类推。

        allFreqScores.append(freqScores[:NUM_MOST_FREQ_LETTERS])

在第 161 行的for循环完成后,allFreqScores应该包含与mostLikelyKeyLength中的整数值相等的多个列表值。例如,如果mostLikelyKeyLength3allFreqScores将是三个列表的列表。第一个列表值保存完整维吉尼亚密钥的第一个子密钥的前三个最高匹配子密钥的元组。第二个列表值保存完整维吉尼亚密钥的第二个子密钥的前三个最高匹配子密钥的元组,依此类推。

最初,如果我们想要暴力破解整个维吉尼亚密钥,可能的密钥数量将是 26 的密钥长度次方。例如,如果密钥是长度为 7 的 ROSEBUD,则可能有26 ** 7,即 8,031,810,176 个密钥。

但是检查英语频率匹配有助于确定每个子项的四个最可能的字母。继续 ROSEBUD 的例子,这意味着我们只需要检查4 ** 7,或者 16384 个可能的密钥,这比 80 亿个可能的密钥有了巨大的改进!

print()end关键字参数

接下来,我们希望向用户打印输出。为此,我们使用print(),但是将一个参数传递给一个我们以前没有使用过的可选参数。每当调用print()函数时,它都会将传递给它的字符串和换行符一起打印到屏幕上。要在字符串末尾打印其他内容而不是换行符,我们可以为print()函数的end关键字参数指定字符串。在交互式 shell 中输入以下内容,查看如何使用print()函数的end关键字参数:

   >>> def printStuff():
           print('Hello', end='\n') # ➊
           print('Howdy', end='') # ➋
           print('Greetings', end='XYZ') # ➌
           print('Goodbye')
   >>> printStuff()
   Hello
   HowdyGreetingsXYZGoodbye

通过end='\n'正常打印字符串 ➊。但是,传递end='' ➋ 或end='XYZ' ➌ 会替换通常的换行符,因此后续的print()调用不会显示在新的一行上。

以静默模式运行程序或向用户打印信息

此时,我们想知道哪个字母是每个子项的前三个候选字母。如果SILENT_MODE常量在程序的早期被设置为False,第 178 到 184 行的代码会将allFreqScores中的值打印到屏幕上:

    if not SILENT_MODE:
        for i in range(len(allFreqScores)):
            # Use i + 1 so the first letter is not called the "0th" letter:
            print('Possible letters for letter %s of the key: ' % (i + 1),
                   end='')
            for freqScore in allFreqScores[i]:
                print('%s ' % freqScore[0], end='')
            print() # Print a newline.

如果SILENT_MODE被设置为True,那么if语句块中的代码将被跳过。

我们现在已经将子项的数量减少到一个足够小的数量,我们可以暴力破解所有子项。接下来,您将学习如何使用itertools .product()函数来生成所有可能的子机码组合以进行暴力破解。

寻找子项的可能组合

现在我们有了可能的子项,我们需要将它们放在一起以找到整个项。问题是,即使我们已经为每个子项找到了字母,最有可能的字母实际上可能不是正确的字母。相反,第二个或第三个最有可能的字母可能是正确的子密钥字母。这意味着我们不能将每个子项最可能的字母组合成一个密钥:我们需要尝试不同的可能字母组合来找到正确的密钥。

vigenereHacker.py程序使用itertools.product()函数来测试所有可能的子密钥组合。

itertools.product()函数

itertools.product()函数产生列表或类似列表的值中所有可能的项目组合,比如字符串或元组。这样的项目组合被称为笛卡尔积,这也是该函数得名的原因。该函数返回一个itertools结果对象值,该值也可以通过传递给list()转换成一个列表。在交互式 shell 中输入以下内容以查看示例:

   >>> import itertools
   >>> itertools.product('ABC', repeat=4)
   <itertools.product object at 0x02C40170> # ➊
   >>> list(itertools.product('ABC', repeat=4))
   [('A', 'A', 'A', 'A'), ('A', 'A', 'A', 'B'), ('A', 'A', 'A', 'C'), ('A', 'A',
   'B', 'A'), ('A', 'A', 'B', 'B'), ('A', 'A', 'B', 'C'), ('A', 'A', 'C', 'A'),
   ('A', 'A', 'C', 'B'), ('A', 'A', 'C', 'C'), ('A', 'B', 'A', 'A'), ('A', 'B',
   'A', 'B'), ('A', 'B', 'A', 'C'), ('A', 'B', 'B', 'A'), ('A', 'B', 'B', 'B'),
   --snip--
   ('C', 'B', 'C', 'B'), ('C', 'B', 'C', 'C'), ('C', 'C', 'A', 'A'), ('C', 'C',
   'A', 'B'), ('C', 'C', 'A', 'C'), ('C', 'C', 'B', 'A'), ('C', 'C', 'B', 'B'),
   ('C', 'C', 'B', 'C'), ('C', 'C', 'C', 'A'), ('C', 'C', 'C', 'B'), ('C', 'C',
   'C', 'C')]

'ABC'repeat关键字参数的整数4传递给itertools .product()会返回一个itertools结果对象 ➊,当转换为一个列表时,该对象包含四个值的元组,每个值都有'A''B''C'的可能组合。这产生了一个总共有3 ** 4或 81 个元组的列表。

还可以将列表值传递给itertools.product()以及一些类似于列表的值,比如从range()返回的范围对象。在交互式 shell 中输入以下内容,看看当列表和类似列表的对象被传递给这个函数时会发生什么:

>>> import itertools
>>> list(itertools.product(range(8), repeat=5))
[(0, 0, 0, 0, 0), (0, 0, 0, 0, 1), (0, 0, 0, 0, 2), (0, 0, 0, 0, 3), (0, 0, 0,
0, 4), (0, 0, 0, 0, 5), (0, 0, 0, 0, 6), (0, 0, 0, 0, 7), (0, 0, 0, 1, 0), (0,
0, 0, 1, 1), (0, 0, 0, 1, 2), (0, 0, 0, 1, 3), (0, 0, 0, 1, 4),
--snip--
(7, 7, 7, 6, 6), (7, 7, 7, 6, 7), (7, 7, 7, 7, 0), (7, 7, 7, 7, 1), (7, 7, 7,
7, 2), (7, 7, 7, 7, 3), (7, 7, 7, 7, 4), (7, 7, 7, 7, 5), (7, 7, 7, 7, 6), (7,
7, 7, 7, 7)]

当从range(8)返回的范围对象和repeat关键字参数的5一起传递给itertools.product()时,它生成一个包含五个值的元组的列表,整数范围从07

我们不能仅仅传递给itertools.product()一个潜在子密钥字母的列表,因为该函数创建相同值的组合,并且每个子密钥可能有不同的潜在字母。相反,因为我们的子密钥存储在allFreqScores的元组中,我们将通过索引值访问这些字母,索引值的范围从 0 到我们想要尝试的字母数减 1。我们知道每个元组中的字母数等于NUM_MOST_FREQ_LETTERS,因为我们只在前面的第 176 行存储了每个元组中的潜在字母数。因此,我们需要访问的索引范围是从0NUM_MOST_FREQ_LETTERS,这是我们将传递给itertools .product()的内容。我们还将传递itertools.product()一个可能的密钥长度作为第二个参数,创建与潜在密钥长度一样长的元组。

例如,如果我们想只尝试每个子密钥的前三个最可能的字母(由NUM_MOST_FREQ_LETTERS决定)作为一个可能有五个字母长的密钥,itertools.product()产生的第一个值将是(0, 0, 0, 0, 0)。下一个值将是(0, 0, 0, 0, 1),然后是(0, 0, 0, 0, 2),并且将生成值,直到到达(2, 2, 2, 2, 2)。五值元组中的每个整数代表一个对allFreqScores的索引。

访问allFreqScores中的子项

allFreqScores中的值是一个列表,它包含每个子项最可能的字母以及它们的频率匹配分数。为了查看这个列表是如何工作的,让我们在空闲时创建一个假设的allFreqScores值。例如,allFreqScores可能是这样一个六个字母的密钥,我们为每个子项找到了四个最可能的字母:

>>> allFreqScores = [[('A', 9), ('E', 5), ('O', 4), ('P', 4)], [('S', 10),
('D', 4), ('G', 4), ('H', 4)], [('I', 11), ('V', 4), ('X', 4), ('B', 3)],
[('M', 10), ('Z', 5), ('Q', 4), ('A', 3)], [('O', 11), ('B', 4), ('Z', 4),
('A', 3)], [('V', 10), ('I', 5), ('K', 5), ('Z', 4)]]

这看起来可能很复杂,但是我们可以通过索引深入到列表和元组的特定值。当在索引处访问allFreqScores时,它求值单个子密钥的可能字母的元组列表以及它们的频率匹配分数。例如,allFreqScores[0]具有第一个子密钥的元组列表以及每个潜在子密钥的频率匹配分数,allFreqScores[1]具有第二个子密钥的元组列表和频率匹配分数,等等:

>>> allFreqScores[0]
[('A', 9), ('E', 5), ('O', 4), ('P', 4)]
>>> allFreqScores[1]
[('S', 10), ('D', 4), ('G', 4), ('H', 4)]

您还可以通过添加额外的索引引用来访问每个子项的每个可能字母元组。例如,如果我们访问allFreqScores[1][0]、来自allFreqScores [1][1]的第二个最可能的字母,等等,我们将得到最可能是第二个子密钥的字母的元组及其频率匹配分数:

>>> allFreqScores[1][0]
('S', 10)
>>> allFreqScores[1][1]
('D', 4)

因为这些值是元组,所以我们需要访问元组中的第一个值,以获得可能的字母,而不包括其频率匹配分值。每个字母都存储在元组的第一个索引中,因此我们将使用allFreqScores[1][0][0]来访问第一个子密钥最可能的字母,allFreqScores [1][1][0]来访问第二子密钥最可能的字母,依此类推:

>>> allFreqScores[1][0][0]
'S'
>>> allFreqScores[1][1][0]
'D'

一旦您能够在allFreqScores中访问潜在的子项,您需要将它们组合起来以找到潜在的项。

使用itertools.product()创建子项组合

itertools.product()产生的元组每个代表一个密钥,其中元组中的位置对应于我们在allFreqScores中访问的第一个索引,元组中的整数代表我们在allFreqScores中访问的第二个索引。

因为我们之前将NUM_MOST_FREQ_LETTERS常量设置为4,所以第 188 行的itertools.product(range(NUM_MOST_FREQ_LETTERS), repeat=mostLikelyKeyLength)导致for循环有一个整数元组(从03,代表indexes变量的每个子项的四个最可能的字母:

    for indexes in itertools.product(range(NUM_MOST_FREQ_LETTERS),
           repeat=mostLikelyKeyLength):
        # Create a possible key from the letters in allFreqScores:
        possibleKey = ''
        for i in range(mostLikelyKeyLength):
            possibleKey += allFreqScores[i][indexes[i]][0]

我们使用indexes构造完整的维吉尼亚密钥,它在每次迭代中接受由itertools.product()创建的一个元组的值。这个密钥从第 190 行的空字符串开始,第 191 行的for循环遍历从0mostLikelyKeyLength的整数,为每个元组构造一个密钥。

随着for循环的每次迭代中i变量的变化,indexes[i]的值就是我们想要在allFreqScores[i]中使用的元组的索引。这就是为什么allFreqScores[i][indexes[i]]计算出我们想要的正确的元组。当我们拥有正确的元组时,我们需要访问该元组中的索引0来获取子密钥字母。

如果SILENT_MODEFalse,第 195 行打印由第 191 行的for循环创建的密钥:

        if not SILENT_MODE:
            print('Attempting with key: %s' % (possibleKey))

现在我们有了一个完整的维吉尼亚密钥,第 197 到 208 行解密密文并检查解密的文本是否是可读的英语。如果是,程序将它打印到屏幕上,让用户确认它确实是英语,以检查误报。

用正确的大小写打印解密文本

因为decryptedText是大写的,所以第 201 到 207 行通过将decryptedText中字母的大写或小写形式追加到origCase列表来构建一个新字符串:

        decryptedText = vigenereCipher.decryptMessage(possibleKey,
               ciphertextUp)

        if detectEnglish.isEnglish(decryptedText):
            # Set the hacked ciphertext to the original casing:
            origCase = []
            for i in range(len(ciphertext)):
                if ciphertext[i].isupper():
                    origCase.append(decryptedText[i].upper())
                else:
                    origCase.append(decryptedText[i].lower())
            decryptedText = ''.join(origCase)

第 202 行上的for循环遍历ciphertext管柱中的每个索引,与ciphertextUp不同,其具有ciphertext的原始套管。如果ciphertext[i]是大写的,则decryptedText[i]的大写形式被附加到origCase上。否则,会附加小写形式的decryptedText[i]。然后将origCase中的列表连接到第 207 行,成为decryptedText的新值。

下面几行代码将解密输出打印给用户,以检查是否找到了密钥:

            print('Possible encryption hack with key %s:' % (possibleKey))
            print(decryptedText[:200]) # Only show first 200 characters.
            print()
            print('Enter D if done, anything else to continue hacking:')
            response = input('> ')

            if response.strip().upper().startswith('D'):
                return decryptedText

大小写正确的解密文本被打印到屏幕上,供用户确认是英语。如果用户输入'D',函数返回decryptedText字符串。

否则,如果没有一个解密看起来像英语,则破解失败,并且返回None值:

    return None

返回破解消息

最后,我们定义的所有函数都将由hackVigenere ()函数使用,该函数接受一个密文字符串作为参数,并返回被攻击的消息(如果攻击成功)或None(如果没有成功)。首先用kasiskiExamination()获得可能的密钥长度:

def hackVigenere(ciphertext):
    # First, we need to do Kasiski examination to figure out what the
    # length of the ciphertext's encryption key is:
    allLikelyKeyLengths = kasiskiExamination(ciphertext)

hackVignere()函数的输出取决于程序是否在SILENT_MODE中:

    if not SILENT_MODE:
        keyLengthStr = ''
        for keyLength in allLikelyKeyLengths:
            keyLengthStr += '%s ' % (keyLength)
        print('Kasiski examination results say the most likely key lengths
               are: ' + keyLengthStr + '\n')

如果SILENT_MODEFalse,则可能的密钥长度被打印到屏幕上。

接下来,我们需要为每个密钥长度找到可能的子密钥字母。我们将使用另一个循环来实现这一点,该循环试图用我们找到的每个密钥长度来破解密码。

找到潜在密钥时跳出循环

我们希望代码继续循环并检查密钥长度,直到找到可能正确的密钥长度。当它发现一个似乎正确的密钥长度时,我们将使用一个break语句来停止循环。

类似于在循环中使用continue语句返回到循环的开始,在循环中使用break语句立即退出循环。当程序执行中断循环时,它会在循环结束后立即移动到第一行代码。每当程序找到一个可能正确的密钥,并需要用户确认该密钥是否正确时,我们就会跳出这个循环。

    hackedMessage = None
    for keyLength in allLikelyKeyLengths:
        if not SILENT_MODE:
            print('Attempting hack with key length %s (%s possible keys)...'
                   % (keyLength, NUM_MOST_FREQ_LETTERS ** keyLength))
        hackedMessage = attemptHackWithKeyLength(ciphertext, keyLength)
        if hackedMessage != None:
            break

对于每个可能的密钥长度,代码在第 236 行调用attemptHackWithKeyLength()。如果attemptHackWithKeyLength()没有返回None,破解就成功了,程序执行应该在第 238 行跳出for循环。

暴力破解其他所有密钥长度

如果黑客没有通过kasiskiExamination()返回的所有可能的密钥长度,当第 242 行的if语句执行时,hackedMessage被设置为None。在这种情况下,所有长度达到MAX_KEY_LENGTH其他键都会被尝试。如果卡西斯基检查未能计算出正确的密钥长度,我们可以使用第 245 行的for循环暴力破解密钥长度:

    if hackedMessage == None:
        if not SILENT_MODE:
            print('Unable to hack message with likely key length(s). Brute-
                   forcing key length...')
        for keyLength in range(1, MAX_KEY_LENGTH + 1):
            # Don't recheck key lengths already tried from Kasiski:
            if keyLength not in allLikelyKeyLengths:
                if not SILENT_MODE:
                    print('Attempting hack with key length %s (%s possible
                           keys)...' % (keyLength, NUM_MOST_FREQ_LETTERS **
                             keyLength))
                hackedMessage = attemptHackWithKeyLength(ciphertext,
                       keyLength)
                if hackedMessage != None:
                    break

第 245 行开始一个for循环,只要不在allLikelyKeyLengths中,就为keyLength(范围从1MAX_KEY_LENGTH)的每个值调用attemptHackWithKeyLength()。原因是allLikelyKeyLengths中的密钥长度已经在第 233 到 238 行的代码中试过了。

最后,hackedMessage中的值在第 253 行返回:

    return hackedMessage

调用main()函数

如果这个程序是自己运行的,而不是由另一个程序导入的,那么第 258 和 259 行调用main()函数:

# If vigenereHacker.py is run (instead of imported as a module), call
# the main() function:
if __name__ == '__main__':
    main()

这就是完整的维吉尼亚破解程序。是否成功取决于密文的特征。原始明文的字母频率越接近常规英语的字母频率,明文越长,破解程序就越有可能起作用。

修改破解程序的常量

如果破解程序不起作用,我们可以修改一些细节。我们在第 8 到 10 行设置的三个常量影响破解程序的运行方式:

MAX_KEY_LENGTH = 16 # Will not attempt keys longer than this.
NUM_MOST_FREQ_LETTERS = 4 # Attempt this many letters per subkey.
SILENT_MODE = False # If set to True, program doesn't print anything.

如果维吉尼亚密钥比第 8 行MAX_KEY_LENGTH中的整数长,破解程序将无法找到正确的密钥。如果破解程序未能破解密文,请尝试增加该值并再次运行该程序。

请记住,尝试破解一个不正确的短密钥长度需要很短的时间。但是,如果MAX_KEY_LENGTH设置得非常高,并且kasiskiExamination()函数错误地认为密钥长度可能是一个巨大的整数,程序可能会花费几个小时,甚至几个月的时间,试图使用错误的密钥长度破解密文。

为了防止这种情况,第 9 行的NUM_MOST_FREQ_LETTERS限制了每个子密钥尝试的可能字母的数量。通过增加这个值,破解程序会尝试更多的密钥,如果freqAnalysis.englishFreqMatchScore()对于原始明文消息不准确,您可能需要这样做,但这也会导致程序变慢。并且将NUM_MOST_FREQ_LETTERS设置为26会导致程序完全跳过缩小每个子项的可能字母数!

对于MAX_KEY_LENGTHNUM_MOST_FREQ_LETTERS来说,较小的值执行起来更快,但破解密码成功的可能性更小,而较大的值执行起来更慢,但成功的可能性更大。

最后,为了提高程序的速度,你可以在第 10 行设置SILENT_MODETrue,这样程序就不会浪费时间把信息打印到屏幕上。虽然您的计算机可以快速执行计算,但在屏幕上显示字符可能相对较慢。不打印信息的缺点是,在程序完全运行完之前,您不会知道程序运行得如何。

总结

破解维吉尼亚密码需要遵循几个详细的步骤。此外,破解程序的许多部分可能会失败:例如,用于加密的维吉尼亚密钥可能比MAX_KEY_LENGTH长,或者英语频率匹配函数收到的结果不准确,因为明文不符合正常的字母频率,或者明文中有太多字典文件中没有的单词,而isEnglish()没有将其识别为英语。

当您发现破解程序可能失败的不同方式时,您可以更改代码来处理这种情况。但是本书中的破解程序做得很好,将数十亿或数万亿个可能的密钥减少到仅仅数千个。

然而,有一个技巧可以让维吉尼亚尔密码在数学上无法破解,不管你的计算机有多强大,你的破解程序有多聪明。你将在第 21 章中了解到这个被称为一次性密码本的技巧。

练习题

练习题的答案可以在本书的网站www.nostarch.com/crackingcodes找到。

  1. 什么是字典攻击?

2.卡西斯基对密文的检查揭示了什么?

  1. 使用set()函数将列表值转换为设定值时会发生哪两种变化?

  2. 如果spam变量包含['cat', 'dog', 'mouse', 'dog'],则该列表中有四项。从list(set(spam))返回的清单有多少项?

  3. 下面的代码打印了什么?

    print('Hello', end='')
    print('World')
    

二十一、一次性密码本

原文:inventwithpython.com/cracking/ch…

“我已经研究过一千次了,”沃特豪斯说,“我能想到的唯一解释是,他们正在将他们的信息转换成大的二进制数,然后将它们与其他大的二进制数组合在一起——很可能是一次性密码本。”“在这种情况下,你的项目是注定要失败的,”艾伦说,“因为你不能打破一次性密码本。”

——尼尔·斯蒂芬森, Cryptonomicon

Images

在这一章中,你将了解到一个不可能破解的密码,无论你的计算机有多强大,你花了多少时间试图破解它,或者你是一个多么聪明的黑客。它被称为一次性密码本,好消息是我们不必编写新的程序来使用它!你在第 18 章中编写的维吉尼亚密码程序无需任何修改就可以实现这种密码。但是一次性密码板很不方便经常使用,所以它经常被保留给最高机密的信息。

本章涵盖的主题

不可破解的一次性密码本

两次性密码本是维吉尼亚密码

不可破解的一次性密码本

一次性密码本密码是一种维吉尼亚密码,当密钥满足以下条件时,它将变得无法破解:

  1. 它正好与加密消息一样长。

  2. 它是由真正随机的符号组成的。

  3. 它只用于一次,不会再用于任何其他消息。

通过遵循这三条规则,你可以使你的加密信息免受任何密码分析者的攻击。即使有无限的计算能力,密码也无法破解。

一次性密码本的密钥被称为密码本,因为这些密钥曾经被印在纸上。在最上面的一张纸被使用后,它会被从便笺簿上撕下来,露出下一个要使用的密钥。通常,会生成一个大型的一次性密码本密钥列表并亲自共享,这些密钥会标记特定的日期。例如,如果我们在 10 月 31 日收到来自合作者的消息,我们只需浏览一次性密码本列表,以找到当天的相应密钥。

使密钥长度等于消息长度

为了理解为什么一次性密码是不可破解的,我们来考虑一下是什么使得常规的维吉尼亚密码容易受到攻击。回想一下,维吉尼亚密码破解程序通过使用频率分析来工作。但是,如果密钥与消息的长度相同,则每个明文字母的子密钥是唯一的,这意味着每个明文字母可以以相等的概率被加密成任何密文字母。

例如,要加密信息,如果你想在这里生存,你必须知道你的毛巾在哪里,我们去掉空格和标点符号,得到一个有 55 个字母的信息。要使用一次性密码本加密此消息,我们需要一个 55 个字母长的密钥。使用示例密钥kcqyzhepxautiqekxejmoretztzhtrwwqdylbttvejmedbsanybppxqik对字符串进行加密,会得到密文shomtdecqtilchzsixghyikdfnnmacewrzlghraqqvhzguerplbqc,如图 21-1 所示。

Images

图 21-1:使用一次性密码本加密一个示例消息

现在想象一个密码分析者得到了密文(SHOM TDEC...).他们怎么能攻击密码呢?暴力破解密钥是行不通的,因为即使对计算机来说,密钥也太多了。密钥的数量将等于消息中字母总数的 26 次方。因此,如果在我们的示例中消息有 55 个字母,那么总共有26 ** 55,即 666091878431395624153823182526730590376250379528249805353030484209594192。

即使密码分析员有一台足够强大的计算机来尝试所有的密钥,它仍然无法破解一次性密码本,因为对于任何密文,所有可能的明文消息都有相同的概率。

例如,密文 SHOMTDEC...可以很容易地从完全不同的明文中得到相同长度的字母数,例如奥西里斯的神话在古埃及宗教中很重要,使用密钥zakavkxolfqdlzhwsqjbzmtwmmnakwuwerwexdcuywksgorghnnedvtcp加密,如图 21-2 所示。

Images

图 21-2:使用不同的密钥对不同的示例消息进行加密,但产生与之前相同的密文

我们能够破解任何加密的原因是,我们知道通常只有一个密钥可以将信息解密成合理的英语。但是我们在前面的例子中已经看到,相同的密文可能是由两个完全不同的明文消息组成的。当我们使用一次性密码本时,密码分析者没有办法判断哪个是原始消息。事实上,任何长度正好为 55 个字母的可读的英语明文消息都有可能是原始明文。某个密钥能把密文解密成可读的英文,不代表它就是原来的加密密钥。

因为任何英文明文都可以被用来以相同的可能性创建密文,所以不可能破解使用一次性密码本加密的消息。

制作真正随机的密钥

正如你在第九章中了解到的,Python 内置的random模块并不产生真正的随机数。它们是用一种算法计算出来的,这种算法产生的数字看起来只是随机的,这在大多数情况下已经足够好了。然而,为了使一次性密码本起作用,密码本必须从真正随机的来源产生;否则,它就失去了数学上完美的保密性。

Python 3.6 和更高版本有secrets模块,它使用操作系统的真正随机数源(通常从随机事件中收集,比如用户击键之间的时间)。函数secrets. randbelow ()可以返回介于0和之间的真随机数,但不包括传递给它的参数,如下例所示:

>>> import secrets
>>> secrets.randbelow(10)
2
>>> secrets.randbelow(10)
0
>>> secrets.randbelow(10)
6

secrets中的函数比random中的函数慢,所以在不需要真随机性时,优先选择random中的函数。您还可以使用secrets.choice()函数,该函数从传递给它的字符串或列表中随机选择一个值,如下例所示:

>>> import secrets
>>> secrets.choice('ABCDEFGHIJKLMNOPQRSTUVWXYZ')
'R'
>>> secrets.choice(['cat', 'dog', 'mouse'])
'dog'

例如,要创建一个长度为 55 个字符的真正随机的一次性密码本,请使用以下代码:

>>> import secrets
>>> otp = ''
>>> for i in range(55):
        otp += secrets.choice('ABCDEFGHIJKLMNOPQRSTUVWXYZ')

>>> otp
'MVOVAAYDPELIRNRUZNNQHDNSOUWWNWPJUPIUAIMKFKNHQANIIYCHHDC'

使用一次性密码本时,我们还必须记住一个细节。让我们检查一下为什么我们需要避免多次使用同一个一次性密码本。

两次性密码本

两次性密码本密码是指使用同一个一次性密码本密钥对两个不同的消息进行加密。这造成了加密中的弱点。

如前所述,仅仅因为一个密钥将一次性密码本密文解密成可读的英文并不意味着它就是正确的密钥。然而,当你对两个不同的信息使用同一个密钥时,你就给了黑客重要的信息。如果您使用相同的密钥加密两条消息,而黑客找到了一个密钥,该密钥将第一条密文解密为可读的英语,但将第二条消息解密为随机垃圾文本,则黑客将知道他们找到的密钥一定不是原始密钥。事实上,很有可能只有一个密钥将两个消息都解密成英文,您将在下一节看到。

如果黑客只有两条消息中的一条,那条消息仍然是完全加密的。但是我们必须始终假设我们所有的加密信息都被黑客和政府截获了。否则,我们一开始就不会费心加密消息。记住香农的格言很重要:敌人了解系统!这包括你所有的密文。

为什么两次性密码本是维吉尼亚密码

你已经学会了如何破解维吉尼亚密码。如果我们可以证明两次填充密码和维吉尼亚密码是一样的,我们就可以用破解维吉尼亚密码的相同技术来证明它是可破解的。

为了解释为什么两次性密码本就像维吉尼亚密码一样是可破解的,让我们回顾一下维吉尼亚密码在加密长度超过密钥的消息时是如何工作的。当我们用完了密钥中用于加密的字母时,我们返回到密钥的第一个字母并继续加密。例如,要用 10 个字母的密钥(如YZNMPZXYXY)加密 20 个字母的消息(如BLUE IODINE INBOUND CAT),前 10 个字母(BLUE IODINE)用YZNMPZXYXY加密,然后接下来的 10 个字母(INBOUND CAT)也用YZNMPZXYXY加密。图 21-3 显示了这种环绕效果。

Images

图 21-3:维吉尼亚尔密码的环绕效应

使用一次性密码本密码,假设 10 个字母的消息BLUE IODINE是使用一次性密码本密钥YZNMPZXYXY加密的。然后密码学家错误地用相同的一次性密钥YZNMPZXYXY加密了第二个 10 个字母的消息INBOUND CAT,如图 21-4 所示。

Images

图 21-4:使用一次性密码本密钥加密明文产生的密文与维吉尼亚密码相同。

当我们将图 21-3 (ZKHQXNAGKCGMOAJMAAXR)中所示的维吉尼亚密码的密文与图 21-4 (ZKHQXNAGKC GMOAJMAAXR)中所示的一次性密码的密文进行比较时,我们可以看到它们是完全相同的。这意味着,因为两次密码本密码与维吉尼亚密码具有相同的属性,所以我们可以使用相同的技术来破解它!

总结

简而言之,一次性密码本(one-time pad)是一种通过使用与消息长度相同、真正随机且仅使用一次的密钥来使维吉尼亚密码加密不受破解的方法。当这三个条件都满足时,一次性密码本就不可能破了。不过因为用起来太不方便了,所以没有用于日常加密。通常,一次性密码本会亲自分发,并包含一系列密钥。但要确保这份名单不会落入坏人之手!

练习题

练习题的答案可以在本书的网站www.nostarch.com/crackingcodes找到。

  1. 为什么本章没有介绍一次性密码本程序?

  2. 两次性密码本相当于哪个密码?

  3. 使用两倍于明文消息长度的密钥会使一次性密码本加倍安全吗?