Python 密码破解指南:15~17

274 阅读1小时+

协议:CC BY-NC-SA 4.0

译者:飞龙

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

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

十五、破解仿射密码

原文:inventwithpython.com/cracking/ch…

直到一个文明在包括数学、统计学和语言学在内的几个学科中达到足够复杂的学术水平,密码分析才能被发明出来。

——西蒙·辛格,《密码之书》

Images

在第 14 章中,您了解到仿射密码仅限于几千个密钥,这意味着我们可以轻松地对其进行暴力攻击。在这一章中,你将学习如何编写一个程序来破解仿射密码加密的信息。

本章涵盖的主题

  • 指数运算符(**)

  • continue语句

仿射密码破解程序的源代码

选择文件 -> 新文件,打开新文件编辑器窗口。在文件编辑器中输入以下代码,然后保存为affineHacker.py。手动输入myMessage变量的字符串可能有些棘手,所以你可以从www.nostarch.com/crackingcodesaffineHacker.py文件中复制并粘贴它以节省时间。确保dictionary.txt以及pyperclip.pyaffinicipher.pydetectEnglish.pycryptomath.pyaffinihacker.py在同一个目录下。

affineHacker.py

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

import pyperclip, affineCipher, detectEnglish, cryptomath

SILENT_MODE = False

def main():
    # You might want to copy & paste this text from the source code at
    # https://www.nostarch.com/crackingcodes/.
    myMessage = """5QG9ol3La6QI93!xQxaia6faQL9QdaQG1!!axQARLa!!A
          uaRLQADQALQG93!xQxaGaAfaQ1QX3o1RQARL9Qda!AafARuQLX1LQALQI1
          iQX3o1RN"Q-5!1RQP36ARu"""

    hackedMessage = hackAffine(myMessage)

    if hackedMessage != None:
        # The plaintext is displayed on the screen. For the convenience of
        # the user, we copy the text of the code to the clipboard:
        print('Copying hacked message to clipboard:')
        print(hackedMessage)
        pyperclip.copy(hackedMessage)
    else:
        print('Failed to hack encryption.')


