changeset 18:766d8da90e48

Implemented the decrypt procedure.
author Brian Neal <bgneal@gmail.com>
date Sat, 08 Jun 2013 15:03:28 -0500
parents c8027f88443f
children 467295d7807f
files m209/converter.py m209/procedure.py m209/tests/test_procedure.py m209/utils.py
diffstat 4 files changed, 164 insertions(+), 21 deletions(-) [+]
line wrap: on
line diff
--- a/m209/converter.py	Sat Jun 08 12:56:25 2013 -0500
+++ b/m209/converter.py	Sat Jun 08 15:03:28 2013 -0500
@@ -13,6 +13,7 @@
 from .data import KEY_WHEEL_DATA
 from .key_wheel import KeyWheel, KeyWheelError
 from .drum import Drum
+from .utils import group as group_text
 
 M209_ALPHABET_LIST = string.ascii_uppercase
 M209_ALPHABET_SET = set(string.ascii_uppercase)
@@ -168,8 +169,7 @@
             ciphertext.append(self._cipher(p))
 
         if group:
-            s = ' '.join(''.join(ciphertext[i:i+5]) for i in range(0,
-                len(ciphertext), 5))
+            s = group_text(ciphertext)
         else:
             s = ''.join(ciphertext)
         return s
--- a/m209/procedure.py	Sat Jun 08 12:56:25 2013 -0500
+++ b/m209/procedure.py	Sat Jun 08 15:03:28 2013 -0500
@@ -18,20 +18,30 @@
 procedure known to the author.
 
 """
+from collections import namedtuple
 import random
+import re
 
 from . import M209Error
 from .converter import M209, M209_ALPHABET_SET, M209_ALPHABET_LIST
 from .key_wheel import KeyWheelError
+from .utils import group as group_text
 
 
 class ProcedureError(M209Error):
     pass
 
 
-class StdEncryptProcedure:
-    """This class encapsulates the "standard" encrypt procedure for the M-209 as
-    found in the training film T.F. 11 - 1400.
+DecryptParams = namedtuple('DecryptParams',
+                    ['sys_ind', 'ext_msg_ind', 'key_list_ind', 'ciphertext'])
+
+
+MSG_RE = re.compile(r'^([A-Z]{5}) ([A-Z]{5}) ((?:[A-Z]{5} )+)\1 \2$')
+
+
+class StdProcedure:
+    """This class encapsulates the "standard" encrypt/decrypt procedure for the
+    M-209 as found in the training film T.F. 11 - 1400.
 
     The procedure can be configured with an optional M-209, and optional key
     list to be used for the day. If the M-209 is not supplied, one will be
@@ -39,15 +49,36 @@
     list must be supplied. This can be done at construction time or via the
     set_key_list() method.
 
+    To perform a decrypt operation, a 3-step process must be used:
+        1) Call set_decrypt_message(msg) - this passes the message to be
+           decrypted to the procedure and establishes the parameters to be used
+           for the actual decrypt() operation. These decrypt parameters are
+           returned to the caller.
+        2) The caller can examine the decrypt parameters to determine which key
+           list must be installed before a successful decrypt() operation can be
+           carried out. The caller may call get_key_list() to examine the
+           current key list. It is up to the caller to obtain the required key
+           list and install it with set_key_list(), if necessary. This is done
+           by ensuring the installed key list indicator matches the key_list_ind
+           field of the decrypt parameters.
+        3) Finally decrypt() can be called. If the procedure does not have the
+           key list necessary to decrypt the message, a ProcedureError is
+           raised.
+
     """
     def __init__(self, m_209=None, key_list=None):
         self.m_209 = m_209 if m_209 else M209()
+        self.decrypt_params = None
 
         if key_list:
             self.set_key_list(key_list)
 
+    def get_key_list(self):
+        """Returns the currently installed key list object."""
+        return self.key_list
+
     def set_key_list(self, key_list):
-        """Use the supplied key list for all future encrypt operations.
+        """Use the supplied key list for all future encrypt/decrypt operations.
 
         Configure the M209 with the key list parameters.
 
@@ -109,19 +140,10 @@
 
         int_msg_ind = self.m_209.encrypt(sys_ind * 12, group=False)
 
-        # Set wheels to internal message indicator from left to right. We must skip
-        # letters that aren't valid for a given key wheel.
-        it = iter(int_msg_ind)
-        n = 0
-        while n != 6:
-            try:
-                self.m_209.set_key_wheel(n, next(it))
-            except KeyWheelError:
-                pass
-            except StopIteration:
-                assert False, "Ran out of letters building internal message indicator"
-            else:
-                n += 1
+        self.m_209.letter_counter = 0
+
+        # Set the key wheels to the internal message indicator
+        self._set_int_message_indicator(int_msg_ind)
 
         # Now encipher the message on the M209
         ciphertext = self.m_209.encrypt(plaintext, group=group, spaces=spaces)
@@ -146,3 +168,102 @@
         # Assemble the final message; group if requested
         sep = ' ' if group else ''
         return sep.join(msg_parts)
