使用 Python 學習和破解古典密碼

Xat_MassacrE發表於2017-09-24

之前在研究一些數字貨幣的時候有一個概念深深的吸引了我,那就是零知識證明,它指的是證明者能夠在不向驗證者提供任何有用的資訊的情況下,使驗證者相信某個論斷是正確的。通俗的講就是我有一個 secret_key,但是我不會把這個 secret_key 提供給驗證者,而讓驗證者相信我知道這個 secret_key。很神奇吧,但是我們今天並不是要說零知識證明,而是從密碼學最基礎的地方說起。對零知識證明感興趣的同學可以去看看 zkSNARKs in a nutshell

古典密碼學雖然在現在看起來非常簡單,但是對於構建密碼的原理和一些解決問題的方法上仍然值得我們學習。今天我們就使用 Python 來對兩個著名的加密演算法進行加解密和破解。本文原始碼在這裡獲取。

凱撒密碼(Caesar Cipher)

介紹

凱撒密碼屬於替換密碼的一種,替換密碼就是指用一個別的字母來替換當前的字母。比如我和對方約定一個替換表: l -> h,o -> a,v -> t,然後我傳送love給對方,對方按照對照表就知道我傳送的其實是hate。凱撒密碼使用的是將正常的 26 個英文字母進行移位替換,通常設定 shift 值為 3,相當於 a -> d,b -> e,c -> f...

加解密方法(encrypt,decrypt)

下面給出加解密實現:

import string

lowercase = string.ascii_lowercase

def substitution(text, key_table):
    text = text.lower()
    result = ''
    for l in text:
        i = lowercase.find(l)
        if i < 0:
            result += l
        else:
            result += key_table[i]
    return result

def caesar_cypher_encrypt(text, shift):
    key_table = lowercase[shift:] + lowercase[:shift]
    return substitution(text, key_table)

def caesar_cypher_decrypt(text, shift):
    return caesar_cypher_encrypt(text, -shift)複製程式碼

為了看起來比較容易,所以在方法中把密文的空格和標點符號都保留了下來。

今天的兩個例子都會使用下面這段經典密文(德國在一戰期間邀請墨西哥進攻美國的密文,源自齊默爾曼電報事件)進行演示:

We intend to begin on the first of February unrestricted submarine warfare. We shall endeavor in spite of this to keep the United States of America neutral. In the event of this not succeeding, we make Mexico a proposal of alliance on the following basis: make war together, make peace together, generous financial support and an understanding on our part that Mexico is to reconquer the lost territory in Texas, New Mexico, and Arizona. The settlement in detail is left to you. You will inform the President of the above most secretly as soon as the outbreak of war with the United States of America is certain and add the suggestion that he should, on his own initiative, invite Japan to immediate adherence and at the same time mediate between Japan and ourselves. Please call the President's attention to the fact that the ruthless employment of our submarines now offers the prospect of compelling England in a few months to make peace.

使用caesar_cypher_encrypt(text, shift)我們會得到:

zh lqwhqg wr ehjlq rq wkh iluvw ri iheuxdub xquhvwulfwhg vxepdulqh zduiduh. zh vkdoo hqghdyru lq vslwh ri wklv wr nhhs wkh xqlwhg vwdwhv ri dphulfd qhxwudo. lq wkh hyhqw ri wklv qrw vxffhhglqj, zh pdnh phalfr d sursrvdo ri dooldqfh rq wkh iroorzlqj edvlv: pdnh zdu wrjhwkhu, pdnh shdfh wrjhwkhu, jhqhurxv ilqdqfldo vxssruw dqg dq xqghuvwdqglqj rq rxu sduw wkdw phalfr lv wr uhfrqtxhu wkh orvw whuulwrub lq whadv, qhz phalfr, dqg dulcrqd. wkh vhwwohphqw lq ghwdlo lv ohiw wr brx. brx zloo lqirup wkh suhvlghqw ri wkh deryh prvw vhfuhwob dv vrrq dv wkh rxweuhdn ri zdu zlwk wkh xqlwhg vwdwhv ri dphulfd lv fhuwdlq dqg dgg wkh vxjjhvwlrq wkdw kh vkrxog, rq klv rzq lqlwldwlyh, lqylwh mdsdq wr lpphgldwh dgkhuhqfh dqg dw wkh vdph wlph phgldwh ehwzhhq mdsdq dqg rxuvhoyhv. sohdvh fdoo wkh suhvlghqwv dwwhqwlrq wr wkh idfw wkdw wkh uxwkohvv hpsorbphqw ri rxu vxepdulqhv qrz riihuv wkh survshfw ri frpshoolqj hqjodqg lq d ihz prqwkv wr pdnh shdfh.

