Bits of tinkering
[cipher-training.git] / cipherbreak.py
index 17df97af0d7ee6769a8789ec1eee74a92a369ef8..6e08f49b05f327e24481734b22d115f727048dd9 100644 (file)
@@ -2,13 +2,20 @@ import string
 import collections
 import norms
 import logging
-from itertools import zip_longest, cycle, permutations
+import random
+from itertools import zip_longest, cycle, permutations, starmap
 from segment import segment
 from multiprocessing import Pool
 from math import log10
 
 import matplotlib.pyplot as plt
 
+logger = logging.getLogger(__name__)
+logger.addHandler(logging.FileHandler('cipher.log'))
+logger.setLevel(logging.WARNING)
+#logger.setLevel(logging.INFO)
+#logger.setLevel(logging.DEBUG)
+
 from cipher import *
 from language_models import *
 
@@ -125,14 +132,14 @@ def keyword_break(message, wordlist=keywords, fitness=Pletters):
     frequency analysis
 
     >>> keyword_break(keyword_encipher('this is a test message for the ' \
-          'keyword decipherment', 'elephant', 1), \
+          'keyword decipherment', 'elephant', Keyword_wrap_alphabet.from_last), \
           wordlist=['cat', 'elephant', 'kangaroo']) # doctest: +ELLIPSIS
-    (('elephant', 1), -52.834575011...)
+    (('elephant', <Keyword_wrap_alphabet.from_last: 2>), -52.834575011...)
     """
     best_keyword = ''
     best_wrap_alphabet = True
     best_fit = float("-inf")
-    for wrap_alphabet in range(3):
+    for wrap_alphabet in Keyword_wrap_alphabet:
         for keyword in wordlist:
             plaintext = keyword_decipher(message, keyword, wrap_alphabet)
             fit = fitness(plaintext)
@@ -156,13 +163,14 @@ def keyword_break_mp(message, wordlist=keywords, fitness=Pletters, chunksize=500
     frequency analysis
 
     >>> keyword_break_mp(keyword_encipher('this is a test message for the ' \
-          'keyword decipherment', 'elephant', 1), \
+          'keyword decipherment', 'elephant', Keyword_wrap_alphabet.from_last), \
           wordlist=['cat', 'elephant', 'kangaroo']) # doctest: +ELLIPSIS
-    (('elephant', 1), -52.834575011...)
+    (('elephant', <Keyword_wrap_alphabet.from_last: 2>), -52.834575011...)
     """
     with Pool() as pool:
         helper_args = [(message, word, wrap, fitness) 
-                       for word in wordlist for wrap in range(3)]
+                       for word in wordlist 
+                       for wrap in Keyword_wrap_alphabet]
         # Gotcha: the helper function here needs to be defined at the top level 
         #   (limitation of Pool.starmap)
         breaks = pool.starmap(keyword_break_worker, helper_args, chunksize) 
@@ -176,67 +184,88 @@ def keyword_break_worker(message, keyword, wrap_alphabet, fitness):
                      wrap_alphabet, fit, sanitise(plaintext)[:50]))
     return (keyword, wrap_alphabet), fit
 