def hackAffine(message):
    print('Hacking...')

    # Python programs can be stopped at any time by pressing Ctrl-C (on
    # Windows) or Ctrl-D (on macOS and Linux):
    print('(Press Ctrl-C or Ctrl-D to quit at any time.)')

    # Brute-force by looping through every possible key:
    for key in range(len(affineCipher.SYMBOLS) ** 2):
        keyA = affineCipher.getKeyParts(key)[0]
        if cryptomath.gcd(keyA, len(affineCipher.SYMBOLS)) != 1:
            continue

        decryptedText = affineCipher.decryptMessage(key, message)
        if not SILENT_MODE:
            print('Tried Key %s... (%s)' % (key, decryptedText[:40]))

        if detectEnglish.isEnglish(decryptedText):
            # Check with the user if the decrypted key has been found:
            print()
            print('Possible encryption hack:')
            print('Key: %s' % (key))
            print('Decrypted message: ' + decryptedText[:200])
            print()
            print('Enter D for done, or just press Enter to continue
                  hacking:')
            response = input('> ')

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


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

仿射密码破解程序的示例运行

在文件编辑器中按F5,运行affineHacker.py程序;输出应该如下所示:

Hacking...
(Press Ctrl-C or Ctrl-D to quit at any time.)
Tried Key 95... (U&'<3dJ^Gjx'-3^MS'Sj0jxuj'G3'%j'<mMMjS'g)
Tried Key 96... (T%&;2cI]Fiw&,2]LR&Ri/iwti&F2&$i&;lLLiR&f)
Tried Key 97... (S$%:1bH\Ehv%+1\KQ%Qh.hvsh%E1%#h%:kKKhQ%e)
--snip--
Tried Key 2190... (?^=!-+.32#0=5-3*"="#1#04#=2-= #=!~**#"=')
Tried Key 2191... (' ^BNLOTSDQ^VNTKC^CDRDQUD^SN^AD^[email protected]^H)
Tried Key 2192... ("A computer would deserve to be called i)
Possible encryption hack:
Key: 2192
Decrypted message: "A computer would deserve to be called intelligent if it
could deceive a human into believing that it was human." -Alan Turing
Enter D for done, or just press Enter to continue hacking:
> d
Copying hacked message to clipboard:
"A computer would deserve to be called intelligent if it could deceive a human
into believing that it was human." –Alan Turing

让我们仔细看看仿射密码破解程序是如何工作的。

设置模块、常量和main()函数

仿射密码破解程序有 60 行长,因为我们已经写了它使用的大部分代码。第 4 行导入了我们在前几章中创建的模块:

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

import pyperclip, affineCipher, detectEnglish, cryptomath

SILENT_MODE = False

当您运行仿射密码破解程序时,您会看到它在通过所有可能的解密时产生大量输出。然而,打印所有这些输出会降低程序的速度。如果你想加速程序,将第 6 行的SILENT_MODE变量设置为True来阻止它打印所有这些消息。

接下来,我们设置main()函数:

def main():
    # You might want to copy & paste this text from the source code at
    # https://www.nostarch.com/crackingcodes/.
    myMessage = """5QG9ol3La6QI93!xQxaia6faQL9QdaQG1!!axQARLa!!A
          uaRLQADQALQG93!xQxaGaAfaQ1QX3o1RQARL9Qda!AafARuQLX1LQALQI1
          iQX3o1RN"Q-5!1RQP36ARu"""

    hackedMessage = hackAffine(myMessage)

被攻击的密文作为一个字符串存储在第 11 行的myMessage中,这个字符串被传递给hackAffine()函数,我们将在下一节中研究这个函数。如果密文被破解,则该调用的返回值是原始消息的字符串,如果破解失败,则返回值是None值。

第 15 到 22 行的代码检查hackedMessage是否被设置为None:

    if hackedMessage != None:
        # The plaintext is displayed on the screen. For the convenience of
        # the user, we copy the text of the code to the clipboard:
        print('Copying hacked message to clipboard:')
        print(hackedMessage)
        pyperclip.copy(hackedMessage)
    else:
        print('Failed to hack encryption.')

如果hackedMessage不等于None,消息将打印到屏幕的第 19 行,并复制到剪贴板的第 20 行。否则,程序会简单地向用户输出反馈,告诉他们无法破解密文。让我们仔细看看hackAffine()函数是如何工作的。

hackAffine()函数

hackAffine()函数从第 25 行开始,包含解密代码。它首先为用户打印一些说明:

def hackAffine(message):
    print('Hacking...')

    # Python programs can be stopped at any time by pressing Ctrl-C (on
    # Windows) or Ctrl-D (on macOS and Linux):
    print('(Press Ctrl-C or Ctrl-D to quit at any time.)')

解密过程可能需要一段时间,所以如果用户想提前退出程序,可以按下ctrl+C(在 Windows 上)或ctrl+D(在 macOS 和 Linux 上)。

在我们继续余下的代码之前,您需要了解指数运算符。

指数运算符

为了理解仿射密码破解程序(除了基本的+-*///运算符之外),您需要知道的一个有用的数学运算符是指数运算符**)。指数运算符将一个数字提升到另一个数字的幂。例如,在 Python 中,2 的 5 次方是2 ** 5。这相当于 2 乘以自身 5 倍:2 * 2 * 2 * 2 * 2。两个表达式2 ** 52 * 2 * 2 * 2 * 2都计算整数32

在交互式 shell 中输入以下内容,看看**操作符是如何工作的:

>>> 5 ** 2
25
>>> 2 ** 5
32
>>> 123 ** 10
792594609605189126649

表达式5 ** 2的计算结果为25,因为5乘以自身等于25。同样地,2 ** 5返回32,因为2乘以自身五次的结果是32

让我们回到源代码,看看**操作符在程序中做了什么。

计算可能的密钥总数

第 33 行使用**操作符来计算可能的密钥的总数:

    # Brute-force by looping through every possible key:
    for key in range(len(affineCipher.SYMBOLS) ** 2):
        keyA = affineCipher.getKeyParts(key)[0]

我们知道密钥 A 最多有len(affineCipher.SYMBOLS)个可能的整数,密钥 B 最多有len(affineCipher.SYMBOLS)个可能的整数。为了得到所有可能的密钥,我们将这些值相乘。因为我们将同一个值本身相乘,所以我们可以在表达式len(affineCipher.SYMBOLS) ** 2中使用**操作符。

第 34 行调用我们在affinicipher.py中使用的getKeyParts()函数,将一个整数密钥分成两个整数。在这个例子中,我们使用函数来获取我们正在测试的密钥的一部分。回想一下,这个函数调用的返回值是两个整数的元组:一个用于密钥 A,一个用于密钥 B。第 34 行通过将[0]放在hackAffine()函数调用之后,将元组的第一个整数存储在keyA中。

例如,affineCipher.getKeyParts(key)[0]对元组和索引(42, 22)[0]求值,然后索引(42, 22)[0]对元组的索引0处的值42求值。这只是得到了键值的一部分,并将它存储在变量keyA中。密钥 B 部分(返回的元组中的第二个值)被忽略,因为我们不需要密钥 B 来计算密钥 A 是否有效。第 35 和 36 行检查keyA是否是仿射密码的有效密钥 A,如果不是,程序继续到下一个密钥进行尝试。为了理解执行如何返回到循环的开始,您需要了解一下continue语句。

continue语句

continue语句单独使用continue关键字,不带任何参数。我们在whilefor循环中使用continue语句。当一条continue语句执行时,程序执行立即跳转到下一次迭代的循环开始处。当程序执行到循环块的末尾时,也会发生这种情况。但是一个continue语句使得程序执行在到达循环结束之前跳回到循环的开始。

在交互式 shell 中输入以下内容:

>>> for i in range(3):
...   print(i)
...   print('Hello!')
...
0
Hello!
1
Hello!
2
Hello!

for循环遍历range对象,并且i中的值变成从03的每个整数,但不包括0。在每次迭代中,print('Hello!')函数调用在屏幕上显示Hello!

现在对比一下下一个例子中的for循环,除了在print('Hello!')行之前有一个continue语句之外,下一个例子与上一个例子相同。

>>> for i in range(3):
...   print(i)
...   continue
...   print('Hello!')
...
0
1
2

请注意,Hello!永远不会被打印,因为continue语句导致程序执行跳回到下一次迭代的for循环的起点,并且执行永远不会到达print('Hello!')行。

一个continue语句通常被放在一个if语句的块中,以便在某些条件下,在循环的开始处继续执行。让我们回到我们的代码,看看它是如何使用continue语句根据使用的密钥跳过执行的。

使用continue跳过代码

在源代码中,第 35 行使用cryptomath模块中的gcd()函数来确定密钥 A 对于符号集大小是否互质:

        if cryptomath.gcd(keyA, len(affineCipher.SYMBOLS)) != 1:
            continue

回想一下,如果两个数的最大公约数(GCD)是 1,那么这两个数就是互质的。如果密钥 A 和符号集大小不是互质的,则第 35 行上的条件是True并且执行第 36 行上的continue语句。这会导致程序执行跳回到下一次迭代的循环起点。结果,如果密钥无效,程序跳过对第 38 行的decryptMessage()的调用,继续尝试其他密钥,直到找到正确的密钥。

当程序找到正确的密钥时,通过用第 38 行的密钥调用decryptMessage()来解密消息:

        decryptedText = affineCipher.decryptMessage(key, message)
        if not SILENT_MODE:
            print('Tried Key %s... (%s)' % (key, decryptedText[:40]))

如果SILENT_MODE被设置为False,则Tried Key消息被打印在屏幕上,但是如果它被设置为True,则跳过第 40 行上的print()调用。

接下来,第 42 行使用来自detectEnglish模块的isEnglish()函数来检查解密的消息是否被识别为英语:

        if detectEnglish.isEnglish(decryptedText):
            # Check with the user if the decrypted key has been found:
            print()
            print('Possible encryption hack:')
            print('Key: %s' % (key))
            print('Decrypted message: ' + decryptedText[:200])
            print()

如果使用了错误的解密密钥,解密后的消息将看起来像随机字符,并且isEnglish()将返回False。但是如果解密后的信息被识别为可读的英语(按照isEnglish()函数的标准),程序会显示给用户。

我们显示一段被识别为英语的解密消息,因为isEnglish()函数可能会错误地将文本识别为英语,即使它没有找到正确的密钥。如果用户决定这确实是正确的解密,他们可以键入D ,然后按Enter

            print('Enter D for done, or just press Enter to continue
                  hacking:')
            response = input('> ')

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

否则,用户只需按下回车键即可从input()调用中返回一个空字符串,而hackAffine()函数将继续尝试更多按密钥。

从第 54 行开头的缩进可以看出,这一行是在第 33 行的for循环完成后执行的:

    return None

如果for循环结束并到达第 54 行,那么它已经遍历了所有可能的解密密钥,但没有找到正确的密钥。在这一点上,hackAffine()函数返回None值,表示它没有成功破解密文。

如果程序找到了正确的密钥,执行将会从第 53 行的函数返回,而不会到达第 54 行。

调用main()函数

如果我们将affineHacker.py作为程序运行,那么特殊的__name__变量将被设置为字符串'__main__'而不是'affineHacker'。在这种情况下,我们调用main()函数。

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

仿射密码破解程序到此结束。

总结

这一章相当短,因为它没有介绍任何新的黑客技术。正如你所看到的,只要可能的密钥的数量只有几千个,那么用不了多久,计算机就会对每一个可能的密钥进行暴力破解,并使用isEnglish()函数来搜索正确的密钥。

您学习了指数运算符(**),它将一个数提升到另一个数的幂。您还学习了如何使用continue语句将程序执行发送回循环的开始,而不是等到执行到达块的末尾。

方便的是,我们已经在affineCipher.pydetectEnglish.pycryptomath.py中为仿射密码黑客编写了很多代码。函数技巧帮助我们在程序中重用代码。

在第 16 章中,你将学习简单的替换密码,这是计算机无法暴力破解的。这个密码可能的密钥数超过万亿万亿!在我们的有生之年,一台笔记本电脑不可能通过这些密钥的一部分,这使得密码对暴力攻击免疫。

练习题

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

  1. 2 ** 5求值为什么?

  2. 6 ** 2求值为什么?

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

    for i in range(5):
        if i == 2:
            continue
        print(i)
    
  4. 如果另一个程序运行import affineHacker,是否会调用affineHacker.pymain()函数?

十六、编程实现简单的替换密码

原文:inventwithpython.com/cracking/ch…

“互联网是人类有史以来发明的最解放的工具,也是最好的监控工具。不是非此即彼。都是。”

——约翰·佩里·巴洛,电子前沿基金会联合创始人

Images

在第 15 章中,你了解到仿射密码有大约 1000 个可能的密钥,但是计算机仍然可以轻易地破解所有的密钥。我们需要一种密码,它有如此多的可能密钥,以至于任何计算机都无法强行破解它们。

简单替换密码就是这样一种密码,它可以有效地抵御暴力攻击,因为它有大量可能的密钥。即使你的计算机每秒钟可以尝试一万亿个密钥,它仍然需要 1200 万年来尝试每一个密钥!在本章中,你将编写一个程序来实现简单的替换密码,并学习一些有用的 Python 函数和字符串方法。

本章涵盖的主题

  • sort()列表方法

  • 消除字符串中的重复字符

  • 包装函数

  • isupper()islower()字符串方法

简单替换密码的工作原理

为了实现简单的替换密码,我们选择一个随机的字母来加密字母表中的每个字母,每个字母只使用一次。简单替换密码的密钥总是一串随机排列的 26 个字母。对于简单替换密码,有 403,291,461,126,605,635,584,000,000 种不同的可能密钥排序。好多密钥啊!更重要的是,这个数字是如此之大,以至于不可能暴力破解。(要了解这个数字是如何计算出来的,请前往www.nostarch.com/crackingcodes。)

先用纸笔试试简单的代换密码。对于这个例子,我们将加密消息“拂晓时的攻击”使用密钥VJZBGNFEPLITMXDWKQUCRYAHSO。首先,写出字母表中的字母和每个字母下面对应的密钥,如图 16-1 。

Images

图 16-1:示例密钥的加密字母表

要加密消息,找到顶行明文中的字母,并用底行中的字母替换它。A加密到VT加密到CC加密到Z,以此类推。所以这条信息Attack at dawn.加密为Vccvzi vc bvax.

要解密加密的消息,请在底行的密文中找到该字母,并用顶行的相应字母替换它。V解密到AC解密到TZ解密到C,以此类推。

与底行移动但保持字母顺序的凯撒密码不同,在简单替换密码中,底行完全被打乱。这导致更多可能的密钥,这是使用简单替换密码的巨大优势。缺点是密钥有 26 个字符长,比较难记。您可能需要记下密钥,但是如果您这样做了,请确保没有其他人会读到它!

简单替换密码程序的源代码

选择文件 -> 新文件,打开新文件编辑器窗口。在文件编辑器中输入以下代码,保存为simpleSubCipher.py。确保将pyperclip.py文件放在与simpleSubCipher.py文件相同的目录中。按F5运行程序。

简单子 Cipher.py

# Simple Substitution Cipher
# https://www.nostarch.com/crackingcodes/ (BSD Licensed)

import pyperclip, sys, random


LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'

def main():
    myMessage = 'If a man is offered a fact which goes against his
          instincts, he will scrutinize it closely, and unless the evidence
          is overwhelming, he will refuse to believe it. If, on the other
          hand, he is offered something which affords a reason for acting
          in accordance to his instincts, he will accept it even on the
          slightest evidence. The origin of myths is explained in this way.
          -Bertrand Russell'
    myKey = 'LFWOAYUISVKMNXPBDCRJTQEGHZ'
    myMode = 'encrypt' # Set to 'encrypt' or 'decrypt'.

    if keyIsValid(myKey):
        sys.exit('There is an error in the key or symbol set.')
    if myMode == 'encrypt':
        translated = encryptMessage(myKey, myMessage)
    elif myMode == 'decrypt':
        translated = decryptMessage(myKey, myMessage)
    print('Using key %s' % (myKey))
    print('The %sed message is:' % (myMode))
    print(translated)
    pyperclip.copy(translated)
    print()
    print('This message has been copied to the clipboard.')


def keyIsValid(key):
    keyList = list(key)
    lettersList = list(LETTERS)
    keyList.sort()
    lettersList.sort()

    return keyList == lettersList


def encryptMessage(key, message):
    return translateMessage(key, message, 'encrypt')


def decryptMessage(key, message):
    return translateMessage(key, message, 'decrypt')


def translateMessage(key, message, mode):
    translated = ''
    charsA = LETTERS
    charsB = key
    if mode == 'decrypt':
        # For decrypting, we can use the same code as encrypting. We
        # just need to swap where the key and LETTERS strings are used.
        charsA, charsB = charsB, charsA

    # Loop through each symbol in the message:
    for symbol in message:
        if symbol.upper() in charsA:
            # Encrypt/decrypt the symbol:
            symIndex = charsA.find(symbol.upper())
            if symbol.isupper():
                translated += charsB[symIndex].upper()
            else:
                translated += charsB[symIndex].lower()
        else:
            # Symbol is not in LETTERS; just add it:
            translated += symbol

    return translated


def getRandomKey():
    key = list(LETTERS)
    random.shuffle(key)
    return ''.join(key)


if __name__ == '__main__':
    main()

简单替换密码程序的运行示例

当您运行simpleSubCipher.py程序时,加密的输出应该如下所示:

Using key LFWOAYUISVKMNXPBDCRJTQEGHZ
The encrypted message is:
Sy l nlx sr pyyacao l ylwj eiswi upar lulsxrj isr sxrjsxwjr, ia esmm
rwctjsxsza sj wmpramh, lxo txmarr jia aqsoaxwa sr pqaceiamnsxu, ia esmm caytra
jp famsaqa sj. Sy, px jia pjiac ilxo, ia sr pyyacao rpnajisxu eiswi lyypcor
l calrpx ypc lwjsxu sx lwwpcolxwa jp isr sxrjsxwjr, ia esmm lwwabj sj aqax
px jia rmsuijarj aqsoaxwa. Jia pcsusx py nhjir sr agbmlsxao sx jisr elh.
-Facjclxo Ctrramm

This message has been copied to the clipboard.

注意,如果明文中的字母是小写的,那么它在密文中也是小写的。同样,如果字母在明文中是大写的,那么在密文中也是大写的。简单替换密码不加密空格或标点符号,而只是按原样返回这些字符。

要解密这个密文,将它粘贴为第 10 行的myMessage变量的值,并将myMode改为字符串'decrypt'。当您再次运行该程序时,解密输出应该如下所示:

Using key LFWOAYUISVKMNXPBDCRJTQEGHZ
The decrypted message is:
If a man is offered a fact which goes against his instincts, he will
scrutinize it closely, and unless the evidence is overwhelming, he will refuse
to believe it. If, on the other hand, he is offered something which affords
a reason for acting in accordance to his instincts, he will accept it even
on the slightest evidence. The origin of myths is explained in this way.
-Bertrand Russell

This message has been copied to the clipboard.

设置模块、常量和main()函数

让我们来看看简单替换密码程序的源代码的第一行。

# Simple Substitution Cipher
# https://www.nostarch.com/crackingcodes/ (BSD Licensed)

import pyperclip, sys, random


LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'

第 4 行导入了pyperclipsysrandom模块。LETTERS常量变量设置为全部大写字母的字符串,这是为简单替换密码程序设置的符号。

simpleSubCipher.py中的main()函数,类似于前面章节中密码程序的main()函数,在程序第一次运行时被调用。它包含存储用于程序的messagekeymode的变量。

def main():
    myMessage = 'If a man is offered a fact which goes against his
          instincts, he will scrutinize it closely, and unless the evidence
          is overwhelming, he will refuse to believe it. If, on the other
          hand, he is offered something which affords a reason for acting
          in accordance to his instincts, he will accept it even on the
          slightest evidence. The origin of myths is explained in this way.
          -Bertrand Russell'
    myKey = 'LFWOAYUISVKMNXPBDCRJTQEGHZ'
    myMode = 'encrypt' # Set to 'encrypt' or 'decrypt'.

简单替换密码的密钥很容易出错,因为它们相当长,需要包含字母表中的每个字母。例如,很容易输入缺少一个字母的密钥或两次输入相同字母的密钥。keyIsValid()函数确保密钥可被加密和解密函数使用,如果密钥无效,该函数将退出程序并显示一条错误消息:

    if keyIsValid(myKey):
        sys.exit('There is an error in the key or symbol set.')

如果第 14 行从keyIsValid()返回False,那么myKey包含一个无效的密钥,第 15 行终止程序。

第 16 到 19 行检查myMode变量是否被设置为'encrypt''decrypt',并相应地调用encryptMessage()decryptMessage():

    if myMode == 'encrypt':
        translated = encryptMessage(myKey, myMessage)
    elif myMode == 'decrypt':
        translated = decryptMessage(myKey, myMessage)

encryptMessage()decryptMessage()的返回值是存储在translated变量中的加密或解密消息的字符串。

第 20 行打印屏幕上使用的密钥。加密或解密的消息被打印到屏幕上,并被复制到剪贴板上。

    print('Using key %s' % (myKey))
    print('The %sed message is:' % (myMode))
    print(translated)
    pyperclip.copy(translated)
    print()
    print('This message has been copied to the clipboard.')

第 25 行是main()函数中的最后一行代码,所以程序执行在第 25 行之后返回。当main()调用在程序的最后一行完成时,程序退出。

接下来,我们将看看keyIsValid()函数如何使用sort()方法来测试密钥是否有效。

sort()列表方法

列表有一个sort()方法,将列表的项目重新排列成数字或字母顺序。当您必须检查两个列表是否包含相同的项目,但它们的排列顺序不同时,这种对列表中的项目进行排序的函数就很方便了。

simpleSubCipher.py中,一个简单的替换键字符串值只有在符号集中的每个字符都没有重复或丢失字母时才有效。我们可以通过对字符串值进行排序并检查它是否等于排序后的LETTERS来检查它是否是有效的密钥。但是因为我们只能对列表进行排序,而不能对字符串进行排序(回想一下,字符串是不可变的,这意味着它们的值不能被改变),我们将通过将它们传递给list()来获得字符串值的列表版本。然后,在对这些列表进行排序后,我们可以比较这两个列表,看它们是否相等。尽管LETTERS已经按字母顺序排列,我们还是要对它进行排序,因为我们稍后会扩展它以包含其他字符。

def keyIsValid(key):
    keyList = list(key)
    lettersList = list(LETTERS)
    keyList.sort()
    lettersList.sort()

key中的字符串被传递到第 29 行的list()。返回的列表值存储在名为keyList的变量中。

在第 30 行,LETTERS常量变量(包含字符串'ABCDEFGHIJKLMNOPQRSTUVWXYZ')被传递给list(),后者以如下格式返回列表:['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z']

在第 31 和 32 行,keyListlettersList中的列表通过调用它们的sort()列表方法按字母顺序排序。注意,类似于append()列表方法,sort()列表方法原地修改列表,并且没有返回值。

排序时,keyListlettersList的值应该是相同的,因为keyList只不过是顺序被打乱的LETTERS中的字符。第 34 行检查值keyListlettersList是否相等:

    return keyList == lettersList

如果keyListlettersList相等,你可以确定keyListkey参数没有任何重复的字符,因为LETTERS里面没有重复的字符。在这种情况下,第 34 行返回True。但是如果keyListlettersList不匹配,密钥无效,第 34 行返回False

包装函数

simpleSubCipher.py程序中的加密代码和解密代码几乎相同。当你有两段非常相似的代码时,最好将它们放入一个函数中并调用两次,而不是输入两次代码。这不仅节省了时间,更重要的是,避免了在复制粘贴代码时引入 bug。这也是有利的,因为如果代码中有 bug,您只需要在一个地方修复 bug,而不是在多个地方。

包装函数通过包装另一个函数的代码并返回包装函数返回的值,帮助您避免输入重复的代码。通常,包装函数会对被包装函数的参数或返回值做一点小小的改变。否则,就没有包装的必要,因为您可以直接调用该函数。

让我们看一个在代码中使用包装函数的例子来理解它们是如何工作的。在这种情况下,第 37 行和第 41 行的encryptMessage()decryptMessage ()是包装函数:

def encryptMessage(key, message):
    return translateMessage(key, message, 'encrypt')


def decryptMessage(key, message):
    return translateMessage(key, message, 'decrypt')

这些包装函数中的每一个都调用translateMessage (),这是被包装的函数,并返回translateMessage()返回的值。(我们将在下一节研究translateMessage()函数。)因为两个包装函数使用相同的translateMessage()函数,所以如果我们需要对密码进行任何更改,我们只需要修改这一个函数,而不是encryptMessage()decryptMessage ()函数。

有了这些包装函数,导入程序simpleSubCipher.py的人可以调用名为encryptMessage()decryptMessage()的函数,就像他们可以调用本书中所有其他密码程序一样。包装函数有明确的名字,告诉使用这些函数的其他人他们做了什么,而不必看代码。因此,如果我们想要共享我们的代码,其他人可以更容易地使用它。

其他程序可以通过导入密码程序并调用它们的encryptMessage()函数来用各种密码加密消息,如下所示:

import affineCipher, simpleSubCipher, transpositionCipher
--snip--
ciphertext1 =        affineCipher.encryptMessage(encKey1, 'Hello!')
ciphertext2 = transpositionCipher.encryptMessage(encKey2, 'Hello!')
ciphertext3 =     simpleSubCipher.encryptMessage(encKey3, 'Hello!')

命名一致性是有帮助的,因为它使得熟悉其中一个密码程序的人更容易使用其他密码程序。例如,您可以看到第一个参数始终是密钥,第二个参数始终是消息,这是本书中大多数密码程序使用的约定。使用translateMessage()函数而不是单独的encryptMessage()decryptMessage函数会与其他程序不一致。

接下来我们来看看translateMessage()函数。

translateMessage()函数

translateMessage()函数用于加密和解密。

def translateMessage(key, message, mode):
    translated = ''
    charsA = LETTERS
    charsB = key
    if mode == 'decrypt':
        # For decrypting, we can use the same code as encrypting. We
        # just need to swap where the key and LETTERS strings are used.
        charsA, charsB = charsB, charsA

注意,translateMessage()有参数keymessage,还有第三个参数mode。当我们调用translateMessage时,encryptMessage()函数中的调用为mode参数传递'encrypt'decryptMessage()函数中的调用传递'decrypt'。这就是translateMessage()函数如何知道它应该加密还是解密传递给它的消息。

实际的加密过程很简单:对于message参数中的每个字母,该函数在LETTERS中查找该字母的索引,并用在key参数中相同索引处的字母替换该字符。解密则相反:它在key中查找索引,并用LETTERS中相同索引处的字母替换该字符。

程序没有使用LETTERSkey,而是使用变量charsAcharsB,这允许它用charsB中相同索引处的字母替换charsA中的字母。能够改变分配给charsAcharsB的值使得程序很容易在加密和解密之间切换。第 47 行将charsA中的字符设置为LETTERS中的字符,第 48 行将charsB中的字符设置为key中的字符。

下图显示了如何使用相同的代码来加密或解密字母。图 16-2 说明了加密过程。该图中最上面一行显示的是charsA(设置为LETTERS)中的字符,中间一行显示的是charsB(设置为key)中的字符,最下面一行显示的是字符对应的整数索引。

Images

图 16-2:使用索引加密明文

translateMessage()中的代码总是在charsA中查找消息字符的索引,并在该索引处用charsB中的相应字符替换它。所以为了加密,我们让charsAcharsB保持原样。使用变量charsAcharsBLETTERS中的字符替换为key中的字符,因为charsA被设置为LETTERScharsB被设置为key

为了解密,使用行 52 上的charsA, charsB = charsB, charsA切换charsAcharsB中的值。图 16-3 显示了解密过程。

Images

图 16-3:使用索引解密密文

请记住,translateMessage()中的代码总是用charsB中相同索引处的字符替换charsA中的字符。所以当第 52 行交换值时,translateMessage()中的代码执行解密过程,而不是加密过程。

接下来的几行代码显示了程序如何找到用于加密和解密的索引。

    # Loop through each symbol in the message:
    for symbol in message:
        if symbol.upper() in charsA:
            # Encrypt/decrypt the symbol:
            symIndex = charsA.find(symbol.upper())

第 55 行的for循环在循环的每次迭代中将symbol变量设置为message字符串中的一个字符。如果这个符号的大写形式存在于charsA(回想一下keyLETTERS中只有大写字符),第 58 行找到symbol大写形式在charsA中的索引。symIndex变量存储这个索引。

我们已经知道,find()方法永远不会返回-1(来自find()方法的-1意味着在字符串中找不到参数),因为第 56 行的if语句保证了symbol.upper()存在于charsA中。否则,第 58 行就不会被执行。

接下来,我们将使用每个加密或解密的symbol来构建由translateMessage()函数返回的字符串。但是因为keyLETTERS都是大写的,我们需要检查message中最初的symbol是否是小写的,如果是,那么将解密或加密的symbol调整为小写。要做到这一点,你需要学习两个字符串方法:isupper()islower()

isupper()islower()字符串方法

isupper()islower()方法检查字符串是大写还是小写。

更具体地说,如果这两个条件都满足,isupper()字符串方法返回True:

  • 该字符串至少有一个大写字母。

  • 该字符串中没有任何小写字母。

如果这两个条件都满足,islower()字符串方法返回True:

  • 该字符串至少有一个小写字母。

  • 该字符串中没有任何大写字母。

字符串中的非字母字符不影响这些方法是返回True还是False,尽管如果字符串中只存在非字母字符,这两种方法的计算结果都是False。在交互式 shell 中输入以下内容,查看这些方法是如何工作的:

   >>> 'HELLO'.isupper()
   True
   >>> 'HELLO WORLD 123'.isupper() # ➊
   True
   >>> 'hello'.islower() # ➋
   True
   >>> '123'.isupper()
   False
   >>> ''.islower()
   False

在 ➊ 的例子返回True,因为'HELLO WORLD 123'中至少有一个大写字母,没有小写字母。字符串中的数字不会影响计算。在 ➋,'hello'.islower()返回True,因为字符串'hello'中至少有一个小写字母,没有大写字母。

让我们回到我们的代码,看看它是如何使用isupper()islower()字符串方法的。

isupper()确保大小写

simpleSubCipher.py程序使用isupper()islower()字符串方法来帮助确保明文的大小写在密文中得到反映。

            if symbol.isupper():
                translated += charsB[symIndex].upper()
            else:
                translated += charsB[symIndex].lower()

第 59 行测试symbol是否有大写字母。如果是,第 60 行将字符的大写版本从charsB[symIndex]连接到translated。这导致大写版本的key字符对应于大写输入。如果symbol有一个小写字母,第 62 行将字符的小写版本从charsB[symIndex]连接到translated

如果symbol不是符号集中的一个字符,比如'5''?',第 59 行将返回False,第 62 行将代替第 60 行执行。原因是不满足isupper()的条件,因为那些字符串没有至少一个大写字母。在这种情况下,第 62 行的lower()方法调用对字符串没有影响,因为它根本没有字母。lower()方法不会改变像'5''?'这样的非字母字符。它只是返回原始的非字母字符。

else块中的第 62 行说明了symbol字符串中的任何小写字符非字母字符。

第 63 行的缩进表示第 56 行的else语句与if symbol.upper() in charsA:语句成对出现,所以如果symbol不在LETTERS中,第 63 行执行。

        else:
            # Symbol is not in LETTERS; just add it:
            translated += symbol

如果symbol不在LETTERS中,则执行第 65 行。这意味着我们不能加密或解密symbol中的字符,所以我们简单地将它连接到translated的末尾。

translateMessage()函数的末尾,第 67 行返回translated变量中的值,该值包含加密或解密的消息:

    return translated

接下来,我们将看看如何使用getRandomKey()函数为简单的替换密码生成一个有效的密钥。

生成随机密钥

键入包含字母表中每个字母的密钥的字符串可能很困难。为了帮助我们做到这一点,getRandomKey()函数返回一个有效的密钥来使用。第 71 到 73 行随机打乱了LETTERS常量中的字符。

def getRandomKey():
    key = list(LETTERS)
    random.shuffle(key)
    return ''.join(key)

阅读第 123 页上的随机打乱一个字符串,了解如何使用list()random.shuffle()join()方法打乱一个字符串。

要使用getRandomKey()函数,我们需要将第 11 行从myKey = 'LFWOAYUISVKMNXPBDCRJTQEGHZ'改为:

    myKey = getRandomKey()

因为我们的简单替换密码程序中的第 20 行打印了正在使用的密钥,所以您将能够看到函数getRandomKey()返回的密钥。

调用main()函数

如果simpleSubCipher.py作为一个程序运行,而不是作为一个模块被另一个程序导入,程序结尾的第 76 和 77 行调用main()

if __name__ == '__main__':
    main()

我们对简单替换密码程序的研究到此结束。

总结

在这一章中,你学习了如何使用sort()列表方法对列表中的条目进行排序,以及如何比较两个有序列表来检查字符串中的重复字符或缺失字符。您还了解了isupper()islower()字符串方法,它们检查字符串值是由大写字母还是小写字母组成的。您了解了包装函数,包装函数是调用其他函数的函数,通常只添加微小的变化或不同的参数。

简单的替换密码有太多可能的密钥,无法强行破解。这使得它不受你用来破解以前的密码程序的技术的影响。你必须编写更聪明的程序来破解这个密码。

在第 17 章中,你将学习如何破解简单的替换密码。您将使用更加智能和复杂的算法,而不是暴力破解所有的密钥。

练习题

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

  1. 为什么不能用暴力攻击来对付简单的替换密码,即使是用强大的超级计算机?

  2. 运行这段代码后spam变量包含什么?

      spam = [4, 6, 2, 8]
      spam.sort()
    
  3. 什么是包装函数?

  4. 'hello'.islower()求值为什么?

  5. 'HELLO 123'.isupper()求值为什么?

  6. '123'.islower()求值为什么?

十七、破解简单替换密码

原文:inventwithpython.com/cracking/ch…

“加密本质上是一种私人行为。事实上,加密的行为将信息从公共领域移除。即使是针对加密技术的法律,也只能延伸到一个国家的边境和暴力地区。”

——埃里克·休斯,《一个赛博朋克的宣言》(1993)

Images

在第 16 章中,你了解到简单的替换密码不可能用暴力破解,因为它有太多可能的密钥。要破解简单的替换密码,我们需要创建一个更复杂的程序,使用字典值来映射密文的潜在解密字母。在这一章中,我们将编写这样一个程序来将潜在的解密输出列表缩小到正确的一个。

本章涵盖的主题

  • 单词模式、候选单词、潜在的解密字母和密码字母映射

  • 正则表达式

  • sub()正则表达式方法

使用单词模式解密

在暴力攻击中,我们尝试每一个可能的密钥来检查它是否能解密密文。如果密钥是正确的,解密结果是可读的英语。但是,通过首先分析密文,我们可以减少可能尝试的密钥数量,甚至可以找到完整或部分密钥。

让我们假设原始明文主要由英语字典文件中的单词组成,就像我们在第 11 章中使用的那样。虽然密文不会由真正的英语单词组成,但它仍然包含由空格分隔的字母组,就像普通句子中的单词一样。在本书中,我们将这些称为密码。在替换密码中,字母表中的每个字母都有一个唯一的对应加密字母。我们将密文中的字母称为密文。因为每个明文字母只能加密成一个密码字母,并且我们在这个版本的密码中没有加密空格,所以明文和密文将共享相同的单词模式

例如,如果我们有明文MISSISSIPPI SPILL,对应的密文可能是RJBBJBBJXXJ BXJHH。明文的第一个字和第一个密码中的字母数是相同的。对于第二明文字和第二密码字也是如此。明文和密文共享相同的字母和空格模式。还要注意,明文中重复的字母与密文重复的次数和位置相同。

因此,我们可以假设一个密码对应于英语字典文件中的一个单词,并且它们的单词模式匹配。然后,如果我们能在字典中找到该密码解密到哪个单词,我们就能算出该单词中每个密码字母的解密。如果我们用这种技术破解出足够多的密码,我们就能解密整个信息。

寻找单词模式

让我们检查一下密码HGHHU的单词模式。您可以看到,密码具有某些特征,这些特征是原始明文必须具有的。这两个词必须有以下共同点。

  1. 它们应该有五个字母长。

  2. 第一、第三和第四个字母应该相同。

  3. 它们应该正好有三个不同的字母;第一、第二和第五个字母应该都不同。

让我们想想英语中符合这种模式的单词。Puppy就是这样一个单词,它有五个字母长(PUPPY),使用三个不同的字母(PUY)以相同的模式排列(P代表第一、第三和第四个字母;U代表第二个字母;Y代表第五个字母)。MommyBobbylullsnanny也符合这种模式。这些单词,以及英语字典文件中匹配该标准的任何其他单词,都是HGHHU的可能解密。

为了用程序可以理解的方式表示一个单词模式,我们将把每个模式分成一组数字,用句点分隔,表示字母的模式。

创建单词模式很容易:第一个字母得到数字 0,此后每个不同字母的第一次出现得到下一个数字。比如Cat的单词模式是0.1.2Category的单词模式是0.1.2.3.4.5.4.0.2.6.4.7.8

在简单的替换密码中,无论使用哪个密钥加密,明文字和它的密码字总是具有相同的字模式。密文HGHHU的字模式是0.1.0.0.2,这意味着对应于HGHHU的明文的字模式也是0.1.0.0.2

寻找潜在解密字母

要解密HGHHU,我们需要在一个英文字典文件中找到所有单词,这个文件的单词模式也是0.1.0.0.2。在本书中,我们将与密码具有相同单词模式的明文单词称为该密码的候选单词。以下是HGHHU的候选名单:

  • PUPPY

  • MOMMY

  • BOBBY

  • LULLS

  • NANNY

使用单词模式,我们可以猜测密文可能解密成哪些明文字母,我们称之为密文的潜在解密字母。要破解用简单替换密码加密的消息,我们需要找到消息中每个单词的所有潜在解密字母,并通过排除过程确定实际的解密字母。表 17-1 列出了HGHHU的潜在解密字母。

表 17-1HGHHU中密文的潜在解密字母

密码字母HGHHU
潜在解密字母PUPPY
MOMMY
BOBBY
LULLS
NANNY

以下是使用表 17-1 创建的密码字母映射:

  1. H有潜在的解密字母PMBLN

  2. G有潜在的解密字母UOA

  3. U有潜在的解密字母YS

  4. 在这个例子中,除了HGU之外的所有其他密码字母都没有潜在的解密字母。

密码字母映射显示字母表中的所有字母及其潜在的解密字母。当我们开始收集加密消息时,我们将为字母表中的每个字母找到潜在的解密字母,但是因为只有密码字母HGU是我们示例密文的一部分,所以我们没有其他密码字母的潜在解密字母。

还要注意,U只有两个潜在的解密字母(YS),因为候选字母之间有重叠,其中许多以字母Y结尾。重叠越多,潜在的解密字母就越少,就越容易找出该密码字母解密成什么

为了用 Python 代码表示表 17-1 ,我们将使用一个字典值来表示密码字母映射,如下所示('H''G''U'的键值对以粗体显示):

{'A': [], 'B': [], 'C': [], 'D': [], 'E': [], 'F': [], 'G': ['U', 'O', 'A'],
'H': ['P', 'M', 'B', 'L', 'N'], 'I': [], 'J': [], 'K': [], 'L': [], 'M': [],
'N': [], 'O': [], 'P': [], 'Q': [], 'R': [], 'S': [], 'T': [], 'U': ['Y',
'S'], 'V': [], 'W': [], 'X': [], 'Y': [], 'Z': []}

该字典有 26 个键值对,字母表中的每个字母有一个键,每个字母有一个潜在的解密字母列表。它显示了键'H''G''的潜在解密字母。对于值,其他键有空列表[],因为到目前为止它们没有潜在的解密字母。

如果我们可以通过交叉引用其他加密单词的密码字母映射,将密码字母的潜在解密字母的数量减少到只有一个字母,我们就可以找到该密码字母解密成什么。即使我们不能解决所有的 26 个密码,我们也可以破解大部分的密码映射来解密大部分的密文。

既然我们已经讲述了本章将要用到的一些基本概念和术语,让我们来看看破解过程中的步骤。

破解过程概述

使用单词模式破解简单的替换密码非常容易。我们可以将破解过程的主要步骤总结如下:

  1. 找出密文中每个密码的单词模式。

  2. 找出每个密码可以解密成的候选英文单词。

  3. 创建一个字典,显示每个密码的潜在解密字母,作为每个密码的密码映射。

  4. 将密码字母映射组合成一个映射,我们称之为相交映射

  5. 从组合映射中移除任何已求解的密码字母。

  6. 用解出的密文解密密文。

密文中的密码越多,映射相互重叠的可能性就越大,每个密码的潜在解密字母就越少。这意味着在简单的替换密码中,密文信息越长,就越容易破解

在深入研究源代码之前,让我们看看如何使破解过程的前两步变得更容易。我们将使用我们在第 11 章中使用的字典文件和一个名为wordPatterns.py的模块来获取字典文件中每个单词的单词模式,并在列表中对它们进行排序。

makewodpatterns模块

要计算dictionary.txt字典文件中每个单词的单词模式,从www.nostarch.com/crackingcodes下载makewodpatterns.py。确保这个程序和dictionary.txt都在保存本章的simpleSubHacker.py程序的文件夹中。

makewodpatterns.py程序有一个getWordPattern()函数,它接受一个字符串(比如'puppy')并返回它的单词模式(比如'0.1.0.0.2')。当您运行makeWordPatterns.py时,它应该会创建 Python 模块wordPatterns.py。该模块包含一个变量赋值语句,如下所示,长度超过 43,000 行:

allPatterns = {'0.0.1': ['EEL'],
 '0.0.1.2': ['EELS', 'OOZE'],
 '0.0.1.2.0': ['EERIE'],
 '0.0.1.2.3': ['AARON', 'LLOYD', 'OOZED'],
--snip--

allPatterns变量包含一个字典值,将单词模式字符串作为关键字,将与该模式匹配的英语单词列表作为其值。例如,要查找模式为0.1.2.1.3.4.5.4.6.7.8的所有英语单词,请在交互式 shell 中输入以下内容:

>>> import wordPatterns
>>> wordPatterns.allPatterns['0.1.2.1.3.4.5.4.6.7.8']
['BENEFICIARY', 'HOMOGENEITY', 'MOTORCYCLES']

allPatterns字典中,键'0.1.2.1.3.4.5.4.6.7.8'具有列表值['BENEFICIARY', 'HOMOGENEITY', 'MOTORCYCLES'],它包含三个具有这种特殊单词模式的英语单词。

现在让我们导入wordPatterns.py模块,开始构建简单的替换破解程序!

如果在交互 shell 中导入wordPatterns时得到一条ModuleNotFoundError错误消息,请先在交互 shell 中输入以下内容:

>>> import sys
>>> sys.path.append('name_of_folder')

将文件夹名称替换为wordPatterns.py保存的位置。这告诉交互式 shell 在您指定的文件夹中查找模块。

简单替换破解程序的源代码

选择文件 -> 新建文件,打开文件编辑器窗口。在文件编辑器中输入以下代码,保存为simpleSubHacker.py。确保将pyperclip.pysimpleSubCipher.pywordPatterns.py文件放在与simpleSubHacker.py相同的目录下。按F5运行程序。

单纯子 黑客. py

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

import os, re, copy, pyperclip, simpleSubCipher, wordPatterns,
       makeWordPatterns





LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
nonLettersOrSpacePattern = re.compile('[^A-Z\s]')

def main():
    message = 'Sy l nlx sr pyyacao l ylwj eiswi upar lulsxrj isr
           sxrjsxwjr, ia esmm rwctjsxsza sj wmpramh, lxo txmarr jia aqsoaxwa
           sr pqaceiamnsxu, ia esmm caytra jp famsaqa sj. Sy, px jia pjiac
           ilxo, ia sr pyyacao rpnajisxu eiswi lyypcor l calrpx ypc lwjsxu sx
           lwwpcolxwa jp isr sxrjsxwjr, ia esmm lwwabj sj aqax px jia
           rmsuijarj aqsoaxwa. Jia pcsusx py nhjir sr agbmlsxao sx jisr elh.
           -Facjclxo Ctrramm'

    # Determine the possible valid ciphertext translations:
    print('Hacking...')
    letterMapping = hackSimpleSub(message)

    # Display the results to the user:
    print('Mapping:')
    print(letterMapping)
    print()
    print('Original ciphertext:')
    print(message)
    print()
    print('Copying hacked message to clipboard:')
    hackedMessage = decryptWithCipherletterMapping(message, letterMapping)
    pyperclip.copy(hackedMessage)
    print(hackedMessage)


def getBlankCipherletterMapping():
    # Returns a dictionary value that is a blank cipherletter mapping:
    return {'A': [], 'B': [], 'C': [], 'D': [], 'E': [], 'F': [], 'G': [],
           'H': [], 'I': [], 'J': [], 'K': [], 'L': [], 'M': [], 'N': [],
           'O': [], 'P': [], 'Q': [], 'R': [], 'S': [], 'T': [], 'U': [],
           'V': [], 'W': [], 'X': [], 'Y': [], 'Z': []}


def addLettersToMapping(letterMapping, cipherword, candidate):
    # The letterMapping parameter takes a dictionary value that
    # stores a cipherletter mapping, which is copied by the function.
    # The cipherword parameter is a string value of the ciphertext word.
    # The candidate parameter is a possible English word that the
    # cipherword could decrypt to.

    # This function adds the letters in the candidate as potential
    # decryption letters for the cipherletters in the cipherletter
    # mapping.


    for i in range(len(cipherword)):
        if candidate[i] not in letterMapping[cipherword[i]]:
            letterMapping[cipherword[i]].append(candidate[i])



def intersectMappings(mapA, mapB):
    # To intersect two maps, create a blank map and then add only the
    # potential decryption letters if they exist in BOTH maps:
    intersectedMapping = getBlankCipherletterMapping()
    for letter in LETTERS:

        # An empty list means "any letter is possible". In this case just
        # copy the other map entirely:
        if mapA[letter] == []:
            intersectedMapping[letter] = copy.deepcopy(mapB[letter])
        elif mapB[letter] == []:
            intersectedMapping[letter] = copy.deepcopy(mapA[letter])
        else:
            # If a letter in mapA[letter] exists in mapB[letter],
            # add that letter to intersectedMapping[letter]:
            for mappedLetter in mapA[letter]:
                if mappedLetter in mapB[letter]:
                    intersectedMapping[letter].append(mappedLetter)

    return intersectedMapping


def removeSolvedLettersFromMapping(letterMapping):
    # Cipherletters in the mapping that map to only one letter are
    # "solved" and can be removed from the other letters.
    # For example, if 'A' maps to potential letters ['M', 'N'], and 'B'
    # maps to ['N'], then we know that 'B' must map to 'N', so we can
    # remove 'N' from the list of what 'A' could map to. So 'A' then maps
    # to ['M']. Note that now that 'A' maps to only one letter, we can
    # remove 'M' from the list of letters for every other letter.
    # (This is why there is a loop that keeps reducing the map.)

    loopAgain = True
    while loopAgain:
        # First assume that we will not loop again:
        loopAgain = False

        # solvedLetters will be a list of uppercase letters that have one
        # and only one possible mapping in letterMapping:
        solvedLetters = []
        for cipherletter in LETTERS:
            if len(letterMapping[cipherletter]) == 1:
                solvedLetters.append(letterMapping[cipherletter][0])

        # If a letter is solved, then it cannot possibly be a potential
        # decryption letter for a different ciphertext letter, so we
        # should remove it from those other lists:
        for cipherletter in LETTERS:
            for s in solvedLetters:
                if len(letterMapping[cipherletter]) != 1 and s in
                       letterMapping[cipherletter]:
                    letterMapping[cipherletter].remove(s)
                    if len(letterMapping[cipherletter]) == 1:
                        # A new letter is now solved, so loop again:
                        loopAgain = True
    return letterMapping


def hackSimpleSub(message):
    intersectedMap = getBlankCipherletterMapping()
    cipherwordList = nonLettersOrSpacePattern.sub('',
           message.upper()).split()
    for cipherword in cipherwordList:
        # Get a new cipherletter mapping for each ciphertext word:
        candidateMap = getBlankCipherletterMapping()

        wordPattern = makeWordPatterns.getWordPattern(cipherword)
        if wordPattern not in wordPatterns.allPatterns:
            continue # This word was not in our dictionary, so continue.

        # Add the letters of each candidate to the mapping:
        for candidate in wordPatterns.allPatterns[wordPattern]:
            addLettersToMapping(candidateMap, cipherword, candidate)

        # Intersect the new mapping with the existing intersected mapping:
        intersectedMap = intersectMappings(intersectedMap, candidateMap)

    # Remove any solved letters from the other lists:
    return removeSolvedLettersFromMapping(intersectedMap)


def decryptWithCipherletterMapping(ciphertext, letterMapping):
    # Return a string of the ciphertext decrypted with the letter mapping,
    # with any ambiguous decrypted letters replaced with an underscore.

    # First create a simple sub key from the letterMapping mapping:
    key = ['x'] * len(LETTERS)
    for cipherletter in LETTERS:
        if len(letterMapping[cipherletter]) == 1:
            # If there's only one letter, add it to the key:
            keyIndex = LETTERS.find(letterMapping[cipherletter][0])
            key[keyIndex] = cipherletter
        else:
            ciphertext = ciphertext.replace(cipherletter.lower(), '_')
            ciphertext = ciphertext.replace(cipherletter.upper(), '_')
    key = ''.join(key)

    # With the key we've created, decrypt the ciphertext:
    return simpleSubCipher.decryptMessage(key, ciphertext)


if __name__ == '__main__':
    main()

简单替换破解程序的运行示例

当你运行这个程序时,它试图破解message变量中的密文。它的输出应该如下所示:

Hacking...
Mapping:
{'A': ['E'], 'B': ['Y', 'P', 'B'], 'C': ['R'], 'D': [], 'E': ['W'], 'F':
['B', 'P'], 'G': ['B', 'Q', 'X', 'P', 'Y'], 'H': ['P', 'Y', 'K', 'X', 'B'],
'I': ['H'], 'J': ['T'], 'K': [], 'L': ['A'], 'M': ['L'], 'N': ['M'], 'O':
['D'], 'P': ['O'], 'Q': ['V'], 'R': ['S'], 'S': ['I'], 'T': ['U'], 'U': ['G'],
'V': [], 'W': ['C'], 'X': ['N'], 'Y': ['F'], 'Z': ['Z']}

Original ciphertext:
Sy l nlx sr pyyacao l ylwj eiswi upar lulsxrj isr sxrjsxwjr, ia esmm
rwctjsxsza sj wmpramh, lxo txmarr jia aqsoaxwa sr pqaceiamnsxu, ia esmm caytra
jp famsaqa sj. Sy, px jia pjiac ilxo, ia sr pyyacao rpnajisxu eiswi lyypcor
l calrpx ypc lwjsxu sx lwwpcolxwa jp isr sxrjsxwjr, ia esmm lwwabj sj aqax
px jia rmsuijarj aqsoaxwa. Jia pcsusx py nhjir sr agbmlsxao sx jisr elh.
-Facjclxo Ctrramm

Copying hacked message to clipboard:
If a man is offered a fact which goes against his instincts, he will
scrutinize it closel_, and unless the evidence is overwhelming, he will refuse
to _elieve it. If, on the other hand, he is offered something which affords
a reason for acting in accordance to his instincts, he will acce_t it even
on the slightest evidence. The origin of m_ths is e__lained in this wa_.
-_ertrand Russell

现在我们来详细探究一下源代码。

设置模块和常量

让我们看看简单替换破解程序的前几行。第 4 行导入了 7 个不同的模块,比迄今为止任何其他程序都多。第 10 行的全局变量LETTERS存储符号集,它由字母表中的大写字母组成。

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

import os, re, copy, pyperclip, simpleSubCipher, wordPatterns,
       makeWordPatterns
     --snip--
LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'

re模块是正则表达式模块,它允许使用正则表达式进行复杂的字符串操作。让我们看看正则表达式是如何工作的。

用正则表达式查找字符

正则表达式是定义匹配特定字符串的特定模式的字符串。例如,第 11 行的字符串'[^A-Z\s]'是一个正则表达式,它告诉 Python 查找不是从AZ的大写字母或空白字符的任何字符(比如空格、制表符或换行符)。

nonLettersOrSpacePattern = re.compile('[^A-Z\s]')

re.compile()函数创建一个re模块可以使用的正则表达式模式对象(缩写为 正则表达式对象模式对象)。我们将使用这个对象从第 241 页的的hackSimpleSub()函数中删除任何非字母字符。

您可以使用正则表达式执行许多复杂的字符串操作。要了解更多关于正则表达式的信息,请转到www.nostarch.com/crackingcodes

设置main()函数

与本书中之前的破解程序一样,main()函数将密文存储在message变量中,第 18 行将该变量传递给hackSimpleSub()函数:

def main():
    message = 'Sy l nlx sr pyyacao l ylwj eiswi upar lulsxrj isr
           sxrjsxwjr, ia esmm rwctjsxsza sj wmpramh, lxo txmarr jia aqsoaxwa
           sr pqaceiamnsxu, ia esmm caytra jp famsaqa sj. Sy, px jia pjiac
           ilxo, ia sr pyyacao rpnajisxu eiswi lyypcor l calrpx ypc lwjsxu sx
           lwwpcolxwa jp isr sxrjsxwjr, ia esmm lwwabj sj aqax px jia
           rmsuijarj aqsoaxwa. Jia pcsusx py nhjir sr agbmlsxao sx jisr elh.
           -Facjclxo Ctrramm'

    # Determine the possible valid ciphertext translations:
    print('Hacking...')
    letterMapping = hackSimpleSub(message)

hackSimpleSub()不是返回解密的消息或None(如果无法解密的话),而是返回一个删除了解密字母的相交密码字母映射。(我们将在第 234 页的上的相交两个映射中查看如何创建相交映射。)这个相交的密文映射然后被传递给decryptWithCipherletterMapping()以将存储在message中的密文解密成可读格式,你将在第 243 页的中的“解密消息中看到更多细节。

存储在letterMapping中的密码字母映射是一个字典值,它有 26 个大写的单字母字符串作为代表密码字母的关键字。它还列出了每个密码字母的潜在解密字母的大写字母,作为字典的值。当每个密码字母只有一个与之相关的潜在解密字母时,我们就有了一个完全解决的映射,并且可以使用相同的密码和密钥解密任何密文。

生成的每个密文映射取决于所使用的密文。在某些情况下,我们将只有部分解决的映射,其中一些密码没有潜在的解密,而其他密码有多个潜在的解密。不包含字母表中每个字母的较短密文更有可能导致不完整的映射。

向用户显示破解结果

然后程序调用print()函数在屏幕上显示letterMapping、原始消息和解密后的消息:

    # Display the results to the user:
    print('Mapping:')
    print(letterMapping)
    print()
    print('Original ciphertext:')
    print(message)
    print()
    print('Copying hacked message to clipboard:')
    hackedMessage = decryptWithCipherletterMapping(message, letterMapping)
    pyperclip.copy(hackedMessage)
    print(hackedMessage)

第 28 行将解密后的消息存储在变量hackedMessage中,该变量被复制到剪贴板并打印到屏幕上,以便用户可以将其与原始消息进行比较。我们使用decryptWithCipherletterMapping()来查找解密的消息,这将在程序的后面定义。

接下来,让我们看看创建密码字母映射的所有函数。

创建密码字母映射

程序需要为密文中的每个密码词建立一个密码映射。为了创建一个完整的映射,我们需要几个助手函数。其中一个帮助函数将建立一个新的密码映射,这样我们就可以为每个密码调用它。

另一个函数将采用一个密码字、其当前字母映射和一个候选解密字来查找所有候选解密字。我们将为每个密码和每个候选字调用这个函数。然后,该函数将候选单词中的所有潜在解密字母添加到密码单词的字母映射中,并返回字母映射。

当我们从密文中得到几个单词的字母映射时,我们将使用一个函数将它们合并在一起。然后,我们将使用最后一个辅助函数,通过将一个解密字母与每个密码字母匹配来解决尽可能多的密码字母的解密。如上所述,我们不可能总是能够解开所有的密码,但是你会在第 243 页的的“解密信息中找到如何解决这个问题。

创建空白映射

首先,我们需要创建一个空白的密码字母映射。

def getBlankCipherletterMapping():
    # Returns a dictionary value that is a blank cipherletter mapping:
    return {'A': [], 'B': [], 'C': [], 'D': [], 'E': [], 'F': [], 'G': [],
           'H': [], 'I': [], 'J': [], 'K': [], 'L': [], 'M': [], 'N': [],
           'O': [], 'P': [], 'Q': [], 'R': [], 'S': [], 'T': [], 'U': [],
           'V': [], 'W': [], 'X': [], 'Y': [], 'Z': []}

当被调用时,getBlankCipherletterMapping()函数返回一个字典,其中的键被设置为字母表中 26 个字母的一个字符串。

给映射添加字母

为了给映射添加字母,我们在第 38 行定义了addLettersToMapping()函数。

def addLettersToMapping(letterMapping, cipherword, candidate):

这个函数有三个参数:一个密码映射(letterMapping)、一个要映射的密码(cipherword)和一个密码可以解密成的候选解密字(candidate)。该函数将candidate中的每个字母映射到cipherword中相应索引位置的密码字母,如果该字母不存在,则将该字母添加到letterMapping

例如,如果'PUPPY'cipherword 'HGHHU'candidate,那么addLettersToMapping()函数会将值'P'加到letterMapping中的键'H'上。然后,该函数移动到下一个字母,并将'U'附加到与键'G'成对的列表值,依此类推。

如果该字母已经在潜在解密字母列表中,那么addLettersToMapping ()不会将该字母再次添加到列表中。例如,在'PUPPY'中,对于接下来的两个'P'实例,它会跳过将'P'添加到'H'键,因为它已经在那里了。最后,该函数更改了密钥'U'的值,因此在它的潜在解密字母列表中有'Y'

addLettersToMapping()中的代码假设len(cipherword)len(candidate)相同,因为我们应该只传递一个具有匹配单词模式的cipherwordcandidate对。

然后,程序遍历cipherword中字符串的每个索引,检查一个字母是否已经被添加到潜在解密字母列表中:

    for i in range(len(cipherword)):
        if candidate[i] not in letterMapping[cipherword[i]]:
            letterMapping[cipherword[i]].append(candidate[i])

我们将使用变量i通过索引遍历cipherword的每个字母及其在candidate中对应的潜在解密字母。我们可以这样做,因为要添加的潜在解密字母是密码字母cipherword[i]candidate[i]。例如,如果cipherword'HGHHU'candidate'PUPPY',那么i将从索引0开始,我们将使用cipherword[0]candidate[0]来访问每个字符串中的第一个字母。那么执行将移动到第 51 行的if语句。

if语句检查潜在解密字母candidate[i]是否已经在密码字母的潜在解密字母列表中,如果已经在列表中,则不添加它。它通过使用letterMapping[cipherword[i]]访问映射中的密码字母来做到这一点,因为cipherword[i]是需要访问的letterMapping中的密钥。该检查可防止潜在解密字母列表中出现重复字母。

例如,'PUPPY'中的第一个'P'可能在循环的第一次迭代中被添加到letterMapping中,但是当i在第三次迭代中等于2时,来自candidate[2]'P'不会被添加到映射中,因为它已经在第一次迭代中被添加了。

如果潜在的解密字母已经不在映射中,第 52 行将新字母candidate[i]添加到letterMapping[cipherword[i]]处的密码字母映射中的潜在解密字母列表中。

回想一下,因为 Python 传递了一个对为参数传递的字典的引用的副本,而不是字典本身的副本,所以在这个函数中对letterMapping的任何更改也将在addLettersToMapping()函数之外完成。这是因为引用的两个副本仍然引用第 126 行的addLettersToMapping()调用中为letterMapping参数传递的同一个字典。

遍历完cipherword中的所有索引后,该函数将字母添加到letterMapping变量的映射中。现在让我们看看程序如何将这个映射与其他密码的映射进行比较,以检查重叠。

两个映射的交集

hackSimpleSub()函数使用intersectMappings()函数将两个密码字母映射作为其mapAmapB参数传递,并返回一个mapAmapB的合并映射。intersectMappings()函数指示程序组合mapAmapB,创建一个空白映射,然后将潜在的解密字母添加到空白映射中,前提是它们同时存在于映射中,以防止重复。

def intersectMappings(mapA, mapB):
    # To intersect two maps, create a blank map and then add only the
    # potential decryption letters if they exist in BOTH maps:
    intersectedMapping = getBlankCipherletterMapping()

首先,第 59 行创建了一个密码映射,通过调用getBlankCipherletterMapping()并将返回值存储在intersectedMapping变量中来存储合并后的映射。

第 60 行的for循环遍历LETTERS常量变量中的大写字母,并使用letter变量作为mapAmapB字典的关键字:

    for letter in LETTERS:

        # An empty list means "any letter is possible". In this case just
        # copy the other map entirely:
        if mapA[letter] == []:
            intersectedMapping[letter] = copy.deepcopy(mapB[letter])
        elif mapB[letter] == []:
            intersectedMapping[letter] = copy.deepcopy(mapA[letter])

第 64 行检查mapA的潜在解密字母列表是否为空。一个空白列表意味着这个密码可能会解密成任何字母。在这种情况下,相交密码字母映射只是复制了other映射的潜在解密字母列表。例如,如果mapA中潜在解密字母的列表为空,那么第 65 行将相交映射的列表设置为mapB中列表的副本,反之亦然。注意,如果两个映射的列表都是空的,第 64 行的条件仍然是True,然后第 65 行简单地将mapB中的空列表复制到相交的映射。

第 68 行的else块处理mapAmapB都不为空的情况;

        else:
            # If a letter in mapA[letter] exists in mapB[letter],
            # add that letter to intersectedMapping[letter]:
            for mappedLetter in mapA[letter]:
                if mappedLetter in mapB[letter]:
                    intersectedMapping[letter].append(mappedLetter)

    return intersectedMapping

当映射不为空时,第 71 行在mapA[letter]处遍历列表中的大写字母字符串。第 72 行检查mapA[letter]中的大写字母是否也存在于mapB[letter]中的大写字母字符串列表中。如果是,那么第 73 行的intersectedMapping[letter]将这个普通字母添加到潜在解密字母的列表中。

在从第 60 行开始的for循环结束后,intersectedMapping中的密码字母映射应该只具有存在于mapAmapB的潜在解密字母列表中的潜在解密字母。第 75 行返回这个完全相交的密码字母映射。接下来,让我们看一个相交映射的示例输出。

字母映射助手函数如何工作

现在我们已经定义了字母映射助手函数,让我们尝试在交互式 shell 中使用它们,以便更好地理解它们是如何协同工作的。让我们为密文'OLQIHXIRCKGNZ  PLQRZKBZB  MPBKSSIPLC'创建一个相交的密码映射,它只包含三个密码。我们将为每个单词创建一个映射,然后组合这些映射。

simpleSubHacker.py导入到交互 shell 中:

>>> import simpleSubHacker

接下来,我们调用getBlankCipherletterMapping()来创建一个空白字母映射,并将这个映射存储在一个名为letterMapping1的变量中:

>>> letterMapping1 = simpleSubHacker.getBlankCipherletterMapping()
>>> letterMapping1
{'A': [], 'C': [], 'B': [], 'E': [], 'D': [], 'G': [], 'F': [], 'I': [],
'H': [], 'K': [], 'J': [], 'M': [], 'L': [], 'O': [], 'N': [], 'Q': [],
'P': [], 'S': [], 'R': [], 'U': [], 'T': [], 'W': [], 'V': [], 'Y': [],
'X': [], 'Z': []}

让我们开始破解第一个密码,'OLQIHXIRCKGNZ'。首先,我们需要通过调用makeWordPattern模块的getWordPattern()函数来获取这个密码的单词模式,如下所示:

>>> import makeWordPatterns
>>> makeWordPatterns.getWordPattern('OLQIHXIRCKGNZ')
0.1.2.3.4.5.3.6.7.8.9.10.11

为了找出字典中哪些英语单词具有单词模式0.1.2.3.4.5.3.6.7.8.9.10.11(也就是说,为了找出密码词'OLQIHXIRCKGNZ')的候选词,我们导入wordPatterns模块并查找这个模式:

>>> import wordPatterns
>>> candidates = wordPatterns.allPatterns['0.1.2.3.4.5.3.6.7.8.9.10.11']
>>> candidates
['UNCOMFORTABLE', 'UNCOMFORTABLY']

两个英文单词匹配'OLQIHXIRCKGNZ'的单词模式;因此,第一个密码可以解密的唯一两个字是'UNCOMFORTABLE''UNCOMFORTABLY'。这些单词是我们的候选词,所以我们将它们作为列表存储在candidates变量中(不要与addLettersToMapping()函数中的candidate参数混淆)。

接下来,我们需要使用addLettersToMapping()将他们的字母映射到cipherword的字母。首先,我们将通过访问candidates列表的第一个成员来映射'UNCOMFORTABLE',如下所示:

>>> letterMapping1 = simpleSubHacker.addLettersToMapping(letterMapping1,
'OLQIHXIRCKGNZ', candidates[0])
>>> letterMapping1
{'A': [], 'C': ['T'], 'B': [], 'E': [], 'D': [], 'G': ['B'], 'F': [], 'I':
['O'], 'H': ['M'], 'K': ['A'], 'J': [], 'M': [], 'L': ['N'], 'O': ['U'], 'N':
['L'], 'Q': ['C'], 'P': [], 'S': [], 'R': ['R'], 'U': [], 'T': [], 'W': [],
'V': [], 'Y': [], 'X': ['F'], 'Z': ['E']}

letterMapping1值可以看到,'OLQIHXIRCKGNZ'中的字母映射到'UNCOMFORTABLE'中的字母:'O'映射到['U']'L'映射到['N']'Q'映射到['C'],以此类推。

但是因为'OLQIHXIRCKGNZ'中的字母也可能解密成' UNCOMFORTABLY',我们也需要将其添加到密码字母映射中。在交互式 shell 中输入以下内容:

>>> letterMapping1 = simpleSubHacker.addLettersToMapping(letterMapping1,
'OLQIHXIRCKGNZ', candidates[1])
>>> letterMapping1
{'A': [], 'C': ['T'], 'B': [], 'E': [], 'D': [], 'G': ['B'], 'F': [],
'I': ['O'], 'H': ['M'], 'K': ['A'], 'J': [], 'M': [], 'L': ['N'], 'O': ['U'],
'N': ['L'], 'Q': ['C'], 'P': [], 'S': [], 'R': ['R'], 'U': [], 'T': [],
'W': [], 'V': [], 'Y': [], 'X': ['F'], 'Z': ['E', 'Y']}

请注意,letterMapping1中没有太多变化,除了letterMapping1中的密码字母映射现在除了'E'之外还有'Z''Y'的映射。这是因为只有当字母不在列表中时,addLettersToMapping()才会将该字母添加到列表中。

现在我们有了三个密码字中第一个的密码字母映射。我们需要为第二个密码词'PLQRZKBZB获取一个新的映射,并重复这个过程:

>>> letterMapping2 = simpleSubHacker.getBlankCipherletterMapping()
>>> wordPat = makeWordPatterns.getWordPattern('PLQRZKBZB')
>>> candidates = wordPatterns.allPatterns[wordPat]
>>> candidates
['CONVERSES', 'INCREASES', 'PORTENDED', 'UNIVERSES']
>>> for candidate in candidates:
...   letterMapping2 = simpleSubHacker.addLettersToMapping(letterMapping2,
'PLQRZKBZB', candidate)
...
>>> letterMapping2
{'A': [], 'C': [], 'B': ['S', 'D'], 'E': [], 'D': [], 'G': [], 'F': [], 'I':
[], 'H': [], 'K': ['R', 'A', 'N'], 'J': [], 'M': [], 'L': ['O', 'N'], 'O': [],
'N': [], 'Q': ['N', 'C', 'R', 'I'], 'P': ['C', 'I', 'P', 'U'], 'S': [], 'R':
['V', 'R', 'T'], 'U': [], 'T': [], 'W': [], 'V': [], 'Y': [], 'X': [], 'Z':
['E']}

我们可以编写一个for循环,遍历candidates中的列表,并对每一个单词调用addLettersToMapping(),而不是为这四个候选单词中的每一个单词输入四个对addLettersToMapping()的调用。这完成了第二个密码的密码字母映射。

接下来,我们需要通过将密码字母映射传递给intersectMappings()来获得它们在letterMapping1letterMapping2中的交集。在交互式 shell 中输入以下内容:

>>> intersectedMapping = simpleSubHacker.intersectMappings(letterMapping1,
letterMapping2)
>>> intersectedMapping
{'A': [], 'C': ['T'], 'B': ['S', 'D'], 'E': [], 'D': [], 'G': ['B'], 'F': [],
'I': ['O'], 'H': ['M'], 'K': ['A'], 'J': [], 'M': [], 'L': ['N'], 'O': ['U'],
'N': ['L'], 'Q': ['C'], 'P': ['C', 'I', 'P', 'U'], 'S': [], 'R': ['R'],
'U': [], 'T': [], 'W': [], 'V': [], 'Y': [], 'X': ['F'], 'Z': ['E']}

现在,相交映射中任何密码的潜在解密字母的列表应该仅仅是在letterMapping1letterMapping2中的潜在解密字母

例如,'Z'键在intersectedMapping中的列表只是['E'],因为letterMapping1['E', 'Y'],而letterMapping2只有['E']

接下来,我们对第三个密码字'MPBKSSIPLC'重复上述所有步骤,如下所示:

>>> letterMapping3 = simpleSubHacker.getBlankCipherletterMapping()
>>> wordPat = makeWordPatterns.getWordPattern('MPBKSSIPLC')
>>> candidates = wordPatterns.allPatterns[wordPat]
>>> for i in range(len(candidates)):
...   letterMapping3 = simpleSubHacker.addLettersToMapping(letterMapping3,
'MPBKSSIPLC', candidates[i])
...
>>> letterMapping3
{'A': [], 'C': ['Y', 'T'], 'B': ['M', 'S'], 'E': [], 'D': [], 'G': [],
'F': [], 'I': ['E', 'O'], 'H': [], 'K': ['I', 'A'], 'J': [], 'M': ['A', 'D'],
'L': ['L', 'N'], 'O': [], 'N': [], 'Q': [], 'P': ['D', 'I'], 'S': ['T', 'P'],
'R': [], 'U': [], 'T': [], 'W': [], 'V': [], 'Y': [], 'X': [], 'Z': []}

在交互 shell 中输入以下内容使letterMapping3intersectedMapping相交,这是letterMapping1letterMapping2的相交映射:

>>> intersectedMapping = simpleSubHacker.intersectMappings(intersectedMapping,
letterMapping3)
>>> intersectedMapping
{'A': [], 'C': ['T'], 'B': ['S'], 'E': [], 'D': [], 'G': ['B'], 'F': [],
'I': ['O'], 'H': ['M'], 'K': ['A'], 'J': [], 'M': ['A', 'D'], 'L': ['N'],
'O': ['U'], 'N': ['L'], 'Q': ['C'], 'P': ['I'], 'S': ['T', 'P'], 'R': ['R'],
'U': [], 'T': [], 'W': [], 'V': [], 'Y': [], 'X': ['F'], 'Z': ['E']}

在这个例子中,我们能够找到列表中只有一个值的键的解决方案。比如'K'解密到'A'。但是请注意,密钥'M'可以解密到'A''D'。因为我们知道'K'解密到'A',所以可以推导出密钥'M'一定解密到'D',而不是'A'。毕竟,如果被破解的字母被一个密码字母使用,它就不能被另一个密码字母使用,因为简单替换密码将一个明文字母加密成恰好一个密码字母。

让我们看看removeSolvedLettersFromMapping()函数是如何找到这些已求解的字母并将它们从潜在解密字母列表中移除的。我们将需要刚刚创建的intersectedMapping,所以暂时不要关闭空闲窗口。

标识映射中已解决的字母

removeSolvedLettersFromMapping()函数在letterMapping参数中搜索任何只有一个潜在解密字母的密码字母。这些密码被认为是已破解的,这意味着在他们的潜在解密字母列表中,任何其他带有这个已破解字母的密码都不可能解密成这个字母。这可能引起连锁反应,因为当一个潜在的解密字母从仅包含两个字母的其他潜在解密字母列表中删除时,结果可能是一个新的已解密码字母。该程序通过循环并从整个密码字母映射中删除新解决的字母来处理这种情况。

def removeSolvedLettersFromMapping(letterMapping):
         --snip--
    loopAgain = True
    while loopAgain:
        # First assume that we will not loop again:
        loopAgain = False

因为为参数letterMapping传递了对字典的引用,所以该字典将包含在函数removeSolvedLettersFromMapping()中所做的更改,即使在函数返回之后。第 88 行创建了loopAgain,这是一个保存布尔值的变量,它决定了代码在找到另一个已解决的字母时是否需要再次循环。

如果第 88 行的loopAgain变量被设置为True,程序执行进入第 89 行的while循环。在循环开始时,第 91 行将loopAgain设置为False。代码假设这是第 89 行的while循环的最后一次迭代。如果程序在这个迭代中找到一个新的解出的密码字母,变量loopAgain仅被设置为True

代码的下一部分创建一个密码列表,该列表中只有一个潜在的解密字母。这些是将从映射中移除的已求解字母。

        # solvedLetters will be a list of uppercase letters that have one
        # and only one possible mapping in letterMapping:
        solvedLetters = []
        for cipherletter in LETTERS:
            if len(letterMapping[cipherletter]) == 1:
                solvedLetters.append(letterMapping[cipherletter][0])

第 96 行的for循环遍历所有 26 个可能的密码字母,并查看该密码字母的密码字母映射的潜在解密字母列表(即在letterMapping[cipherletter]的列表)。

第 97 行检查这个列表的长度是否为1。如果是的话,我们知道只有一个字母可以被解密,密码就被破解了。第 98 行将解决的解密字母添加到solvedLetters列表中。被求解的字母总是在letterMapping[cipherletter][0]处,因为letterMapping[cipherletter]是潜在解密字母的列表,在列表的索引0处只有一个字符串值。

在从第 96 行开始的前一个for循环结束后,solvedLetters变量应该包含一个密文的所有解密列表。第 98 行将这些解密后的字符串作为列表存储在solvedLetters中。

至此,程序完成了对所有已解字母的识别。然后检查它们是否被列为其他密码的潜在解密字母,并删除它们。

为此,第 103 行的for循环遍历所有 26 个可能的密码字母,并查看密码字母映射的潜在解密字母列表。

        for cipherletter in LETTERS:
            for s in solvedLetters:
                if len(letterMapping[cipherletter]) != 1 and s in
                       letterMapping[cipherletter]:
                    letterMapping[cipherletter].remove(s)
                    if len(letterMapping[cipherletter]) == 1:
                        # A new letter is now solved, so loop again:
                        loopAgain = True
    return letterMapping

对于检查的每个密码字母,行 104 循环通过solvedLetters中的字母,以检查它们中的任何一个是否存在于letterMapping[cipherletter]的潜在解密字母列表中。

第 105 行通过检查len(letterMapping[cipherletter]) != 1已解决的字母是否存在于潜在解密字母列表中,来检查潜在解密字母列表是否未被解决。如果两个标准都满足,则该条件返回True,并且第 106 行从潜在解密字母的列表中移除s中已解决的字母。

如果这种移除仅在潜在解密字母列表中留下一个字母,则第 109 行将loopAgain变量设置为True,因此代码可以在循环的下一次迭代中从密码字母映射中移除这个新求解的字母。

在第 89 行的while循环已经完成一次完整的迭代而loopAgain没有被设置为True之后,程序移出该循环,并且第 110 行返回存储在letterMapping中的密码字母映射。

变量letterMapping现在应该包含部分或潜在的完全解决的密码字母映射。

测试removeSolvedLetterFromMapping()函数

让我们通过在交互式 shell 中测试来看看removeSolvedLetterFromMapping()的运行情况。返回到创建intersectedMapping时打开的交互式 shell 窗口。(如果你关了窗户,不用担心;你可以重新输入第 235 页的的“字母映射帮助函数如何工作中的指令,然后跟着这个例子做。)

要从intersectedMapping中删除已解决的字母,请在交互式 shell 中输入以下内容:

>>> letterMapping = simpleSubHacker.removeSolvedLettersFromMapping(
intersectedMapping)
>>> intersectedMapping
{'A': [], 'C': ['T'], 'B': ['S'], 'E': [], 'D': [], 'G': ['B'], 'F': [],
'I': ['O'], 'H': ['M'], 'K': ['A'], 'J': [], 'M': ['D'], 'L': ['N'], 'O':
['U'], 'N': ['L'], 'Q': ['C'], 'P': ['I'], 'S': ['P'], 'R': ['R'], 'U': [],
'T': [], 'W': [], 'V': [], 'Y': [], 'X': ['F'], 'Z': ['E']}

当您从intersectedMapping中移除已解决的字母时,请注意'M'现在只有一个潜在的解密字母'D',这正是我们预测的情况。现在每个密码字母只有一个潜在的解密字母,所以我们可以使用密码字母映射来开始解密。我们需要再一次回到这个交互式 shell 示例,所以保持它的窗口打开。

hackSimpleSub()函数

既然您已经看到了函数getBlankCipherletterMapping()addLettersToMapping()intersectMappings()removeSolvedLettersFromMapping()如何操作您传递给它们的密码字母映射,那么让我们在我们的simpleSubHacker.py程序中使用它们来解密消息。

第 113 行定义了hackSimpleSub()函数,它获取一条密文消息,并使用字母映射帮助函数返回部分或全部解决的密码字母映射:

def hackSimpleSub(message):
    intersectedMap = getBlankCipherletterMapping()
    cipherwordList = nonLettersOrSpacePattern.sub('', 
           message.upper()).split()

在第 114 行,我们创建了一个新的密码字母映射,并存储在变量intersectedMap中。这个变量最终将保存每个密码的交集映射。

在第 115 行,我们删除了message中的任何非字母字符。nonLettersOrSpacePattern中的正则对象匹配任何不是字母或空白字符的字符串。在正则表达式上调用sub()方法,该方法有两个参数。该函数在第二个参数中搜索匹配项,并用第一个参数中的字符串替换这些匹配项。然后它返回一个包含所有这些替换的字符串。在这个例子中,sub()方法告诉程序遍历大写的message字符串,并用空白字符串('')替换所有非字母字符。这使得sub()返回一个去掉了所有标点和数字字符的字符串,这个字符串存储在cipherwordList变量中。

在第 115 行执行之后,cipherwordList变量应该包含前面在message中的单个密码的大写字符串列表。

第 116 行的for循环将message列表中的每个字符串分配给cipherword变量。在这个循环中,代码创建一个空白映射,获取密码的候选项,将候选项的字母添加到一个密码字母映射中,然后将这个映射与intersectedMap相交。

    for cipherword in cipherwordList:
        # Get a new cipherletter mapping for each ciphertext word:
        candidateMap = getBlankCipherletterMapping()
        wordPattern = makeWordPatterns.getWordPattern(cipherword)
        if wordPattern not in wordPatterns.allPatterns:
            continue # This word was not in our dictionary, so continue.
        # Add the letters of each candidate to the mapping:
        for candidate in wordPatterns.allPatterns[wordPattern]:
            addLettersToMapping(candidateMap, cipherword, candidate)
        # Intersect the new mapping with the existing intersected mapping:
        intersectedMap = intersectMappings(intersectedMap, candidateMap)

第 118 行从函数getBlankCipherletterMapping ()获得新的空白密码字母映射,并将其存储在candidateMap变量中。

为了找到当前密码的候选,第 120 行调用makeWordPatterns模块中的getWordPattern ()。在某些情况下,密码可能是一个名字或一个字典中不存在的非常不常用的词,在这种情况下,它的词模式可能也不会存在于wordPatterns中。如果密码的单词模式不存在于wordPatterns.allPatterns字典的关键字中,则原始明文单词不存在于字典文件中。在这种情况下,密码没有得到映射,第 122 行的continue语句返回到第 116 行列表中的下一个密码。

如果执行到第 125 行,我们知道单词模式存在于wordPatterns.allPatterns中。allPatterns字典中的值是具有wordPattern中模式的英语单词的字符串列表。因为值是列表的形式,所以我们使用一个for循环来遍历它们。在循环的每次迭代中,变量candidate被设置为这些英文单词串中的每一个。

第 125 行上的for循环调用第 126 行上的addLettersToMapping(),以使用每个候选者中的字母更新candidateMap中的密码字母映射。addLettersToMapping()函数直接修改列表,所以candidateMap在函数调用返回时被修改。

在候选中的所有字母被添加到candidateMap中的密码字母映射后,第 129 行将candidateMapintersectedMap相交,并返回新的值intersectedMap

此时,程序执行返回到第 116 行的for循环的开始,为cipherwordList列表中的下一个密码创建新的映射,并且下一个密码的映射也与intersectedMap相交。循环继续映射密码,直到到达cipherWordList中的最后一个字。

当我们得到包含密文中所有密码词的映射的最终相交密码字母映射时,我们将其传递给第 132 行的removeSolvedLettersFromMapping ()以移除任何已解字母。

    # Remove any solved letters from the other lists:
    return removeSolvedLettersFromMapping(intersectedMap)

removeSolvedLettersFromMapping ()返回的密码字母映射然后被返回给hackSimpleSub()函数。现在我们有了部分的密码解决方案,所以我们可以开始解密信息。

replace()字符串方法

string 方法返回一个替换了字符的新字符串。第一个参数是要查找的子字符串,第二个参数是替换这些子字符串的字符串。在交互式 shell 中输入以下内容以查看示例:

>>> 'mississippi'.replace('s', 'X')
'miXXiXXippi'
>>> 'dog'.replace('d', 'bl')
'blog'
>>> 'jogger'.replace('ger', 's')
'jogs'

我们将在simpleSubHacker.py程序的decryptMessage()中使用replace()字符串方法。

解密消息

为了解密我们的消息,我们将使用已经在simplesubstitutioncipher.py中编程的函数simpleSubstitutionCipher .decryptMessage()。但是simpleSubstitutionCipher.decryptMessage()只使用密钥解密,不使用字母映射,所以我们不能直接使用函数。为了解决这个问题,我们将创建一个decryptWithCipherletterMapping()函数,它接受一个字母映射,将映射转换成一个密钥,然后将密钥和消息传递给simpleSubstitutionCipher.decryptMessage()。函数decryptWithCipherletterMapping()将返回一个解密的字符串。回想一下,简单替换密钥是 26 个字符的字符串,密钥字符串中索引0处的字符是 A 的加密字符,索引1处的字符是 B 的加密字符,依此类推。

为了将一个映射转换成我们容易阅读的解密输出,我们需要首先创建一个占位符密钥,它看起来像这样:['x', 'x', 'x', 'x', 'x', 'x', 'x', 'x', 'x', 'x', 'x', 'x', 'x', 'x', 'x', 'x', 'x', 'x', 'x', 'x', 'x', 'x', 'x', 'x', 'x', 'x']。小写的'x'可以用在占位符密钥中,因为实际的密钥只使用大写字母。(您可以使用任何不是大写字母的字符作为占位符。)因为不是所有的字母都有解密,我们需要能够区分密钥列表中已经填充了解密字母的部分和解密还没有解决的部分。' x'表示尚未解决的字母。

让我们看看这些是如何在源代码中组合在一起的:

def decryptWithCipherletterMapping(ciphertext, letterMapping):
    # Return a string of the ciphertext decrypted with the letter mapping,
    # with any ambiguous decrypted letters replaced with an underscore.

    # First create a simple sub key from the letterMapping mapping:
    key = ['x'] * len(LETTERS)
    for cipherletter in LETTERS:
        if len(letterMapping[cipherletter]) == 1:
            # If there's only one letter, add it to the key:
            keyIndex = LETTERS.find(letterMapping[cipherletter][0])
            key[keyIndex] = cipherletter

第 140 行通过将单项式列表['x']复制 26 次来创建占位符列表。因为LETTERS是字母表中的一串字母,len(LETTERS)的计算结果是26。当用于列表和整数时,乘法运算符(*)执行列表复制。

第 141 行的for循环检查LETTERS中的每个字母是否是cipherletter变量,如果密码字母被求解(即letterMapping[cipherletter]中只有一个字母),它就用那个字母替换'x'占位符。

第 144 行的letterMapping[cipherletter][0]是解密函,keyIndex是从find()调用返回的LETTERS中解密函的索引。第 145 行将密钥列表中的这个索引设置为解密字母。

但是,如果密码字母没有解,该函数会为该密码字母插入一个下划线,以指示哪些字符仍然没有解。第 147 行用下划线替换了cipherletter中的小写字母,第 148 行用下划线替换了大写字母:

        else:
            ciphertext = ciphertext.replace(cipherletter.lower(), '_')
            ciphertext = ciphertext.replace(cipherletter.upper(), '_')

在用已求解的字母替换了key中列表的所有部分后,该函数使用join()方法将字符串列表合并成一个单独的字符串,以创建一个简单的替换密钥。这个字符串被传递给simpleSubCipher.py程序中的decryptMessage()函数。

    key = ''.join(key)

    # With the key we've created, decrypt the ciphertext:
    return simpleSubCipher.decryptMessage(key, ciphertext)

最后,第 152 行从decryptMessage ()函数返回解密的消息字符串。现在,我们已经拥有了查找相交字母映射、破解密钥和解密消息所需的所有函数。让我们看一个简单的例子,看看这些函数在交互式 shell 中是如何工作的。

在交互 Shell 中解密

让我们回到我们在第 235 页的的“字母映射帮助函数如何工作”中使用的例子。我们将使用我们在前面的 shell 示例中创建的intersectedMapping变量来解密密文消息'OLQIHXIRCKGNZ PLQRZKBZB MPBKSSIPLC'

在交互式 shell 中输入以下内容:

>>> simpleSubHacker.decryptWithCipherletterMapping('OLQIHXIRCKGNZ PLQRZKBZB
MPBKSSIPLC', intersectedMapping)
UNCOMFORTABLE INCREASES DISAPPOINT

密文解密到消息“不舒服增加失望”。正如您所看到的,decryptWithCipherletterMapping()函数运行良好,并返回了完全解密的字符串。但是这个例子没有显示当我们没有解决密文中出现的所有字母时会发生什么。为了看看当我们错过一个密码字母的解密时会发生什么,让我们通过使用下面的指令从intersectedMapping中删除密码字母'M''S'的解:

>>> intersectedMapping['M'] = []
>>> intersectedMapping['S'] = []

然后再次尝试用intersectedMapping解密密文:

>>> simpleSubHacker.decryptWithCipherletterMapping('OLQIHXIRCKGNZ PLQRZKBZB
MPBKSSIPLC', intersectedMapping)
UNCOMFORTABLE INCREASES _ISA__OINT

这一次,部分密文没有被解密。没有解密字母的密码字母被替换为下划线。

这是一段很短的密文,很难破解。通常,加密的消息会更长。(这个例子被特别选择为可破解的。像这个例子这样短的消息通常无法使用单词模式方法破解。)要破解更长的加密,您需要为更长消息中的每个密码创建一个密码映射,然后将它们交叉在一起。hackSimpleSub()函数调用我们程序中的其他函数来完成这个任务。

调用main()函数

第 155 和 156 行调用main()函数来运行simpleSubHacker.py,如果它是直接运行的,而不是被另一个 Python 程序作为模块导入的话:

if __name__ == '__main__':
    main()

这就完成了我们对simpleSubHacker.py程序使用的所有函数的讨论。

我们的黑客方法只有在空间没有加密的情况下才有效。您可以扩展符号集,这样密码程序就可以加密空格、数字和标点符号以及字母,使您的加密消息更难(但不是不可能)破解。破解这样的信息不仅要更新字母的频率,还要更新符号集中所有符号的频率。这使得破解更加复杂,这也是本书只加密字母的原因。

总结

咻!这个simpleSubHacker.py程序相当复杂。您了解了如何使用密码字母映射来为每个密文字母建模可能的解密字母。您还了解了如何通过向映射中添加潜在的字母、使它们相交以及从其他潜在的解密字母列表中删除已求解的字母来缩小可能的密钥数量。您可以使用一些复杂的 Python 代码来找出原始简单替换密钥的大部分(如果不是全部),而不是强行找到 403,291,461,126,605,635,584,000,000 个可能的密钥。

简单替换密码的主要优点是它有大量可能的密钥。缺点是比较密码和字典文件中的单词来确定哪个密码解密成哪个字母相对容易。在第 18 章中,我们将探索一种更强大的多字母替换密码,称为维吉尼亚密码,这种密码几百年来都被认为是无法破解的。

练习题

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

  1. hello这个词的单词模式是什么?

  2. mammothgoggles的单词模式一样吗?

  3. 哪个单词可能是密码PYYACAO的明文单词?Allegedefficiently,还是poodle