注:英國情報局截獲的密文並不是這樣使用凱撒密碼加密的

破解

如果當時你截獲到這樣一份電報你會怎麼想?在短暫的一面懵逼之後,顯然要想辦法破解出正常的意思才行。破解的思路其實也非常簡單:因為加密時可以人為設定 shift 值,那麼我們就從 0-25 迴圈來一遍看看哪個是有意義的就行了:

def crack_caesar_cypher(text):
    for i in range(26):
        key_table = lowercase[-i:] + lowercase[:-i]
        print(substitution(text, key_table)[:12], '| shift is ', i, )複製程式碼

看看結果:

哪一行是有意義的呢?

維吉尼亞密碼(Vigener cipher)

介紹

凱撒密碼顯然無法阻擋人類的智慧,其實正常的替換密碼的空間大小為 26!,這個數字非常大,相當於 2 的 88 次方,就是隨機替換字母表生成加密表,然後按照加密表進行加密和解密,但是在字頻分析下也敗下陣來。然後就出現了維吉尼亞密碼。維吉尼亞密碼的加密方式也非常簡單,先設定一個key = 'crypto',然後將key迴圈排列與原資訊按照字母一一對應,然後將字母在字母表中的所在位置進行相加得到一個index,然後將 index 模 26,得到加密後的字母在字母表中的位置。例如:

we intend to
cr yptocr yp

相當於:

22 4 8 13 19 4 13 3 19 14
2 17 24 15 19 14 2 17 24 15

上下相加我們就得到:

24 21 32 28 38 18 15 20 43 29 再模 26 得到:
24 21 6 2 12 18 15 20 17 3

然後對應字母表我們得到:

yv gcmspu rd

加解密方法(encrypt,decrypt)

下面給出加解密實現:

def insert_letter(text, i, l):
    return text[:i] + l + text[i:]

def get_blank_record(text):
    text = text.lower()
    blank_record = []
    for i in range(len(text)):
        l = text[i]
        item = []
        if lowercase.find(l) < 0:
            item.append(i)
            item.append(l)
            blank_record.append(item)
    return blank_record

def restore_blank_record(text, blank_record):
    for i in blank_record:
        text = insert_letter(text, i[0], i[1])
    return text

def get_vigener_key_table(text, key):
    text = text.lower()
    trim_text = ''
    for l in text:
        if lowercase.find(l) >= 0:
            trim_text += l

    total_length = len(trim_text)
    key_length = len(key)
    quotient = total_length // key_length
    reminder = total_length % key_length
    key_table = quotient * key + key[:reminder]

    return trim_text, key_table

def vigener_cypher_encrypt(text, key, is_encrypt=True):
    blank_record = get_blank_record(text)
    trim_text, key_table = get_vigener_key_table(text, key)

    result = ''
    for i in range(len(trim_text)):
        l = trim_text[i]
        index_lowercase = lowercase.find(l)
        index_key_table = lowercase.find(key_table[i])
        if not is_encrypt:
            index_key_table = -index_key_table
        result += lowercase[(index_lowercase + index_key_table) % 26]

    return restore_blank_record(result, blank_record)

def vigener_cypher_decrypt(text, key):
    return vigener_cypher_encrypt(text, key, False)複製程式碼

使用vigener_cypher_encrypt(text, key)我們會得到:

yv gcmspu rd usizl dg hjv dxkgv fd uxptlygr ipichmfktrtw gwskpkwpv upktcic. lx gjrja xbfvykhf ke qebhg fd iawu km zxsr kft nbkkcs lhckch ht cdcgbqc ecjmfcc. gc mvg vttgh qw rwbg pfr hnqevcsbbi, nc btyg dcmbqq r nghdqjya ht ccjxtbev mc mvg wmaecyzlv uouzq: btyg nyg mcivrwxf, orit isctc ihugkftk, ugecghiu wgctbezya lirgmgm opu yc nbfvphmopugcz cp fsg iotk rwth ovvxvc kj rd kseflfnst kft ecuk rtkfkkmgr wp kcmtg, pvu bxlktm, pgr cigohbc. kft lsvkjtfspk gc wsvrga bg nvdi mc afs. nhi yzja bbhfpb mvg gptlwfvli ht vyc pucxv kdlh uvagxhnp yh lcqe yh mvg fsiufgri dy kci uxmv vyc jgwvvb hmovvq dy oovpxvo kj atkhczl pgr cub ias ulevxgvzmc mvck ft lvqljs, hb jzq dpb kegibovztt, bbxzrt corrl ih wodcsbovv ysastvlrx opu yi mvg jybx hkdc bxrkrrt usvnctg xcgyc tbf fsglsnmch. izgrqt vonc rwx dtvqxwspkq pmhgerxhb vf rwx tctr iaov kft kivyjtlg gdnahmovli ht qlp hnporpxgsu eml hthvph mvg gpdldgtr dy qqdntezkee tgunrls bb c wcl fcpkfh mc orit isctc.

為了看起來比較容易,所以在方法中把密文的空格和標點符號都保留了下來。

上面的密文看起來似乎與凱撒密碼差不多,但是這種加密方法在當時很難被破解。

破解

這種加密特點在於同樣的字母加密之後並不會指向同樣的密文,比如前3個單詞we intent to中的 t,在凱撒密碼或者常規的替換密碼的下都會對應一個特定的字母,但是在這裡我們可以看到intent中的t加密為mto中的t加密為r。這樣的話,字頻分析也不起作用了。

但是道高一尺魔高一丈,這個號稱不能被破解的密碼還是迎來了被破解的命運:

假設我知道這個 key 的長度為 6。那麼我們把密文以 6 個一組進行切割,那麼一組中每個字母對應的原字母出現的的頻率就是符合字頻分析的,所謂的字頻分析就是:26 個英文字母在句子中出現的頻率

然後我們開始對每組的字頻進行統計:

group 0 : [{'c': 17}, {'g': 16}, {'v': 14}, {'p': 13}, {'k': 11}, {'o': 7}, {'q': 7}]
group 1 : [{'v': 23}, {'k': 17}, {'r': 11}, {'f': 10}, {'z': 10}, {'e': 8}, {'d': 6}]
group 2 : [{'c': 20}, {'r': 15}, {'y': 12}, {'l': 9}, {'g': 8}, {'m': 8}, {'p': 8}]
group 3 : [{'t': 22}, {'h': 11}, {'i': 11}, {'g': 10}, {'c': 9}, {'d': 9}, {'x': 8}]
group 4 : [{'m': 18}, {'h': 15}, {'x': 13}, {'b': 11}, {'l': 10}, {'g': 8}, {'k': 8}]
group 5 : [{'s': 16}, {'b': 14}, {'o': 13}, {'c': 10}, {'h': 10}, {'v': 9}, {'g': 8}]

根據字母頻率表我們發現,字母e出現的頻率超過了 12%,所以我們假設每組中至少有一個是字母e對應的密文,然後遍歷出現次數超過10次的字母,並對另外兩個出現頻率最高的的字元陣列['t', 'a']進行檢測記分,然後得出最可能的的key

下面是程式碼:

def get_trim_text(text):
    text = text.lower()
    trim_text = ''
    for l in text:
        if lowercase.find(l) >= 0:
            trim_text += l
    return trim_text