-def scytale_break(message, fitness=Pbigrams):
-    """Breaks a Scytale cipher
-    
-    >>> scytale_break('tfeulchtrtteehwahsdehneoifeayfsondmwpltmaoalhikotoere' \
-           'dcweatehiplwxsnhooacgorrcrcraotohsgullasenylrendaianeplscdriioto' \
-           'aek') # doctest: +ELLIPSIS
-    (6, -281.276219108...)
-    """
-    best_key = 0
-    best_fit = float("-inf")
-    for key in range(1, 20):
-        if len(message) % key == 0:
-            plaintext = scytale_decipher(message, key)
-            fit = fitness(sanitise(plaintext))
-            logger.debug('Scytale break attempt using key {0} gives fit of '
-                         '{1} and decrypt starting: {2}'.format(key, 
-                             fit, sanitise(plaintext)[:50]))
-            if fit > best_fit:
-                best_fit = fit
-                best_key = key
-    logger.info('Scytale break best fit with key {0} gives fit of {1} and '
-                'decrypt starting: {2}'.format(best_key, best_fit, 
-                    sanitise(scytale_decipher(message, best_key))[:50]))
-    return best_key, best_fit
+def monoalphabetic_break_hillclimbing(message, max_iterations = 10000000, 
+        fitness=Pletters):
+    ciphertext = unaccent(message).lower()
+    alphabet = list(string.ascii_lowercase)
+    random.shuffle(alphabet)
+    alphabet = ''.join(alphabet)
+    return monoalphabetic_break_hillclimbing_worker(ciphertext, alphabet,
+        max_iterations, fitness)
+
+def monoalphabetic_break_hillclimbing_mp(message, workers=10, 
+        max_iterations = 10000000, fitness=Pletters, chunksize=1):
+    worker_args = []
+    ciphertext = unaccent(message).lower()
+    for i in range(workers):
+        alphabet = list(string.ascii_lowercase)
+        random.shuffle(alphabet)
+        alphabet = ''.join(alphabet)
+        worker_args.append((ciphertext, alphabet, max_iterations, fitness))
+    with Pool() as pool:
+        breaks = pool.starmap(monoalphabetic_break_hillclimbing_worker,
+            worker_args, chunksize)
+    return max(breaks, key=lambda k: k[1])
+
+def monoalphabetic_break_hillclimbing_worker(message, alphabet, 
+        max_iterations, fitness):
+    def swap(letters, i, j):
+        if i > j:
+            i, j = j, i
+        if i == j:
+            return letters
+        else:
+            return letters[:i] + letters[j] + letters[i+1:j] + letters[i] + letters[j+1:]
+    best_alphabet = alphabet
+    best_fitness = float('-inf')
+    for i in range(max_iterations):
+        alphabet = swap(alphabet, random.randrange(26), random.randrange(26))
+        cipher_translation = ''.maketrans(string.ascii_lowercase, alphabet)
+        plaintext = message.translate(cipher_translation)
+        if fitness(plaintext) > best_fitness:
+            best_fitness = fitness(plaintext)
+            best_alphabet = alphabet
+            print(i, best_alphabet, best_fitness, plaintext)
+    return best_alphabet, best_fitness
 
 
 def column_transposition_break_mp(message, translist=transpositions, 
                      fitness=Pbigrams, chunksize=500):
     """Breaks a column transposition cipher using a dictionary and 
     n-gram frequency analysis
+
+    >>> column_transposition_break_mp(column_transposition_encipher(sanitise( \
+            "It is a truth universally acknowledged, that a single man in \
+             possession of a good fortune, must be in want of a wife. However \
+             little known the feelings or views of such a man may be on his \
+             first entering a neighbourhood, this truth is so well fixed in the \
+             minds of the surrounding families, that he is considered the \
+             rightful property of some one or other of their daughters."), \
+        'encipher'), \
+        translist={(2, 0, 5, 3, 1, 4, 6): ['encipher'], \
+                   (5, 0, 6, 1, 3, 4, 2): ['fourteen'], \
+                   (6, 1, 0, 4, 5, 3, 2): ['keyword']}) # doctest: +ELLIPSIS
+    (((2, 0, 5, 3, 1, 4, 6), False, False), -709.4646722...)
+    >>> column_transposition_break_mp(column_transposition_encipher(sanitise( \
+            "It is a truth universally acknowledged, that a single man in \
+             possession of a good fortune, must be in want of a wife. However \
+             little known the feelings or views of such a man may be on his \
+             first entering a neighbourhood, this truth is so well fixed in the \
+             minds of the surrounding families, that he is considered the \
+             rightful property of some one or other of their daughters."), \
+        'encipher'), \
+        translist={(2, 0, 5, 3, 1, 4, 6): ['encipher'], \
+                   (5, 0, 6, 1, 3, 4, 2): ['fourteen'], \
+                   (6, 1, 0, 4, 5, 3, 2): ['keyword']}, \
+        fitness=Ptrigrams) # doctest: +ELLIPSIS
+    (((2, 0, 5, 3, 1, 4, 6), False, False), -997.0129085...)
     """