+
+    def set_decrypt_message(self, msg):
+        """Prepare to decrypt the supplied message.
+
+        The messsage can be grouped into 5-letter groups separated by spaces or
+        accepted without spaces.
+
+        Returns a DecryptParams tuple to the caller. The caller should ensure
+        the procedure instance has the required key list before calling decrypt.
+
+        """
+        # See if we need to group the message.
+        if not ' ' in msg:
+            msg = group_text(msg)
+
+        # Perform some basic checks on the message to see if it looks like an
+        # M209 message.
+
+        # Ensure the message passes a regex format check
+        m = MSG_RE.match(msg)
+        if not m:
+            raise ProcedureError("invalid decrypt message format")
+
+        group1 = m.group(1)
+        group2 = m.group(2)
+
+        # Check system indicator is repeated twice
+        if group1[0] != group1[1]:
+            raise ProcedureError("missing system indicator")
+
+        sys_ind = group1[0]
+        ext_msg_ind = group1[2:] + group2[:3]
+        key_list_ind = group2[3:]
+        ciphertext = m.group(3).rstrip()
+
+        self.decrypt_params = DecryptParams(sys_ind=sys_ind,
+                                    ext_msg_ind=ext_msg_ind,
+                                    key_list_ind=key_list_ind,
+                                    ciphertext=ciphertext)
+
+        return self.decrypt_params
+
+    def decrypt(self, z_sub=True):
+        """Decrypt the message set in a previous set_decrypt_message() call. The
+        resulting plaintext is returned as a string.
+
+        If z_sub is True, 'Z' characters in the output plaintext will be
+        replaced by space characters, just like an actual M-209. If z_sub is
+        False, no such substitution will occur.
+
+        A ProcedureError will be raised if the procedure instance has not been
+        configured with the required key list.
+
+        """
+        if not self.decrypt_params:
+            raise ProcedureError("no prior call to set_decrypt_message")
+
+        if not self.key_list or (
+                self.key_list.indicator != self.decrypt_params.key_list_ind):
+            raise ProcedureError("key list '{}' required".format(
+                        self.decrypt_params.key_list_ind))
+
+        # We assume the caller called set_key_list() if necessary, so the M209
+        # has been keyed.
+
+        self.m_209.letter_counter = 0
+        self.m_209.set_key_wheels(self.decrypt_params.ext_msg_ind)
+
+        # Generate internal message indicator
+
+        int_msg_ind = self.m_209.encrypt(self.decrypt_params.sys_ind * 12,
+                                         group=False)
+
+        # set key wheels to internal message indicator
+        self._set_int_message_indicator(int_msg_ind)
+
+        self.m_209.letter_counter = 0
+        plaintext = self.m_209.decrypt(self.decrypt_params.ciphertext,
+                                       spaces=True, z_sub=z_sub)
+        return plaintext
+
+    def _set_int_message_indicator(self, indicator):
+        """Sets the key wheels to the given internal message indicator as per
+        the standard procedure.
+
+        """
+        # Set wheels to internal message indicator from left to right. We must skip
+        # letters that aren't valid for a given key wheel.
+        it = iter(indicator)
+        n = 0
+        while n != 6:
+            try:
+                self.m_209.set_key_wheel(n, next(it))
+            except KeyWheelError:
+                pass
+            except StopIteration:
+                assert False, "Ran out of letters building internal message indicator"
+            else:
+                n += 1
--- a/m209/tests/test_procedure.py	Sat Jun 08 12:56:25 2013 -0500
+++ b/m209/tests/test_procedure.py	Sat Jun 08 15:03:28 2013 -0500
@@ -7,7 +7,7 @@
 import unittest
 
 from ..keylist import KeyList
-from ..procedure import StdEncryptProcedure
+from ..procedure import StdProcedure
 
 
 PLAINTEXT = 'ATTACK AT DAWN'
@@ -28,8 +28,19 @@
                         'ACDFHIMN'
                     ],
                     letter_check='TNMYS CRMKK UHLKW LDQHM RQOLW R')
-        self.proc = StdEncryptProcedure(key_list=self.fm)
+        self.proc = StdProcedure(key_list=self.fm)
 
     def test_encrypt(self):
         result = self.proc.encrypt(PLAINTEXT, ext_msg_ind='ABCDEF', sys_ind='G')
         self.assertEqual(result, CIPHERTEXT)
+
+    def test_decrypt(self):
+        result = self.proc.set_decrypt_message(CIPHERTEXT)
+
+        self.assertEqual(result.sys_ind, 'G')
+        self.assertEqual(result.ext_msg_ind, 'ABCDEF')
+        self.assertEqual(result.key_list_ind, 'FM')
+        self.assertEqual(result.ciphertext, CIPHERTEXT[12:29])
+
+        plaintext = self.proc.decrypt()
+        self.assertEqual(plaintext[:len(PLAINTEXT)], PLAINTEXT)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/m209/utils.py	Sat Jun 08 15:03:28 2013 -0500
@@ -0,0 +1,11 @@
+# Copyright (C) 2013 by Brian Neal.
+# This file is part of m209, the M-209 simulation.
+# m209 is released under the MIT License (see LICENSE.txt).
+
+"""This module contains various utility functions."""
+
+
+def group(text, n=5):
+    """Groups the given text into n-letter groups separated by spaces."""
+
+    return ' '.join(''.join(text[i:i+n]) for i in range(0, len(text), n))