def crack_vigener_cypher(text, key_length):
    blank_record = get_blank_record(text)
    trim_text = get_trim_text(text)
    group = ['' for i in range(key_length)]
    for i in range(len(trim_text)):
        l = trim_text[i]
        for j in range(key_length):
            if i % key_length == j:
                group[j] += l

    key = ''
    letter_stats_group = []
    for j in range(key_length):
        letter_stats = []
        for l in lowercase:
            lt = {}
            count = group[j].count(l)
            lt[l] = count
            letter_stats.append(lt)

        letter_stats = sorted(letter_stats, key=lambda x: list(x.values())[0], reverse=True)
        letter_stats_group.append(letter_stats)
        # print('group', j, ':', letter_stats[:8])

        # gvctxs
        score_list = []
        for i in range(3):
            current_letter = list(letter_stats[i].keys())[0]
            index = lowercase.find(current_letter)
            key_letter = lowercase[index - lowercase.find('e')]
            item = []
            item.append(key_letter)
            score = 0
            for k in range(3):
                vl = list(letter_stats[k].keys())[0]
                for fl in ['t', 'a']:
                    #if i == 1 and (k == 1 or k == 2) and j == 1:
                    if (lowercase.find(key_letter) + lowercase.find(fl)) % 26 == lowercase.find(vl):
                        score += 1
            item.append(score)
            score_list.append(item)
        score_list = sorted(score_list, key=lambda x: x[1], reverse=True)
        key += score_list[0][0]

    plain_text = vigener_cypher_decrypt(trim_text, key)
    return restore_blank_record(plain_text, blank_record)複製程式碼

然後我們通過執行crack_vigener_cypher(cypher_text, 6)就可以得到明文了。然而這個地方大家可以看到,我預設傳了一個引數 6 進去,也就是 key 的長度。但是當我們截獲到密文的時候連 key 都不知道,顯然也不會知道 key 的長度了。所以如果我們可以確定 key 的長度,那麼維吉尼亞密碼在我們面前就是紙老虎了。

實際上,像凱撒密碼一樣,從 1 到 25 去試也是可以破解的,但是我們這裡使用一個叫做重合指數的方法來幫助縮小範圍(雖然實際效果並不好,但是還是要理解一下方法)。

重合指數法:由26個字母構成的一段有意義文字中,任取兩個元素剛好相同的概率約為0.067,所以如果一段明文是用同一個字母加密的話,這個概率依然不會改變。

因此我們可以寫個方法來計算重合指數:


def get_coincidence_index(text):
    trim_text = get_trim_text(text)
    length = len(trim_text)
    letter_stats = []
    for l in lowercase:
        lt = {}
        count = trim_text.count(l)
        lt[l] = count
        letter_stats.append(lt)

    index = 0
    for d in letter_stats:
        v = list(d.values())[0]
        index += (v/length) ** 2

    return index複製程式碼

然後我們假設不同的 key 的長度,對每組密文進行重合指數的計算,然後選出和0.067相比方差較小的一部分長度作為備選:

def get_var(data, mean=0.067):
    if not data:
        return 0
    var_sum = 0
    for d in data:
        var_sum += (d - mean) ** 2

    return var_sum / len(data)

def get_key_length(text):
    trim_text = get_trim_text(text)
    # assume text length less than 26
    group = []
    for n in range(1, 26):
        group_str = ['' for i in range(n)]
        for i in range(len(trim_text)):
            l = trim_text[i]
            for j in range(n):
                if i % n == j:
                    group_str[j] += l
        group.append(group_str)

    var_list = []
    length = 1
    for text in group:
        data = []
        for t in text:
            index = get_coincidence_index(t)
            data.append(index)
        var_list.append([length, get_var(data)])
        length += 1
    var_list = sorted(var_list, key=lambda x: x[1])
    return [v[0] for v in var_list[:12]]複製程式碼

[[16, 2.9408699990533694e-05], [9, 3.8755178005797864e-05], [10, 5.415541187702294e-05], [17, 5.5154370298645345e-05], [19, 6.055960902865477e-05], [13, 8.572621594737114e-05], [8, 8.734954157595401e-05], [14, 9.767402405996375e-05], [3, 0.00010208649957333127], [20, 0.00012009258116435503], [11, 0.0001230940714957638], [6, 0.00014687184215509398]]
正確長度在第12個,我也很絕望啊...

現在萬事具備了,直接遍歷備選的 key_length,然後看看解密之後的效果吧。

12 行,應該一眼就可以看到答案了吧。

本文到此結束,歡迎批評指正。

相關文章