-    # >>> column_transposition_break_mp(column_transposition_encipher(sanitise( \
-    #         "It is a truth universally acknowledged, that a single man in \
-    #          possession of a good fortune, must be in want of a wife. However \
-    #          little known the feelings or views of such a man may be on his \
-    #          first entering a neighbourhood, this truth is so well fixed in the \
-    #          minds of the surrounding families, that he is considered the \
-    #          rightful property of some one or other of their daughters."), \
-    #     'encipher'), \
-    #     translist={(2, 0, 5, 3, 1, 4, 6): ['encipher'], \
-    #                (5, 0, 6, 1, 3, 4, 2): ['fourteen'], \
-    #                (6, 1, 0, 4, 5, 3, 2): ['keyword']}) # doctest: +ELLIPSIS
-    # (((2, 0, 5, 3, 1, 4, 6), False), 0.0628106372...)
-    # >>> column_transposition_break_mp(column_transposition_encipher(sanitise( \
-    #         "It is a truth universally acknowledged, that a single man in \
-    #          possession of a good fortune, must be in want of a wife. However \
-    #          little known the feelings or views of such a man may be on his \
-    #          first entering a neighbourhood, this truth is so well fixed in the \
-    #          minds of the surrounding families, that he is considered the \
-    #          rightful property of some one or other of their daughters."), \
-    #     'encipher'), \
-    #     translist={(2, 0, 5, 3, 1, 4, 6): ['encipher'], \
-    #                (5, 0, 6, 1, 3, 4, 2): ['fourteen'], \
-    #                (6, 1, 0, 4, 5, 3, 2): ['keyword']}, \
-    #     target_counts=normalised_english_trigram_counts) # doctest: +ELLIPSIS
-    # (((2, 0, 5, 3, 1, 4, 6), False), 0.0592259560...)
-    # """
     with Pool() as pool:
-        helper_args = [(message, trans, columnwise, fitness) 
+        helper_args = [(message, trans, fillcolumnwise, emptycolumnwise, 
+                          fitness) 
                        for trans in translist.keys() 
-                       for columnwise in [True, False]]
+                       for fillcolumnwise in [True, False]
+                       for emptycolumnwise in [True, False]]
         # Gotcha: the helper function here needs to be defined at the top level 
         #   (limitation of Pool.starmap)
         breaks = pool.starmap(column_transposition_break_worker, 
@@ -244,64 +273,56 @@ def column_transposition_break_mp(message, translist=transpositions,
         return max(breaks, key=lambda k: k[1])
 column_transposition_break = column_transposition_break_mp
 
-def column_transposition_break_worker(message, transposition, columnwise, 
-                                        fitness):
-    plaintext = column_transposition_decipher(message, transposition, columnwise=columnwise)
+def column_transposition_break_worker(message, transposition, 
+        fillcolumnwise, emptycolumnwise, fitness):
+    plaintext = column_transposition_decipher(message, transposition, 
+        fillcolumnwise=fillcolumnwise, emptycolumnwise=emptycolumnwise)
     fit = fitness(sanitise(plaintext))
     logger.debug('Column transposition break attempt using key {0} '
                          'gives fit of {1} and decrypt starting: {2}'.format(
                              transposition, fit, 
                              sanitise(plaintext)[:50]))
-    return (transposition, columnwise), fit
-
-
-def transposition_break_exhaustive(message, fitness=Pbigrams):
-    best_transposition = ''
-    best_pw = float('-inf')
-    for keylength in range(1, 21):
-        if len(message) % keylength == 0:
-            for transposition in permutations(range(keylength)):
-                for columnwise in [True, False]:
-                    plaintext = column_transposition_decipher(message, 
-                        transposition, columnwise=columnwise)
-                    fit=fitness(plaintext)
-                    logger.debug('Column transposition break attempt using key {0} {1} '
-                         'gives fit of {2} and decrypt starting: {3}'.format(
-                             transposition, columnwise, pw, 
-                             sanitise(plaintext)[:50]))
-                    if fit > best_fit:
-                        best_transposition = transposition
-                        best_columnwise = columnwise
-                        best_fit = fit
-    return (best_transposition, best_columnwise), best_pw
+    return (transposition, fillcolumnwise, emptycolumnwise), fit
 
 
-def vigenere_keyword_break(message, wordlist=keywords, fitness=Pletters):
-    """Breaks a vigenere cipher using a dictionary and 
-    frequency analysis
-    
-    >>> vigenere_keyword_break(vigenere_encipher(sanitise('this is a test ' \
-             'message for the vigenere decipherment'), 'cat'), \
-             wordlist=['cat', 'elephant', 'kangaroo']) # doctest: +ELLIPSIS
-    ('cat', -52.947271216...)
+def scytale_break_mp(message, max_key_length=20,
+                     fitness=Pbigrams, chunksize=500):
+    """Breaks a scytale cipher using a range of lengths and
+    n-gram frequency analysis
+
+    >>> scytale_break_mp(scytale_encipher(sanitise( \
+            "It is a truth universally acknowledged, that a single man in \
+             possession of a good fortune, must be in want of a wife. However \
+             little known the feelings or views of such a man may be on his \
+             first entering a neighbourhood, this truth is so well fixed in the \
+             minds of the surrounding families, that he is considered the \
+             rightful property of some one or other of their daughters."), \
+        5)) # doctest: +ELLIPSIS
+    (5, -709.4646722...)
+    >>> scytale_break_mp(scytale_encipher(sanitise( \
+            "It is a truth universally acknowledged, that a single man in \
+             possession of a good fortune, must be in want of a wife. However \
+             little known the feelings or views of such a man may be on his \
+             first entering a neighbourhood, this truth is so well fixed in the \
+             minds of the surrounding families, that he is considered the \
+             rightful property of some one or other of their daughters."), \
+        5), \
+        fitness=Ptrigrams) # doctest: +ELLIPSIS
+    (5, -997.0129085...)
     """
-    best_keyword = ''
-    best_fit = float("-inf")
-    for keyword in wordlist:
-        plaintext = vigenere_decipher(message, keyword)
-        fit = fitness(plaintext)
-        logger.debug('Vigenere break attempt using key {0} '
-                         'gives fit of {1} and decrypt starting: {2}'.format(
-                             keyword, fit, 
-                             sanitise(plaintext)[:50]))
-        if fit > best_fit:
-            best_fit = fit
-            best_keyword = keyword
-    logger.info('Vigenere break best fit with key {0} gives fit '
-                'of {1} and decrypt starting: {2}'.format(best_keyword, 
-                    best_fit, sanitise(
-                        vigenere_decipher(message, best_keyword))[:50]))
-    return best_keyword, best_fit
+    with Pool() as pool:
+        helper_args = [(message, trans, False, True, fitness) 
+            for trans in 
+                [[col for col in range(math.ceil(len(message)/rows))] 
+                    for rows in range(1,max_key_length+1)]]
+        # Gotcha: the helper function here needs to be defined at the top level 
+        #   (limitation of Pool.starmap)
+        breaks = pool.starmap(column_transposition_break_worker, 
+          helper_args, chunksize) 
+        best = max(breaks, key=lambda k: k[1]) 
+        return math.trunc(len(message) / len(best[0][0])), best[1]
+scytale_break = scytale_break_mp
+
 
 def vigenere_keyword_break_mp(message, wordlist=keywords, fitness=Pletters, 
                      chunksize=500):
@@ -320,6 +341,7 @@ def vigenere_keyword_break_mp(message, wordlist=keywords, fitness=Pletters,
         #   (limitation of Pool.starmap)
         breaks = pool.starmap(vigenere_keyword_break_worker, helper_args, chunksize) 
         return max(breaks, key=lambda k: k[1])
+vigenere_keyword_break = vigenere_keyword_break_mp
 
 def vigenere_keyword_break_worker(message, keyword, fitness):
     plaintext = vigenere_decipher(message, keyword)
@@ -331,7 +353,7 @@ def vigenere_keyword_break_worker(message, keyword, fitness):
 
 
 
-def vigenere_frequency_break(message, fitness=Pletters):
+def vigenere_frequency_break(message, max_key_length=20, fitness=Pletters):
     """Breaks a Vigenere cipher with frequency analysis
 
     >>> vigenere_frequency_break(vigenere_encipher(sanitise("It is time to " \
@@ -343,26 +365,19 @@ def vigenere_frequency_break(message, fitness=Pletters):
             "sure"), 'florence')) # doctest: +ELLIPSIS
     ('florence', -307.5473096791...)
     """
-    best_fit = float("-inf")
-    best_key = ''
-    sanitised_message = sanitise(message)
-    for trial_length in range(1, 20):
-        splits = every_nth(sanitised_message, trial_length)
+    def worker(message, key_length, fitness):
+        splits = every_nth(sanitised_message, key_length)
         key = ''.join([chr(caesar_break(s)[0] + ord('a')) for s in splits])
-        plaintext = vigenere_decipher(sanitised_message, key)
+        plaintext = vigenere_decipher(message, key)
         fit = fitness(plaintext)
-        logger.debug('Vigenere key length of {0} ({1}) gives fit of {2}'.
-                     format(trial_length, key, fit))
-        if fit > best_fit:
-            best_fit = fit
-            best_key = key
-    logger.info('Vigenere break best fit with key {0} gives fit '
-                'of {1} and decrypt starting: {2}'.format(best_key, 
-                    best_fit, sanitise(
-                        vigenere_decipher(message, best_key))[:50]))
-    return best_key, best_fit
-
-def beaufort_frequency_break(message, fitness=Pletters):
+        return key, fit
+    sanitised_message = sanitise(message)
+    results = starmap(worker, [(sanitised_message, i, fitness) 
+        for i in range(1, max_key_length+1)])
+    return max(results, key=lambda k: k[1])
+
+
+def beaufort_frequency_break(message, max_key_length=20, fitness=Pletters):
     """Breaks a Beaufort cipher with frequency analysis
 
     >>> beaufort_frequency_break(beaufort_encipher(sanitise("It is time to " \
@@ -374,25 +389,44 @@ def beaufort_frequency_break(message, fitness=Pletters):
             "that he is sure"), 'florence')) # doctest: +ELLIPSIS
     ('florence', -307.5473096791...)
     """
-    best_fit = float("-inf")
-    best_key = ''
-    sanitised_message = sanitise(message)
-    for trial_length in range(1, 20):
-        splits = every_nth(sanitised_message, trial_length)
+    def worker(message, key_length, fitness):
+        splits = every_nth(sanitised_message, key_length)
         key = ''.join([chr(-caesar_break(s)[0] % 26 + ord('a')) for s in splits])
-        plaintext = beaufort_decipher(sanitised_message, key)
+        plaintext = beaufort_decipher(message, key)
         fit = fitness(plaintext)
-        logger.debug('Beaufort key length of {0} ({1}) gives fit of {2}'.
-                     format(trial_length, key, fit))
-        if fit > best_fit:
-            best_fit = fit
-            best_key = key
-    logger.info('Beaufort break best fit with key {0} gives fit '
-                'of {1} and decrypt starting: {2}'.format(best_key, 
-                    best_fit, sanitise(
-                        beaufort_decipher(message, best_key))[:50]))
-    return best_key, best_fit
-
+        return key, fit
+    sanitised_message = sanitise(message)
+    results = starmap(worker, [(sanitised_message, i, fitness) 
+        for i in range(1, max_key_length+1)])
+    return max(results, key=lambda k: k[1])
+
+
+def pocket_enigma_break_by_crib(message, wheel_spec, crib, crib_position):
+    """Break a pocket enigma using a crib (some plaintext that's expected to
+    be in a certain position). Returns a list of possible starting wheel
+    positions that could produce the crib.
+
+    >>> pocket_enigma_break_by_crib('kzpjlzmoga', 1, 'h', 0)
+    ['a', 'f', 'q']
+    >>> pocket_enigma_break_by_crib('kzpjlzmoga', 1, 'he', 0)
+    ['a']
+    >>> pocket_enigma_break_by_crib('kzpjlzmoga', 1, 'll', 2)
+    ['a']
+    >>> pocket_enigma_break_by_crib('kzpjlzmoga', 1, 'l', 2)
+    ['a']
+    >>> pocket_enigma_break_by_crib('kzpjlzmoga', 1, 'l', 3)
+    ['a', 'j', 'n']
+    >>> pocket_enigma_break_by_crib('aaaaa', 1, 'l', 3)
+    []
+    """
+    pe = PocketEnigma(wheel=wheel_spec)
+    possible_positions = []
+    for p in string.ascii_lowercase:
+        pe.set_position(p)
+        plaintext = pe.decipher(message)
+        if plaintext[crib_position:crib_position+len(crib)] == crib:
+            possible_positions += [p]
+    return possible_positions
 
 
 def plot_frequency_histogram(freqs, sort_key=None):