bgneal@15
|
1 # Copyright (C) 2013 by Brian Neal.
|
bgneal@15
|
2 # This file is part of m209, the M-209 simulation.
|
bgneal@15
|
3 # m209 is released under the MIT License (see LICENSE.txt).
|
bgneal@15
|
4
|
bgneal@15
|
5 """This module contains the encrypt & decrypt procedures as described in "War
|
bgneal@15
|
6 Department, Official Training Film, T.F. 11 - 1400, Army, Service Forces."
|
bgneal@15
|
7 A YouTube playlist of this film can be found here:
|
bgneal@15
|
8
|
bgneal@15
|
9 http://www.youtube.com/playlist?list=PLCPgncK_sTnEny2-uoTV-1_GC72zo-vKq
|
bgneal@15
|
10
|
bgneal@15
|
11 This procedure is also described on Mark J. Blair's pages:
|
bgneal@15
|
12
|
bgneal@15
|
13 http://www.nf6x.net/2013/04/practical-use-of-the-m-209-cipher-machine-chapter-4/
|
bgneal@15
|
14 http://www.nf6x.net/2013/04/practical-use-of-the-m-209-cipher-machine-chapter-5/
|
bgneal@15
|
15
|
bgneal@15
|
16 If other procedures are discovered, this module can be expanded to a package and
|
bgneal@15
|
17 the new procedures can be added as different modules. For now, this is the only
|
bgneal@15
|
18 procedure known to the author.
|
bgneal@15
|
19
|
bgneal@15
|
20 """
|
bgneal@18
|
21 from collections import namedtuple
|
bgneal@15
|
22 import random
|
bgneal@18
|
23 import re
|
bgneal@15
|
24
|
bgneal@15
|
25 from . import M209Error
|
bgneal@16
|
26 from .converter import M209, M209_ALPHABET_SET, M209_ALPHABET_LIST
|
bgneal@15
|
27 from .key_wheel import KeyWheelError
|
bgneal@18
|
28 from .utils import group as group_text
|
bgneal@15
|
29
|
bgneal@15
|
30
|
bgneal@15
|
31 class ProcedureError(M209Error):
|
bgneal@15
|
32 pass
|
bgneal@15
|
33
|
bgneal@15
|
34
|
bgneal@18
|
35 DecryptParams = namedtuple('DecryptParams',
|
bgneal@18
|
36 ['sys_ind', 'ext_msg_ind', 'key_list_ind', 'ciphertext'])
|
bgneal@18
|
37
|
bgneal@18
|
38
|
bgneal@18
|
39 MSG_RE = re.compile(r'^([A-Z]{5}) ([A-Z]{5}) ((?:[A-Z]{5} )+)\1 \2$')
|
bgneal@18
|
40
|
bgneal@18
|
41
|
bgneal@18
|
42 class StdProcedure:
|
bgneal@18
|
43 """This class encapsulates the "standard" encrypt/decrypt procedure for the
|
bgneal@18
|
44 M-209 as found in the training film T.F. 11 - 1400.
|
bgneal@15
|
45
|
bgneal@16
|
46 The procedure can be configured with an optional M-209, and optional key
|
bgneal@16
|
47 list to be used for the day. If the M-209 is not supplied, one will be
|
bgneal@16
|
48 created internally. Before an encrypt() operation can be performed, a key
|
bgneal@16
|
49 list must be supplied. This can be done at construction time or via the
|
bgneal@16
|
50 set_key_list() method.
|
bgneal@15
|
51
|
bgneal@18
|
52 To perform a decrypt operation, a 3-step process must be used:
|
bgneal@18
|
53 1) Call set_decrypt_message(msg) - this passes the message to be
|
bgneal@18
|
54 decrypted to the procedure and establishes the parameters to be used
|
bgneal@18
|
55 for the actual decrypt() operation. These decrypt parameters are
|
bgneal@18
|
56 returned to the caller.
|
bgneal@18
|
57 2) The caller can examine the decrypt parameters to determine which key
|
bgneal@18
|
58 list must be installed before a successful decrypt() operation can be
|
bgneal@18
|
59 carried out. The caller may call get_key_list() to examine the
|
bgneal@18
|
60 current key list. It is up to the caller to obtain the required key
|
bgneal@18
|
61 list and install it with set_key_list(), if necessary. This is done
|
bgneal@18
|
62 by ensuring the installed key list indicator matches the key_list_ind
|
bgneal@18
|
63 field of the decrypt parameters.
|
bgneal@18
|
64 3) Finally decrypt() can be called. If the procedure does not have the
|
bgneal@18
|
65 key list necessary to decrypt the message, a ProcedureError is
|
bgneal@18
|
66 raised.
|
bgneal@18
|
67
|
bgneal@15
|
68 """
|
bgneal@16
|
69 def __init__(self, m_209=None, key_list=None):
|
bgneal@16
|
70 self.m_209 = m_209 if m_209 else M209()
|
bgneal@18
|
71 self.decrypt_params = None
|
bgneal@15
|
72
|
bgneal@16
|
73 if key_list:
|
bgneal@16
|
74 self.set_key_list(key_list)
|
bgneal@15
|
75
|
bgneal@18
|
76 def get_key_list(self):
|
bgneal@18
|
77 """Returns the currently installed key list object."""
|
bgneal@18
|
78 return self.key_list
|
bgneal@18
|
79
|
bgneal@16
|
80 def set_key_list(self, key_list):
|
bgneal@18
|
81 """Use the supplied key list for all future encrypt/decrypt operations.
|
bgneal@15
|
82
|
bgneal@16
|
83 Configure the M209 with the key list parameters.
|
bgneal@15
|
84
|
bgneal@16
|
85 """
|
bgneal@16
|
86 if len(key_list.indicator) != 2:
|
bgneal@16
|
87 raise ProcedureError("invalid key list indicator")
|
bgneal@15
|
88
|
bgneal@16
|
89 self.key_list = key_list
|
bgneal@16
|
90 self.m_209.set_drum_lugs(key_list.lugs)
|
bgneal@16
|
91 self.m_209.set_all_pins(key_list.pin_list)
|
bgneal@15
|
92
|
bgneal@16
|
93 def encrypt(self, plaintext, group=True, spaces=True, ext_msg_ind=None, sys_ind=None):
|
bgneal@16
|
94 """Encrypts a plaintext message using standard procedure. The encrypted text
|
bgneal@16
|
95 with the required message indicators are returned as one string.
|
bgneal@15
|
96
|
bgneal@16
|
97 The encrypt function accepts these parameters:
|
bgneal@15
|
98
|
bgneal@16
|
99 plaintext - Input string of text to be encrypted
|
bgneal@16
|
100 group - If True, the resulting encrypted text will be grouped into 5-letter
|
bgneal@16
|
101 groups with a space between each group. If False, no spaces will be
|
bgneal@16
|
102 present in the output.
|
bgneal@16
|
103 spaces - If True, space characters in the input plaintext will automatically
|
bgneal@16
|
104 be replaced with 'Z' characters before encrypting.
|
bgneal@16
|
105 ext_msg_ind - This is the external message indicator, which, if supplied,
|
bgneal@16
|
106 must be a valid 6 letter string of key wheel settings. If not supplied,
|
bgneal@16
|
107 one will be generated randomly.
|
bgneal@16
|
108 sys_ind - This is the system indicator, which must be a string of length
|
bgneal@16
|
109 1 in the range 'A'-'Z', inclusive. If None, one is chosen at random.
|
bgneal@15
|
110
|
bgneal@16
|
111 A ProcedureError will be raised if the procedure does not have a key
|
bgneal@16
|
112 list to work with.
|
bgneal@15
|
113
|
bgneal@16
|
114 """
|
bgneal@16
|
115 # Ensure we have a key list indicator and there is no ambiguity:
|
bgneal@16
|
116
|
bgneal@16
|
117 if not self.key_list:
|
bgneal@16
|
118 raise ProcedureError("encrypt requires a key list")
|
bgneal@16
|
119
|
bgneal@16
|
120 self.m_209.letter_counter = 0
|
bgneal@16
|
121
|
bgneal@16
|
122 # Set key wheels to external message indicator
|
bgneal@16
|
123 if ext_msg_ind:
|
bgneal@16
|
124 try:
|
bgneal@16
|
125 self.m_209.set_key_wheels(ext_msg_ind)
|
bgneal@16
|
126 except M209Error as ex:
|
bgneal@16
|
127 raise M209Error("invalid external message indicator {} - {}".format(
|
bgneal@16
|
128 ext_msg_ind, ex))
|
bgneal@15
|
129 else:
|
bgneal@16
|
130 ext_msg_ind = self.m_209.set_random_key_wheels()
|
bgneal@15
|
131
|
bgneal@16
|
132 # Ensure we have a valid system indicator
|
bgneal@16
|
133 if sys_ind:
|
bgneal@16
|
134 if sys_ind not in M209_ALPHABET_SET:
|
bgneal@16
|
135 raise ProcedureError("invalid system indicator {}".format(sys_ind))
|
bgneal@16
|
136 else:
|
bgneal@16
|
137 sys_ind = random.choice(M209_ALPHABET_LIST)
|
bgneal@15
|
138
|
bgneal@16
|
139 # Generate internal message indicator
|
bgneal@15
|
140
|
bgneal@16
|
141 int_msg_ind = self.m_209.encrypt(sys_ind * 12, group=False)
|
bgneal@15
|
142
|
bgneal@18
|
143 self.m_209.letter_counter = 0
|
bgneal@18
|
144
|
bgneal@18
|
145 # Set the key wheels to the internal message indicator
|
bgneal@18
|
146 self._set_int_message_indicator(int_msg_ind)
|
bgneal@15
|
147
|
bgneal@16
|
148 # Now encipher the message on the M209
|
bgneal@16
|
149 ciphertext = self.m_209.encrypt(plaintext, group=group, spaces=spaces)
|
bgneal@15
|
150
|
bgneal@16
|
151 # If we are grouping, and the final group in the ciphertext has less than
|
bgneal@16
|
152 # 5 letters, pad with X's to make a complete group:
|
bgneal@16
|
153 if group:
|
bgneal@16
|
154 total_len = len(ciphertext)
|
bgneal@16
|
155 num_groups = total_len // 5
|
bgneal@16
|
156 num_spaces = num_groups - 1 if num_groups >= 2 else 0
|
bgneal@16
|
157 x_count = 5 - (total_len - num_spaces) % 5
|
bgneal@16
|
158 if 0 < x_count < 5:
|
bgneal@16
|
159 ciphertext = ciphertext + 'X' * x_count
|
bgneal@16
|
160
|
bgneal@16
|
161 # Add the message indicators to pad each end of the message
|
bgneal@16
|
162
|
bgneal@16
|
163 pad1 = sys_ind * 2 + ext_msg_ind[:3]
|
bgneal@16
|
164 pad2 = ext_msg_ind[3:] + self.key_list.indicator
|
bgneal@16
|
165
|
bgneal@16
|
166 msg_parts = [pad1, pad2, ciphertext, pad1, pad2]
|
bgneal@16
|
167
|
bgneal@16
|
168 # Assemble the final message; group if requested
|
bgneal@16
|
169 sep = ' ' if group else ''
|
bgneal@16
|
170 return sep.join(msg_parts)
|
bgneal@18
|
171
|
bgneal@18
|
172 def set_decrypt_message(self, msg):
|
bgneal@18
|
173 """Prepare to decrypt the supplied message.
|
bgneal@18
|
174
|
bgneal@18
|
175 The messsage can be grouped into 5-letter groups separated by spaces or
|
bgneal@18
|
176 accepted without spaces.
|
bgneal@18
|
177
|
bgneal@18
|
178 Returns a DecryptParams tuple to the caller. The caller should ensure
|
bgneal@18
|
179 the procedure instance has the required key list before calling decrypt.
|
bgneal@18
|
180
|
bgneal@18
|
181 """
|
bgneal@18
|
182 # See if we need to group the message.
|
bgneal@18
|
183 if not ' ' in msg:
|
bgneal@18
|
184 msg = group_text(msg)
|
bgneal@18
|
185
|
bgneal@18
|
186 # Perform some basic checks on the message to see if it looks like an
|
bgneal@18
|
187 # M209 message.
|
bgneal@18
|
188
|
bgneal@18
|
189 # Ensure the message passes a regex format check
|
bgneal@18
|
190 m = MSG_RE.match(msg)
|
bgneal@18
|
191 if not m:
|
bgneal@18
|
192 raise ProcedureError("invalid decrypt message format")
|
bgneal@18
|
193
|
bgneal@18
|
194 group1 = m.group(1)
|
bgneal@18
|
195 group2 = m.group(2)
|
bgneal@18
|
196
|
bgneal@18
|
197 # Check system indicator is repeated twice
|
bgneal@18
|
198 if group1[0] != group1[1]:
|
bgneal@18
|
199 raise ProcedureError("missing system indicator")
|
bgneal@18
|
200
|
bgneal@18
|
201 sys_ind = group1[0]
|
bgneal@18
|
202 ext_msg_ind = group1[2:] + group2[:3]
|
bgneal@18
|
203 key_list_ind = group2[3:]
|
bgneal@18
|
204 ciphertext = m.group(3).rstrip()
|
bgneal@18
|
205
|
bgneal@18
|
206 self.decrypt_params = DecryptParams(sys_ind=sys_ind,
|
bgneal@18
|
207 ext_msg_ind=ext_msg_ind,
|
bgneal@18
|
208 key_list_ind=key_list_ind,
|
bgneal@18
|
209 ciphertext=ciphertext)
|
bgneal@18
|
210
|
bgneal@18
|
211 return self.decrypt_params
|
bgneal@18
|
212
|
bgneal@18
|
213 def decrypt(self, z_sub=True):
|
bgneal@18
|
214 """Decrypt the message set in a previous set_decrypt_message() call. The
|
bgneal@18
|
215 resulting plaintext is returned as a string.
|
bgneal@18
|
216
|
bgneal@18
|
217 If z_sub is True, 'Z' characters in the output plaintext will be
|
bgneal@18
|
218 replaced by space characters, just like an actual M-209. If z_sub is
|
bgneal@18
|
219 False, no such substitution will occur.
|
bgneal@18
|
220
|
bgneal@18
|
221 A ProcedureError will be raised if the procedure instance has not been
|
bgneal@18
|
222 configured with the required key list.
|
bgneal@18
|
223
|
bgneal@18
|
224 """
|
bgneal@18
|
225 if not self.decrypt_params:
|
bgneal@18
|
226 raise ProcedureError("no prior call to set_decrypt_message")
|
bgneal@18
|
227
|
bgneal@18
|
228 if not self.key_list or (
|
bgneal@18
|
229 self.key_list.indicator != self.decrypt_params.key_list_ind):
|
bgneal@18
|
230 raise ProcedureError("key list '{}' required".format(
|
bgneal@18
|
231 self.decrypt_params.key_list_ind))
|
bgneal@18
|
232
|
bgneal@18
|
233 # We assume the caller called set_key_list() if necessary, so the M209
|
bgneal@18
|
234 # has been keyed.
|
bgneal@18
|
235
|
bgneal@18
|
236 self.m_209.letter_counter = 0
|
bgneal@18
|
237 self.m_209.set_key_wheels(self.decrypt_params.ext_msg_ind)
|
bgneal@18
|
238
|
bgneal@18
|
239 # Generate internal message indicator
|
bgneal@18
|
240
|
bgneal@18
|
241 int_msg_ind = self.m_209.encrypt(self.decrypt_params.sys_ind * 12,
|
bgneal@18
|
242 group=False)
|
bgneal@18
|
243
|
bgneal@18
|
244 # set key wheels to internal message indicator
|
bgneal@18
|
245 self._set_int_message_indicator(int_msg_ind)
|
bgneal@18
|
246
|
bgneal@18
|
247 self.m_209.letter_counter = 0
|
bgneal@18
|
248 plaintext = self.m_209.decrypt(self.decrypt_params.ciphertext,
|
bgneal@18
|
249 spaces=True, z_sub=z_sub)
|
bgneal@18
|
250 return plaintext
|
bgneal@18
|
251
|
bgneal@18
|
252 def _set_int_message_indicator(self, indicator):
|
bgneal@18
|
253 """Sets the key wheels to the given internal message indicator as per
|
bgneal@18
|
254 the standard procedure.
|
bgneal@18
|
255
|
bgneal@18
|
256 """
|
bgneal@18
|
257 # Set wheels to internal message indicator from left to right. We must skip
|
bgneal@18
|
258 # letters that aren't valid for a given key wheel.
|
bgneal@18
|
259 it = iter(indicator)
|
bgneal@18
|
260 n = 0
|
bgneal@18
|
261 while n != 6:
|
bgneal@18
|
262 try:
|
bgneal@18
|
263 self.m_209.set_key_wheel(n, next(it))
|
bgneal@18
|
264 except KeyWheelError:
|
bgneal@18
|
265 pass
|
bgneal@18
|
266 except StopIteration:
|
bgneal@18
|
267 assert False, "Ran out of letters building internal message indicator"
|
bgneal@18
|
268 else:
|
bgneal@18
|
269 n += 1
|