summaryrefslogtreecommitdiff
path: root/src/otp2289
diff options
context:
space:
mode:
Diffstat (limited to 'src/otp2289')
-rw-r--r--src/otp2289/__init__.py68
-rw-r--r--src/otp2289/__main__.py385
-rw-r--r--src/otp2289/generator.py760
-rw-r--r--src/otp2289/server.py476
4 files changed, 1689 insertions, 0 deletions
diff --git a/src/otp2289/__init__.py b/src/otp2289/__init__.py
new file mode 100644
index 0000000..59694a7
--- /dev/null
+++ b/src/otp2289/__init__.py
@@ -0,0 +1,68 @@
1# -*- coding: utf-8 -*-
2# SPDX-License-Identifier: BSD-2-Clause-FreeBSD
3#
4# Copyright (c) 2020-2022 Simeon Simeonov
5# All rights reserved.
6#
7# Redistribution and use in source and binary forms, with or without
8# modification, are permitted provided that the following conditions
9# are met:
10# 1. Redistributions of source code must retain the above copyright
11# notice, this list of conditions and the following disclaimer.
12# 2. Redistributions in binary form must reproduce the above copyright notice,
13# this list of conditions and the following disclaimer in the documentation
14# and/or other materials provided with the distribution.
15#
16# THIS SOFTWARE IS PROVIDED BY THE AUTHORS ``AS IS'' AND ANY EXPRESS OR
17# IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
18# OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
19# IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT,
20# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
21# NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
22# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
23# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
24# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
25# THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
26"""A pure Python implementation of RFC-2289"""
27from .generator import (
28 OTP_ALGO_MD5,
29 OTP_ALGO_SHA1,
30 OTPChallengeException,
31 OTPGenerator,
32 OTPGeneratorException,
33)
34from .server import (
35 OTPInvalidResponse,
36 OTPState,
37 OTPStateException,
38 OTPStore,
39 OTPStoreException,
40)
41
42__author__ = 'Simeon Simeonov'
43__version__ = '1.1.0'
44__license__ = 'BSD 2-Clause'
45
46
47def int_or_str(value):
48 """Returns int value of value when possible"""
49 try:
50 return int(value)
51 except ValueError:
52 return value
53
54
55VERSION = tuple(map(int_or_str, __version__.split('.')))
56
57__all__ = [
58 'OTP_ALGO_MD5',
59 'OTP_ALGO_SHA1',
60 'OTPChallengeException',
61 'OTPGenerator',
62 'OTPGeneratorException',
63 'OTPInvalidResponse',
64 'OTPState',
65 'OTPStateException',
66 'OTPStore',
67 'OTPStoreException',
68]
diff --git a/src/otp2289/__main__.py b/src/otp2289/__main__.py
new file mode 100644
index 0000000..78e60e8
--- /dev/null
+++ b/src/otp2289/__main__.py
@@ -0,0 +1,385 @@
1# -*- coding: utf-8 -*-
2# SPDX-License-Identifier: BSD-2-Clause-FreeBSD
3#
4# Copyright (c) 2020-2022 Simeon Simeonov
5# All rights reserved.
6#
7# Redistribution and use in source and binary forms, with or without
8# modification, are permitted provided that the following conditions
9# are met:
10# 1. Redistributions of source code must retain the above copyright
11# notice, this list of conditions and the following disclaimer.
12# 2. Redistributions in binary form must reproduce the above copyright notice,
13# this list of conditions and the following disclaimer in the documentation
14# and/or other materials provided with the distribution.
15#
16# THIS SOFTWARE IS PROVIDED BY THE AUTHORS ``AS IS'' AND ANY EXPRESS OR
17# IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
18# OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
19# IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT,
20# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
21# NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
22# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
23# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
24# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
25# THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
26"""
27CLI entry point for the otp2289 package
28
29Examples:
30python -m otp2289 --initiate-new-sequence -s TesT
31
32python -m otp2289 --generate-otp-response -c "otp-md5 499 TesT " -f token
33python -m otp2289 --generate-otp-response -s TesT -i 499 -f token
34"""
35import argparse
36import errno
37import getpass
38import io
39import os
40import secrets
41import string
42import sys
43
44import otp2289
45
46
47def eprint(*arg, **kwargs):
48 """stdderr print wrapper"""
49 print(*arg, file=sys.stderr, flush=True, **kwargs)
50
51
52def generate_otp_response(args: argparse.Namespace) -> str:
53 """
54 Generates a response based on the parameters sent from the parser
55
56 :param args: The arguments assigned from argparse
57 :type args: argparse.Namespace
58
59 :raises otp2289.OTPChallengeException: If the challenge is invalid
60
61 :raises otp2289.OTPGeneratorException: If generator parameters are wrong
62
63 :return: The response string
64 :rtype: str
65 """
66 generator = otp2289.generator.OTPGenerator(
67 args.password.encode(),
68 args.seed,
69 args.hash_algo,
70 )
71 if args.challenge_string:
72 if args.output_format == 'token':
73 return generator.generate_otp_words_from_challenge(
74 args.challenge_string
75 )
76 return generator.generate_otp_hexdigest_from_challenge(
77 args.challenge_string
78 )
79 # regular parameters
80 header = ''
81 if not args.quiet:
82 header = (
83 f'Seed: {args.seed}, Step: {args.step}, '
84 f'Hash: {args.hash_algo}{os.linesep}'
85 )
86 if args.output_format == 'token':
87 return header + generator.generate_otp_words(args.step)
88 return header + generator.generate_otp_hexdigest(args.step)
89
90
91def generate_otp_range(args: argparse.Namespace) -> str:
92 """
93 Generates range of responses based on the parameters sent from the parser
94
95 :param args: The arguments assigned from argparse
96 :type args: argparse.Namespace
97
98 :raises otp2289.OTPChallengeException: If the challenge is invalid
99
100 :raises otp2289.OTPGeneratorException: If generator parameters are wrong
101
102 :return: The responses string
103 :rtype: str
104 """
105 generator = otp2289.generator.OTPGenerator(
106 args.password.encode(),
107 args.seed,
108 args.hash_algo,
109 )
110 if args.output_format == 'token':
111 method = generator.generate_otp_words
112 else:
113 method = generator.generate_otp_hexdigest
114 # handle most cases explicitly
115 if args.range == 1:
116 return f'{args.step}: ' + method(args.step)
117 if args.range > args.step + 1:
118 args.range = args.step + 1
119 # any need for quiet?
120 header = ''
121 if not args.quiet:
122 header = (
123 f'Seed: {args.seed}, Step: {args.step}, '
124 f'Hash: {args.hash_algo}, Range: {args.range}{os.linesep}'
125 )
126 return header + os.linesep.join(
127 [
128 f'{step}: ' + method(step)
129 for step in range(
130 args.step,
131 args.step - args.range,
132 -1,
133 )
134 ]
135 )
136
137
138def get_password(args: argparse.Namespace) -> str:
139 """
140 Extract the provided password using the defined argparse arguments
141
142 :param args: The arguments assigned from argparse
143 :type args: argparse.Namespace
144
145 :raises KeyboardInterrupt: If the password prompt is interrupted
146
147 :return: The extrated password string
148 :rtype: str
149 """
150 if args.force_password_prompt:
151 while True:
152 password = getpass.getpass()
153 if not args.initiate_new_sequence or password == getpass.getpass(
154 'Repeat password: '
155 ):
156 break
157 eprint('The passwords do not match')
158 return password
159
160 if not args.password:
161 password = os.environ.get('OTP2289_PASSWORD')
162 if password is not None:
163 return password
164 while True:
165 password = getpass.getpass()
166 if not args.initiate_new_sequence or password == getpass.getpass(
167 'Repeat password: '
168 ):
169 break
170 eprint('The passwords do not match')
171 return password
172
173 if os.path.isfile(args.password):
174 with io.open(args.password, 'r', encoding='utf-8') as fp:
175 return fp.readline().strip()
176
177 return args.password
178
179
180def get_rnd_seed() -> str:
181 """
182 Returns a random seed in the format:
183
184 2 random letters (capitalize()) + 5 random digits
185 """
186 rnd = secrets.SystemRandom()
187 return ''.join(
188 rnd.choices(string.ascii_lowercase, k=2)
189 ).capitalize() + ''.join(rnd.choices(string.digits, k=5))
190
191
192def initiate_new_sequence(args: argparse.Namespace) -> str:
193 """
194 Generates a new sequence based on the parameters sent from the parser.
195
196 :param args: The arguments assigned from argparse
197 :type args: argparse.Namespace
198
199 :raises otp2289.OTPChallengeException: If the challenge is invalid
200
201 :raises otp2289.OTPGeneratorException: If generator parameters are wrong
202
203 :return: The response string
204 :rtype: str
205 """
206 if not args.seed:
207 args.seed = get_rnd_seed()
208 header = ''
209 if not args.quiet:
210 header = (
211 f'Seed: {args.seed}, Step: {args.step}, '
212 f'Hash: {args.hash_algo}{os.linesep}'
213 )
214 generator = otp2289.generator.OTPGenerator(
215 args.password.encode(),
216 args.seed,
217 args.hash_algo,
218 )
219 if args.challenge_string:
220 return header + generator.generate_otp_hexdigest_from_challenge(
221 args.challenge_string
222 )
223 return header + generator.generate_otp_hexdigest(args.step)
224
225
226def main(args=None):
227 """the main entry point"""
228 parser = argparse.ArgumentParser(
229 prog=__package__,
230 epilog=(
231 f'%(prog)s {otp2289.__version__} by Simeon Simeonov '
232 '(sgs @ LiberaChat)'
233 ),
234 description='The following options are available',
235 )
236 group = parser.add_mutually_exclusive_group(required=True)
237 password_group = parser.add_mutually_exclusive_group()
238 group.add_argument(
239 '--generate-otp-range',
240 action='store_true',
241 dest='generate_otp_range',
242 default=False,
243 help='Generates a range of OTP responses',
244 )
245 group.add_argument(
246 '--generate-otp-response',
247 action='store_true',
248 dest='generate_otp_response',
249 default=False,
250 help='Generates a new OTP response',
251 )
252 group.add_argument(
253 '--initiate-new-sequence',
254 action='store_true',
255 dest='initiate_new_sequence',
256 default=False,
257 help=(
258 'Initiates a new OTP sequence. Essentially the same as '
259 '--generate-otp-response only it prompts twice for password '
260 'and always outputs hex (ignores -f).'
261 ),
262 )
263 password_group.add_argument(
264 '-P',
265 '--force-password-prompt',
266 dest='force_password_prompt',
267 action='store_true',
268 help=(
269 'Force password prompt even if the env. variable '
270 '"OTP2289_PASSWORD" is set'
271 ),
272 )
273 password_group.add_argument(
274 '-p',
275 '--password',
276 metavar='<PASSWORD[FILE]>',
277 type=str,
278 dest='password',
279 default='',
280 help=(
281 'The password or path to password file '
282 '(default & recommended: prompt for passwd)'
283 ),
284 )
285 parser.add_argument(
286 '-a',
287 '--hash-algorithm',
288 metavar='<md5 | sha1>',
289 type=str,
290 dest='hash_algo',
291 default='md5',
292 help='The hash algorithm to use. Possible values: md5 (default), sha1',
293 )
294 parser.add_argument(
295 '-c',
296 '--challenge-string',
297 metavar='<challenge string>',
298 type=str,
299 dest='challenge_string',
300 default='',
301 help='Use challenge string when generating response',
302 )
303 parser.add_argument(
304 '-f',
305 '--output-format',
306 metavar='<hex | token>',
307 type=str,
308 dest='output_format',
309 default='hex',
310 help='The output format to use. Possible values: hex (default), token',
311 )
312 parser.add_argument(
313 '-i',
314 '--step',
315 metavar='<step>',
316 type=int,
317 dest='step',
318 default=500,
319 help='The step. Default for initiating a new sequence is: 500',
320 )
321 parser.add_argument(
322 '-q',
323 '--quiet',
324 action='store_true',
325 dest='quiet',
326 default=False,
327 help='Dot not show headers. Only hex / tokens',
328 )
329 parser.add_argument(
330 '-r',
331 '--range',
332 metavar='<range>',
333 type=int,
334 dest='range',
335 default=1,
336 help='Amount of consecutive OTP hex/tokens to generate. default: 1',
337 )
338 parser.add_argument(
339 '-s',
340 '--seed',
341 metavar='[seed]',
342 type=str,
343 dest='seed',
344 default='',
345 help=(
346 'The seed to use (1 to 16 alphanumeric characters) '
347 '(default & recommended: random seed)'
348 ),
349 )
350 parser.add_argument(
351 '-v',
352 '--version',
353 action='version',
354 version=f'%(prog)s {otp2289.__version__}',
355 help='display program-version and exit',
356 )
357 args = parser.parse_args(args)
358 # handle the password before everything else
359 try:
360 args.password = get_password(args)
361 except KeyboardInterrupt:
362 eprint(os.linesep + 'Prompt terminated')
363 sys.exit(errno.EACCES)
364 except Exception as exp:
365 eprint(f'Unable to fetch password: {exp}')
366 sys.exit(1)
367 try:
368 if args.initiate_new_sequence:
369 print(initiate_new_sequence(args))
370 if args.generate_otp_range:
371 print(generate_otp_range(args))
372 if args.generate_otp_response:
373 print(generate_otp_response(args))
374 sys.exit(0)
375 except otp2289.generator.OTPGeneratorException as exp:
376 eprint(f'GeneratorException: {exp}')
377 except otp2289.generator.OTPChallengeException as exp:
378 eprint(f'ChallengeException: {exp}')
379 except Exception as exp:
380 eprint(f'Unknown error: {exp}')
381 sys.exit(1)
382
383
384if __name__ == '__main__':
385 main()
diff --git a/src/otp2289/generator.py b/src/otp2289/generator.py
new file mode 100644
index 0000000..7b86e1b
--- /dev/null
+++ b/src/otp2289/generator.py
@@ -0,0 +1,760 @@
1# -*- coding: utf-8 -*-
2# SPDX-License-Identifier: BSD-2-Clause-FreeBSD
3#
4# Copyright (c) 2020-2022 Simeon Simeonov
5# All rights reserved.
6#
7# Redistribution and use in source and binary forms, with or without
8# modification, are permitted provided that the following conditions
9# are met:
10# 1. Redistributions of source code must retain the above copyright
11# notice, this list of conditions and the following disclaimer.
12# 2. Redistributions in binary form must reproduce the above copyright notice,
13# this list of conditions and the following disclaimer in the documentation
14# and/or other materials provided with the distribution.
15#
16# THIS SOFTWARE IS PROVIDED BY THE AUTHORS ``AS IS'' AND ANY EXPRESS OR
17# IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
18# OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
19# IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT,
20# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
21# NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
22# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
23# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
24# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
25# THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
26"""A pure Python implementation of the RFC-2289 OTP generator"""
27import binascii
28import hashlib
29import string
30
31OTP_ALGO_MD5 = 1
32OTP_ALGO_SHA1 = 2
33
34# the tokens are defined in https://tools.ietf.org/html/rfc2289 #
35RFC1760_TOKENS = [
36 'A', 'ABE', 'ACE', 'ACT', 'AD', 'ADA', 'ADD',
37 'AGO', 'AID', 'AIM', 'AIR', 'ALL', 'ALP', 'AM', 'AMY',
38 'AN', 'ANA', 'AND', 'ANN', 'ANT', 'ANY', 'APE', 'APS',
39 'APT', 'ARC', 'ARE', 'ARK', 'ARM', 'ART', 'AS', 'ASH',
40 'ASK', 'AT', 'ATE', 'AUG', 'AUK', 'AVE', 'AWE', 'AWK',
41 'AWL', 'AWN', 'AX', 'AYE', 'BAD', 'BAG', 'BAH', 'BAM',
42 'BAN', 'BAR', 'BAT', 'BAY', 'BE', 'BED', 'BEE', 'BEG',
43 'BEN', 'BET', 'BEY', 'BIB', 'BID', 'BIG', 'BIN', 'BIT',
44 'BOB', 'BOG', 'BON', 'BOO', 'BOP', 'BOW', 'BOY', 'BUB',
45 'BUD', 'BUG', 'BUM', 'BUN', 'BUS', 'BUT', 'BUY', 'BY',
46 'BYE', 'CAB', 'CAL', 'CAM', 'CAN', 'CAP', 'CAR', 'CAT',
47 'CAW', 'COD', 'COG', 'COL', 'CON', 'COO', 'COP', 'COT',
48 'COW', 'COY', 'CRY', 'CUB', 'CUE', 'CUP', 'CUR', 'CUT',
49 'DAB', 'DAD', 'DAM', 'DAN', 'DAR', 'DAY', 'DEE', 'DEL',
50 'DEN', 'DES', 'DEW', 'DID', 'DIE', 'DIG', 'DIN', 'DIP',
51 'DO', 'DOE', 'DOG', 'DON', 'DOT', 'DOW', 'DRY', 'DUB',
52 'DUD', 'DUE', 'DUG', 'DUN', 'EAR', 'EAT', 'ED', 'EEL',
53 'EGG', 'EGO', 'ELI', 'ELK', 'ELM', 'ELY', 'EM', 'END',
54 'EST', 'ETC', 'EVA', 'EVE', 'EWE', 'EYE', 'FAD', 'FAN',
55 'FAR', 'FAT', 'FAY', 'FED', 'FEE', 'FEW', 'FIB', 'FIG',
56 'FIN', 'FIR', 'FIT', 'FLO', 'FLY', 'FOE', 'FOG', 'FOR',
57 'FRY', 'FUM', 'FUN', 'FUR', 'GAB', 'GAD', 'GAG', 'GAL',
58 'GAM', 'GAP', 'GAS', 'GAY', 'GEE', 'GEL', 'GEM', 'GET',
59 'GIG', 'GIL', 'GIN', 'GO', 'GOT', 'GUM', 'GUN', 'GUS',
60 'GUT', 'GUY', 'GYM', 'GYP', 'HA', 'HAD', 'HAL', 'HAM',
61 'HAN', 'HAP', 'HAS', 'HAT', 'HAW', 'HAY', 'HE', 'HEM',
62 'HEN', 'HER', 'HEW', 'HEY', 'HI', 'HID', 'HIM', 'HIP',
63 'HIS', 'HIT', 'HO', 'HOB', 'HOC', 'HOE', 'HOG', 'HOP',
64 'HOT', 'HOW', 'HUB', 'HUE', 'HUG', 'HUH', 'HUM', 'HUT',
65 'I', 'ICY', 'IDA', 'IF', 'IKE', 'ILL', 'INK', 'INN',
66 'IO', 'ION', 'IQ', 'IRA', 'IRE', 'IRK', 'IS', 'IT',
67 'ITS', 'IVY', 'JAB', 'JAG', 'JAM', 'JAN', 'JAR', 'JAW',
68 'JAY', 'JET', 'JIG', 'JIM', 'JO', 'JOB', 'JOE', 'JOG',
69 'JOT', 'JOY', 'JUG', 'JUT', 'KAY', 'KEG', 'KEN', 'KEY',
70 'KID', 'KIM', 'KIN', 'KIT', 'LA', 'LAB', 'LAC', 'LAD',
71 'LAG', 'LAM', 'LAP', 'LAW', 'LAY', 'LEA', 'LED', 'LEE',
72 'LEG', 'LEN', 'LEO', 'LET', 'LEW', 'LID', 'LIE', 'LIN',
73 'LIP', 'LIT', 'LO', 'LOB', 'LOG', 'LOP', 'LOS', 'LOT',
74 'LOU', 'LOW', 'LOY', 'LUG', 'LYE', 'MA', 'MAC', 'MAD',
75 'MAE', 'MAN', 'MAO', 'MAP', 'MAT', 'MAW', 'MAY', 'ME',
76 'MEG', 'MEL', 'MEN', 'MET', 'MEW', 'MID', 'MIN', 'MIT',
77 'MOB', 'MOD', 'MOE', 'MOO', 'MOP', 'MOS', 'MOT', 'MOW',
78 'MUD', 'MUG', 'MUM', 'MY', 'NAB', 'NAG', 'NAN', 'NAP',
79 'NAT', 'NAY', 'NE', 'NED', 'NEE', 'NET', 'NEW', 'NIB',
80 'NIL', 'NIP', 'NIT', 'NO', 'NOB', 'NOD', 'NON', 'NOR',
81 'NOT', 'NOV', 'NOW', 'NU', 'NUN', 'NUT', 'O', 'OAF',
82 'OAK', 'OAR', 'OAT', 'ODD', 'ODE', 'OF', 'OFF', 'OFT',
83 'OH', 'OIL', 'OK', 'OLD', 'ON', 'ONE', 'OR', 'ORB',
84 'ORE', 'ORR', 'OS', 'OTT', 'OUR', 'OUT', 'OVA', 'OW',
85 'OWE', 'OWL', 'OWN', 'OX', 'PA', 'PAD', 'PAL', 'PAM',
86 'PAN', 'PAP', 'PAR', 'PAT', 'PAW', 'PAY', 'PEA', 'PEG',
87 'PEN', 'PEP', 'PER', 'PET', 'PEW', 'PHI', 'PI', 'PIE',
88 'PIN', 'PIT', 'PLY', 'PO', 'POD', 'POE', 'POP', 'POT',
89 'POW', 'PRO', 'PRY', 'PUB', 'PUG', 'PUN', 'PUP', 'PUT',
90 'QUO', 'RAG', 'RAM', 'RAN', 'RAP', 'RAT', 'RAW', 'RAY',
91 'REB', 'RED', 'REP', 'RET', 'RIB', 'RID', 'RIG', 'RIM',
92 'RIO', 'RIP', 'ROB', 'ROD', 'ROE', 'RON', 'ROT', 'ROW',
93 'ROY', 'RUB', 'RUE', 'RUG', 'RUM', 'RUN', 'RYE', 'SAC',
94 'SAD', 'SAG', 'SAL', 'SAM', 'SAN', 'SAP', 'SAT', 'SAW',
95 'SAY', 'SEA', 'SEC', 'SEE', 'SEN', 'SET', 'SEW', 'SHE',
96 'SHY', 'SIN', 'SIP', 'SIR', 'SIS', 'SIT', 'SKI', 'SKY',
97 'SLY', 'SO', 'SOB', 'SOD', 'SON', 'SOP', 'SOW', 'SOY',
98 'SPA', 'SPY', 'SUB', 'SUD', 'SUE', 'SUM', 'SUN', 'SUP',
99 'TAB', 'TAD', 'TAG', 'TAN', 'TAP', 'TAR', 'TEA', 'TED',
100 'TEE', 'TEN', 'THE', 'THY', 'TIC', 'TIE', 'TIM', 'TIN',
101 'TIP', 'TO', 'TOE', 'TOG', 'TOM', 'TON', 'TOO', 'TOP',
102 'TOW', 'TOY', 'TRY', 'TUB', 'TUG', 'TUM', 'TUN', 'TWO',
103 'UN', 'UP', 'US', 'USE', 'VAN', 'VAT', 'VET', 'VIE',
104 'WAD', 'WAG', 'WAR', 'WAS', 'WAY', 'WE', 'WEB', 'WED',
105 'WEE', 'WET', 'WHO', 'WHY', 'WIN', 'WIT', 'WOK', 'WON',
106 'WOO', 'WOW', 'WRY', 'WU', 'YAM', 'YAP', 'YAW', 'YE',
107 'YEA', 'YES', 'YET', 'YOU', 'ABED', 'ABEL', 'ABET', 'ABLE',
108 'ABUT', 'ACHE', 'ACID', 'ACME', 'ACRE', 'ACTA', 'ACTS', 'ADAM',
109 'ADDS', 'ADEN', 'AFAR', 'AFRO', 'AGEE', 'AHEM', 'AHOY', 'AIDA',
110 'AIDE', 'AIDS', 'AIRY', 'AJAR', 'AKIN', 'ALAN', 'ALEC', 'ALGA',
111 'ALIA', 'ALLY', 'ALMA', 'ALOE', 'ALSO', 'ALTO', 'ALUM', 'ALVA',
112 'AMEN', 'AMES', 'AMID', 'AMMO', 'AMOK', 'AMOS', 'AMRA', 'ANDY',
113 'ANEW', 'ANNA', 'ANNE', 'ANTE', 'ANTI', 'AQUA', 'ARAB', 'ARCH',
114 'AREA', 'ARGO', 'ARID', 'ARMY', 'ARTS', 'ARTY', 'ASIA', 'ASKS',
115 'ATOM', 'AUNT', 'AURA', 'AUTO', 'AVER', 'AVID', 'AVIS', 'AVON',
116 'AVOW', 'AWAY', 'AWRY', 'BABE', 'BABY', 'BACH', 'BACK', 'BADE',
117 'BAIL', 'BAIT', 'BAKE', 'BALD', 'BALE', 'BALI', 'BALK', 'BALL',
118 'BALM', 'BAND', 'BANE', 'BANG', 'BANK', 'BARB', 'BARD', 'BARE',
119 'BARK', 'BARN', 'BARR', 'BASE', 'BASH', 'BASK', 'BASS', 'BATE',
120 'BATH', 'BAWD', 'BAWL', 'BEAD', 'BEAK', 'BEAM', 'BEAN', 'BEAR',
121 'BEAT', 'BEAU', 'BECK', 'BEEF', 'BEEN', 'BEER', 'BEET', 'BELA',
122 'BELL', 'BELT', 'BEND', 'BENT', 'BERG', 'BERN', 'BERT', 'BESS',
123 'BEST', 'BETA', 'BETH', 'BHOY', 'BIAS', 'BIDE', 'BIEN', 'BILE',
124 'BILK', 'BILL', 'BIND', 'BING', 'BIRD', 'BITE', 'BITS', 'BLAB',
125 'BLAT', 'BLED', 'BLEW', 'BLOB', 'BLOC', 'BLOT', 'BLOW', 'BLUE',
126 'BLUM', 'BLUR', 'BOAR', 'BOAT', 'BOCA', 'BOCK', 'BODE', 'BODY',
127 'BOGY', 'BOHR', 'BOIL', 'BOLD', 'BOLO', 'BOLT', 'BOMB', 'BONA',
128 'BOND', 'BONE', 'BONG', 'BONN', 'BONY', 'BOOK', 'BOOM', 'BOON',
129 'BOOT', 'BORE', 'BORG', 'BORN', 'BOSE', 'BOSS', 'BOTH', 'BOUT',
130 'BOWL', 'BOYD', 'BRAD', 'BRAE', 'BRAG', 'BRAN', 'BRAY', 'BRED',
131 'BREW', 'BRIG', 'BRIM', 'BROW', 'BUCK', 'BUDD', 'BUFF', 'BULB',
132 'BULK', 'BULL', 'BUNK', 'BUNT', 'BUOY', 'BURG', 'BURL', 'BURN',
133 'BURR', 'BURT', 'BURY', 'BUSH', 'BUSS', 'BUST', 'BUSY', 'BYTE',
134 'CADY', 'CAFE', 'CAGE', 'CAIN', 'CAKE', 'CALF', 'CALL', 'CALM',
135 'CAME', 'CANE', 'CANT', 'CARD', 'CARE', 'CARL', 'CARR', 'CART',
136 'CASE', 'CASH', 'CASK', 'CAST', 'CAVE', 'CEIL', 'CELL', 'CENT',
137 'CERN', 'CHAD', 'CHAR', 'CHAT', 'CHAW', 'CHEF', 'CHEN', 'CHEW',
138 'CHIC', 'CHIN', 'CHOU', 'CHOW', 'CHUB', 'CHUG', 'CHUM', 'CITE',
139 'CITY', 'CLAD', 'CLAM', 'CLAN', 'CLAW', 'CLAY', 'CLOD', 'CLOG',
140 'CLOT', 'CLUB', 'CLUE', 'COAL', 'COAT', 'COCA', 'COCK', 'COCO',
141 'CODA', 'CODE', 'CODY', 'COED', 'COIL', 'COIN', 'COKE', 'COLA',
142 'COLD', 'COLT', 'COMA', 'COMB', 'COME', 'COOK', 'COOL', 'COON',
143 'COOT', 'CORD', 'CORE', 'CORK', 'CORN', 'COST', 'COVE', 'COWL',
144 'CRAB', 'CRAG', 'CRAM', 'CRAY', 'CREW', 'CRIB', 'CROW', 'CRUD',
145 'CUBA', 'CUBE', 'CUFF', 'CULL', 'CULT', 'CUNY', 'CURB', 'CURD',
146 'CURE', 'CURL', 'CURT', 'CUTS', 'DADE', 'DALE', 'DAME', 'DANA',
147 'DANE', 'DANG', 'DANK', 'DARE', 'DARK', 'DARN', 'DART', 'DASH',
148 'DATA', 'DATE', 'DAVE', 'DAVY', 'DAWN', 'DAYS', 'DEAD', 'DEAF',
149 'DEAL', 'DEAN', 'DEAR', 'DEBT', 'DECK', 'DEED', 'DEEM', 'DEER',
150 'DEFT', 'DEFY', 'DELL', 'DENT', 'DENY', 'DESK', 'DIAL', 'DICE',
151 'DIED', 'DIET', 'DIME', 'DINE', 'DING', 'DINT', 'DIRE', 'DIRT',
152 'DISC', 'DISH', 'DISK', 'DIVE', 'DOCK', 'DOES', 'DOLE', 'DOLL',
153 'DOLT', 'DOME', 'DONE', 'DOOM', 'DOOR', 'DORA', 'DOSE', 'DOTE',
154 'DOUG', 'DOUR', 'DOVE', 'DOWN', 'DRAB', 'DRAG', 'DRAM', 'DRAW',
155 'DREW', 'DRUB', 'DRUG', 'DRUM', 'DUAL', 'DUCK', 'DUCT', 'DUEL',
156 'DUET', 'DUKE', 'DULL', 'DUMB', 'DUNE', 'DUNK', 'DUSK', 'DUST',
157 'DUTY', 'EACH', 'EARL', 'EARN', 'EASE', 'EAST', 'EASY', 'EBEN',
158 'ECHO', 'EDDY', 'EDEN', 'EDGE', 'EDGY', 'EDIT', 'EDNA', 'EGAN',
159 'ELAN', 'ELBA', 'ELLA', 'ELSE', 'EMIL', 'EMIT', 'EMMA', 'ENDS',
160 'ERIC', 'EROS', 'EVEN', 'EVER', 'EVIL', 'EYED', 'FACE', 'FACT',
161 'FADE', 'FAIL', 'FAIN', 'FAIR', 'FAKE', 'FALL', 'FAME', 'FANG',
162 'FARM', 'FAST', 'FATE', 'FAWN', 'FEAR', 'FEAT', 'FEED', 'FEEL',
163 'FEET', 'FELL', 'FELT', 'FEND', 'FERN', 'FEST', 'FEUD', 'FIEF',
164 'FIGS', 'FILE', 'FILL', 'FILM', 'FIND', 'FINE', 'FINK', 'FIRE',
165 'FIRM', 'FISH', 'FISK', 'FIST', 'FITS', 'FIVE', 'FLAG', 'FLAK',
166 'FLAM', 'FLAT', 'FLAW', 'FLEA', 'FLED', 'FLEW', 'FLIT', 'FLOC',
167 'FLOG', 'FLOW', 'FLUB', 'FLUE', 'FOAL', 'FOAM', 'FOGY', 'FOIL',
168 'FOLD', 'FOLK', 'FOND', 'FONT', 'FOOD', 'FOOL', 'FOOT', 'FORD',
169 'FORE', 'FORK', 'FORM', 'FORT', 'FOSS', 'FOUL', 'FOUR', 'FOWL',
170 'FRAU', 'FRAY', 'FRED', 'FREE', 'FRET', 'FREY', 'FROG', 'FROM',
171 'FUEL', 'FULL', 'FUME', 'FUND', 'FUNK', 'FURY', 'FUSE', 'FUSS',
172 'GAFF', 'GAGE', 'GAIL', 'GAIN', 'GAIT', 'GALA', 'GALE', 'GALL',
173 'GALT', 'GAME', 'GANG', 'GARB', 'GARY', 'GASH', 'GATE', 'GAUL',
174 'GAUR', 'GAVE', 'GAWK', 'GEAR', 'GELD', 'GENE', 'GENT', 'GERM',
175 'GETS', 'GIBE', 'GIFT', 'GILD', 'GILL', 'GILT', 'GINA', 'GIRD',
176 'GIRL', 'GIST', 'GIVE', 'GLAD', 'GLEE', 'GLEN', 'GLIB', 'GLOB',
177 'GLOM', 'GLOW', 'GLUE', 'GLUM', 'GLUT', 'GOAD', 'GOAL', 'GOAT',
178 'GOER', 'GOES', 'GOLD', 'GOLF', 'GONE', 'GONG', 'GOOD', 'GOOF',
179 'GORE', 'GORY', 'GOSH', 'GOUT', 'GOWN', 'GRAB', 'GRAD', 'GRAY',
180 'GREG', 'GREW', 'GREY', 'GRID', 'GRIM', 'GRIN', 'GRIT', 'GROW',
181 'GRUB', 'GULF', 'GULL', 'GUNK', 'GURU', 'GUSH', 'GUST', 'GWEN',
182 'GWYN', 'HAAG', 'HAAS', 'HACK', 'HAIL', 'HAIR', 'HALE', 'HALF',
183 'HALL', 'HALO', 'HALT', 'HAND', 'HANG', 'HANK', 'HANS', 'HARD',
184 'HARK', 'HARM', 'HART', 'HASH', 'HAST', 'HATE', 'HATH', 'HAUL',
185 'HAVE', 'HAWK', 'HAYS', 'HEAD', 'HEAL', 'HEAR', 'HEAT', 'HEBE',
186 'HECK', 'HEED', 'HEEL', 'HEFT', 'HELD', 'HELL', 'HELM', 'HERB',
187 'HERD', 'HERE', 'HERO', 'HERS', 'HESS', 'HEWN', 'HICK', 'HIDE',
188 'HIGH', 'HIKE', 'HILL', 'HILT', 'HIND', 'HINT', 'HIRE', 'HISS',
189 'HIVE', 'HOBO', 'HOCK', 'HOFF', 'HOLD', 'HOLE', 'HOLM', 'HOLT',
190 'HOME', 'HONE', 'HONK', 'HOOD', 'HOOF', 'HOOK', 'HOOT', 'HORN',
191 'HOSE', 'HOST', 'HOUR', 'HOVE', 'HOWE', 'HOWL', 'HOYT', 'HUCK',
192 'HUED', 'HUFF', 'HUGE', 'HUGH', 'HUGO', 'HULK', 'HULL', 'HUNK',
193 'HUNT', 'HURD', 'HURL', 'HURT', 'HUSH', 'HYDE', 'HYMN', 'IBIS',
194 'ICON', 'IDEA', 'IDLE', 'IFFY', 'INCA', 'INCH', 'INTO', 'IONS',
195 'IOTA', 'IOWA', 'IRIS', 'IRMA', 'IRON', 'ISLE', 'ITCH', 'ITEM',
196 'IVAN', 'JACK', 'JADE', 'JAIL', 'JAKE', 'JANE', 'JAVA', 'JEAN',
197 'JEFF', 'JERK', 'JESS', 'JEST', 'JIBE', 'JILL', 'JILT', 'JIVE',
198 'JOAN', 'JOBS', 'JOCK', 'JOEL', 'JOEY', 'JOHN', 'JOIN', 'JOKE',
199 'JOLT', 'JOVE', 'JUDD', 'JUDE', 'JUDO', 'JUDY', 'JUJU', 'JUKE',
200 'JULY', 'JUNE', 'JUNK', 'JUNO', 'JURY', 'JUST', 'JUTE', 'KAHN',
201 'KALE', 'KANE', 'KANT', 'KARL', 'KATE', 'KEEL', 'KEEN', 'KENO',
202 'KENT', 'KERN', 'KERR', 'KEYS', 'KICK', 'KILL', 'KIND', 'KING',
203 'KIRK', 'KISS', 'KITE', 'KLAN', 'KNEE', 'KNEW', 'KNIT', 'KNOB',
204 'KNOT', 'KNOW', 'KOCH', 'KONG', 'KUDO', 'KURD', 'KURT', 'KYLE',
205 'LACE', 'LACK', 'LACY', 'LADY', 'LAID', 'LAIN', 'LAIR', 'LAKE',
206 'LAMB', 'LAME', 'LAND', 'LANE', 'LANG', 'LARD', 'LARK', 'LASS',
207 'LAST', 'LATE', 'LAUD', 'LAVA', 'LAWN', 'LAWS', 'LAYS', 'LEAD',
208 'LEAF', 'LEAK', 'LEAN', 'LEAR', 'LEEK', 'LEER', 'LEFT', 'LEND',
209 'LENS', 'LENT', 'LEON', 'LESK', 'LESS', 'LEST', 'LETS', 'LIAR',
210 'LICE', 'LICK', 'LIED', 'LIEN', 'LIES', 'LIEU', 'LIFE', 'LIFT',
211 'LIKE', 'LILA', 'LILT', 'LILY', 'LIMA', 'LIMB', 'LIME', 'LIND',
212 'LINE', 'LINK', 'LINT', 'LION', 'LISA', 'LIST', 'LIVE', 'LOAD',
213 'LOAF', 'LOAM', 'LOAN', 'LOCK', 'LOFT', 'LOGE', 'LOIS', 'LOLA',
214 'LONE', 'LONG', 'LOOK', 'LOON', 'LOOT', 'LORD', 'LORE', 'LOSE',
215 'LOSS', 'LOST', 'LOUD', 'LOVE', 'LOWE', 'LUCK', 'LUCY', 'LUGE',
216 'LUKE', 'LULU', 'LUND', 'LUNG', 'LURA', 'LURE', 'LURK', 'LUSH',
217 'LUST', 'LYLE', 'LYNN', 'LYON', 'LYRA', 'MACE', 'MADE', 'MAGI',
218 'MAID', 'MAIL', 'MAIN', 'MAKE', 'MALE', 'MALI', 'MALL', 'MALT',
219 'MANA', 'MANN', 'MANY', 'MARC', 'MARE', 'MARK', 'MARS', 'MART',
220 'MARY', 'MASH', 'MASK', 'MASS', 'MAST', 'MATE', 'MATH', 'MAUL',
221 'MAYO', 'MEAD', 'MEAL', 'MEAN', 'MEAT', 'MEEK', 'MEET', 'MELD',
222 'MELT', 'MEMO', 'MEND', 'MENU', 'MERT', 'MESH', 'MESS', 'MICE',
223 'MIKE', 'MILD', 'MILE', 'MILK', 'MILL', 'MILT', 'MIMI', 'MIND',
224 'MINE', 'MINI', 'MINK', 'MINT', 'MIRE', 'MISS', 'MIST', 'MITE',
225 'MITT', 'MOAN', 'MOAT', 'MOCK', 'MODE', 'MOLD', 'MOLE', 'MOLL',
226 'MOLT', 'MONA', 'MONK', 'MONT', 'MOOD', 'MOON', 'MOOR', 'MOOT',
227 'MORE', 'MORN', 'MORT', 'MOSS', 'MOST', 'MOTH', 'MOVE', 'MUCH',
228 'MUCK', 'MUDD', 'MUFF', 'MULE', 'MULL', 'MURK', 'MUSH', 'MUST',
229 'MUTE', 'MUTT', 'MYRA', 'MYTH', 'NAGY', 'NAIL', 'NAIR', 'NAME',
230 'NARY', 'NASH', 'NAVE', 'NAVY', 'NEAL', 'NEAR', 'NEAT', 'NECK',
231 'NEED', 'NEIL', 'NELL', 'NEON', 'NERO', 'NESS', 'NEST', 'NEWS',
232 'NEWT', 'NIBS', 'NICE', 'NICK', 'NILE', 'NINA', 'NINE', 'NOAH',
233 'NODE', 'NOEL', 'NOLL', 'NONE', 'NOOK', 'NOON', 'NORM', 'NOSE',
234 'NOTE', 'NOUN', 'NOVA', 'NUDE', 'NULL', 'NUMB', 'OATH', 'OBEY',
235 'OBOE', 'ODIN', 'OHIO', 'OILY', 'OINT', 'OKAY', 'OLAF', 'OLDY',
236 'OLGA', 'OLIN', 'OMAN', 'OMEN', 'OMIT', 'ONCE', 'ONES', 'ONLY',
237 'ONTO', 'ONUS', 'ORAL', 'ORGY', 'OSLO', 'OTIS', 'OTTO', 'OUCH',
238 'OUST', 'OUTS', 'OVAL', 'OVEN', 'OVER', 'OWLY', 'OWNS', 'QUAD',
239 'QUIT', 'QUOD', 'RACE', 'RACK', 'RACY', 'RAFT', 'RAGE', 'RAID',
240 'RAIL', 'RAIN', 'RAKE', 'RANK', 'RANT', 'RARE', 'RASH', 'RATE',
241 'RAVE', 'RAYS', 'READ', 'REAL', 'REAM', 'REAR', 'RECK', 'REED',
242 'REEF', 'REEK', 'REEL', 'REID', 'REIN', 'RENA', 'REND', 'RENT',
243 'REST', 'RICE', 'RICH', 'RICK', 'RIDE', 'RIFT', 'RILL', 'RIME',
244 'RING', 'RINK', 'RISE', 'RISK', 'RITE', 'ROAD', 'ROAM', 'ROAR',
245 'ROBE', 'ROCK', 'RODE', 'ROIL', 'ROLL', 'ROME', 'ROOD', 'ROOF',
246 'ROOK', 'ROOM', 'ROOT', 'ROSA', 'ROSE', 'ROSS', 'ROSY', 'ROTH',
247 'ROUT', 'ROVE', 'ROWE', 'ROWS', 'RUBE', 'RUBY', 'RUDE', 'RUDY',
248 'RUIN', 'RULE', 'RUNG', 'RUNS', 'RUNT', 'RUSE', 'RUSH', 'RUSK',
249 'RUSS', 'RUST', 'RUTH', 'SACK', 'SAFE', 'SAGE', 'SAID', 'SAIL',
250 'SALE', 'SALK', 'SALT', 'SAME', 'SAND', 'SANE', 'SANG', 'SANK',
251 'SARA', 'SAUL', 'SAVE', 'SAYS', 'SCAN', 'SCAR', 'SCAT', 'SCOT',
252 'SEAL', 'SEAM', 'SEAR', 'SEAT', 'SEED', 'SEEK', 'SEEM', 'SEEN',
253 'SEES', 'SELF', 'SELL', 'SEND', 'SENT', 'SETS', 'SEWN', 'SHAG',
254 'SHAM', 'SHAW', 'SHAY', 'SHED', 'SHIM', 'SHIN', 'SHOD', 'SHOE',
255 'SHOT', 'SHOW', 'SHUN', 'SHUT', 'SICK', 'SIDE', 'SIFT', 'SIGH',
256 'SIGN', 'SILK', 'SILL', 'SILO', 'SILT', 'SINE', 'SING', 'SINK',
257 'SIRE', 'SITE', 'SITS', 'SITU', 'SKAT', 'SKEW', 'SKID', 'SKIM',
258 'SKIN', 'SKIT', 'SLAB', 'SLAM', 'SLAT', 'SLAY', 'SLED', 'SLEW',
259 'SLID', 'SLIM', 'SLIT', 'SLOB', 'SLOG', 'SLOT', 'SLOW', 'SLUG',
260 'SLUM', 'SLUR', 'SMOG', 'SMUG', 'SNAG', 'SNOB', 'SNOW', 'SNUB',
261 'SNUG', 'SOAK', 'SOAR', 'SOCK', 'SODA', 'SOFA', 'SOFT', 'SOIL',
262 'SOLD', 'SOME', 'SONG', 'SOON', 'SOOT', 'SORE', 'SORT', 'SOUL',
263 'SOUR', 'SOWN', 'STAB', 'STAG', 'STAN', 'STAR', 'STAY', 'STEM',
264 'STEW', 'STIR', 'STOW', 'STUB', 'STUN', 'SUCH', 'SUDS', 'SUIT',
265 'SULK', 'SUMS', 'SUNG', 'SUNK', 'SURE', 'SURF', 'SWAB', 'SWAG',
266 'SWAM', 'SWAN', 'SWAT', 'SWAY', 'SWIM', 'SWUM', 'TACK', 'TACT',
267 'TAIL', 'TAKE', 'TALE', 'TALK', 'TALL', 'TANK', 'TASK', 'TATE',
268 'TAUT', 'TEAL', 'TEAM', 'TEAR', 'TECH', 'TEEM', 'TEEN', 'TEET',
269 'TELL', 'TEND', 'TENT', 'TERM', 'TERN', 'TESS', 'TEST', 'THAN',
270 'THAT', 'THEE', 'THEM', 'THEN', 'THEY', 'THIN', 'THIS', 'THUD',
271 'THUG', 'TICK', 'TIDE', 'TIDY', 'TIED', 'TIER', 'TILE', 'TILL',
272 'TILT', 'TIME', 'TINA', 'TINE', 'TINT', 'TINY', 'TIRE', 'TOAD',
273 'TOGO', 'TOIL', 'TOLD', 'TOLL', 'TONE', 'TONG', 'TONY', 'TOOK',
274 'TOOL', 'TOOT', 'TORE', 'TORN', 'TOTE', 'TOUR', 'TOUT', 'TOWN',
275 'TRAG', 'TRAM', 'TRAY', 'TREE', 'TREK', 'TRIG', 'TRIM', 'TRIO',
276 'TROD', 'TROT', 'TROY', 'TRUE', 'TUBA', 'TUBE', 'TUCK', 'TUFT',
277 'TUNA', 'TUNE', 'TUNG', 'TURF', 'TURN', 'TUSK', 'TWIG', 'TWIN',
278 'TWIT', 'ULAN', 'UNIT', 'URGE', 'USED', 'USER', 'USES', 'UTAH',
279 'VAIL', 'VAIN', 'VALE', 'VARY', 'VASE', 'VAST', 'VEAL', 'VEDA',
280 'VEIL', 'VEIN', 'VEND', 'VENT', 'VERB', 'VERY', 'VETO', 'VICE',
281 'VIEW', 'VINE', 'VISE', 'VOID', 'VOLT', 'VOTE', 'WACK', 'WADE',
282 'WAGE', 'WAIL', 'WAIT', 'WAKE', 'WALE', 'WALK', 'WALL', 'WALT',
283 'WAND', 'WANE', 'WANG', 'WANT', 'WARD', 'WARM', 'WARN', 'WART',
284 'WASH', 'WAST', 'WATS', 'WATT', 'WAVE', 'WAVY', 'WAYS', 'WEAK',
285 'WEAL', 'WEAN', 'WEAR', 'WEED', 'WEEK', 'WEIR', 'WELD', 'WELL',
286 'WELT', 'WENT', 'WERE', 'WERT', 'WEST', 'WHAM', 'WHAT', 'WHEE',
287 'WHEN', 'WHET', 'WHOA', 'WHOM', 'WICK', 'WIFE', 'WILD', 'WILL',
288 'WIND', 'WINE', 'WING', 'WINK', 'WINO', 'WIRE', 'WISE', 'WISH',
289 'WITH', 'WOLF', 'WONT', 'WOOD', 'WOOL', 'WORD', 'WORE', 'WORK',
290 'WORM', 'WORN', 'WOVE', 'WRIT', 'WYNN', 'YALE', 'YANG', 'YANK',
291 'YARD', 'YARN', 'YAWL', 'YAWN', 'YEAH', 'YEAR', 'YELL', 'YOGA',
292 'YOKE']
293
294_ALGO_DICT = {OTP_ALGO_MD5: 'md5', OTP_ALGO_SHA1: 'sha1'}
295
296
297class OTPGeneratorException(Exception):
298 """OTPGeneratorException class"""
299
300
301class OTPChallengeException(Exception):
302 """OTPChallengeException class"""
303
304
305class OTPGenerator:
306 """OTPGenerator class"""
307
308 def __init__(
309 self,
310 password: bytes,
311 seed: str = '',
312 hash_algo=OTP_ALGO_MD5,
313 ):
314 """
315 Constructs an OTPGenerator object with a given password and seed.
316
317 :param password: The password string
318 :type password: bytes
319
320 :param seed: The seed received from the challenge, defaults to ''
321 :type seed: str
322
323 :param hash_algo: The hash algo, defaults to OTP_ALGO_MD5
324 :type hash_algo: int or str
325
326 :raises otp2289.OTPGeneratorException: If the input does not validate
327 """
328 # enforce the rfc2289 constraints
329 self._seed = seed
330 if self._seed: # the seed was set here. Validate it
331 self._seed = self.validate_seed(self._seed)
332 self._hash_algo = self.validate_hash_algo(hash_algo)
333 if not isinstance(password, bytes):
334 raise OTPGeneratorException('Password must be a byte-string')
335 if len(password) < 10:
336 raise OTPGeneratorException(
337 'Password must be longer than 10 bytes'
338 )
339 self._password = password
340
341 def __repr__(self):
342 """repr implementation"""
343 return (
344 f'{self.__class__} at {id(self)} (seed={self._seed}, '
345 f'hash_algo={self._hash_algo})'
346 )
347
348 @staticmethod
349 def bit_pair_sum(bit_stream: str) -> int:
350 """
351 Split bit_stream in bit-pairs and sum them all together.
352
353 :param bit_stream: The bit-stream object
354 :type bit_stream: str
355
356 :return: The sum of all bit-pairs in bit_stream
357 :rtype: int
358 """
359 if not isinstance(bit_stream, str):
360 raise OTPGeneratorException('bit_stream must be of type str')
361 if len(bit_stream) != 64:
362 raise OTPGeneratorException('bit_stream must be of size 64')
363 value = 0
364 for pair in zip(bit_stream[::2], bit_stream[1::2]):
365 value += int(''.join(pair), 2)
366 return value
367
368 @staticmethod
369 def bytes_to_tokens(hash_bytes: bytes) -> str:
370 """
371 Returns a 6 words token from bytes as specified by RFC-2289.
372
373 :param hash_bytes: The input bytes
374 :type hash_bytes: bytes
375
376 :return: 6 words tokens
377 :rtype: str
378 """
379 bit_stream = ''.join([f'{byte:0>8b}' for byte in hash_bytes])
380 bit_pair_sum = OTPGenerator.bit_pair_sum(bit_stream)
381 tokens = []
382 tokens.append(RFC1760_TOKENS[int(bit_stream[:11], 2)])
383 tokens.append(RFC1760_TOKENS[int(bit_stream[11:22], 2)])
384 tokens.append(RFC1760_TOKENS[int(bit_stream[22:33], 2)])
385 tokens.append(RFC1760_TOKENS[int(bit_stream[33:44], 2)])
386 tokens.append(RFC1760_TOKENS[int(bit_stream[44:55], 2)])
387 tokens.append(
388 RFC1760_TOKENS[
389 int(
390 bit_stream[55:64] + f'{bit_pair_sum:0>8b}'[-2:],
391 2,
392 )
393 ]
394 )
395 return ' '.join(tokens)
396
397 @staticmethod
398 def get_tokens_from_challenge(challenge: str) -> tuple:
399 """
400 Returns tokens (seed, hash_algo and step) from a challenge string.
401
402 N.B. The tokens are not validated here.
403
404 :param challenge: The challenge string described in RFC-2289
405 :type challenge: str
406
407 :raises otp2289.OTPChallengeException: If the challenge is invalid
408
409 :return: (seed, hash_algo, step) tuple.
410 :rtype: tuple
411 """
412 if not isinstance(challenge, str):
413 raise OTPChallengeException('Challenge must be str')
414 challenge = challenge.strip()
415 if not challenge.startswith('otp-'):
416 raise OTPChallengeException('Invalid challenge')
417 try:
418 hash_algo, step, seed = challenge[4:].split()
419 return (seed, hash_algo, int(step))
420 except ValueError:
421 raise OTPChallengeException('Invalid challenge') from None
422
423 @staticmethod
424 def sha1_digest_folding(sha1_digest: bytes) -> bytes:
425 """
426 Implementation of the 160bit -> 64bit folding algorithm
427 for sha1 digest.
428
429 :param sha1_digest: The SHA1 digest
430 :type sha1_digest: bytes
431
432 :return: The byte-string representing the folded sha1-digest
433 :rtype: bytes
434 """
435 if not (isinstance(sha1_digest, bytes)):
436 raise OTPGeneratorException('sha1_digest must be of type bytes')
437 if len(sha1_digest) != 20:
438 raise OTPGeneratorException(
439 'sha1_digest must be 160 bits (20 bytes) long'
440 )
441 digested = list(5 * b'i') # 5 bytes (40 bits)
442 result = list(8 * b'x') # 8 bytes (64 bits)
443 for i in range(5):
444 digested[i] = (
445 ((sha1_digest[i * 4 + 0] & 0xFF) << 24)
446 | ((sha1_digest[i * 4 + 1] & 0xFF) << 16)
447 | ((sha1_digest[i * 4 + 2] & 0xFF) << 8)
448 | (sha1_digest[i * 4 + 3] & 0xFF)
449 )
450 # sha.digest[0] ^= sha.digest[2];
451 # sha.digest[1] ^= sha.digest[3];
452 # sha.digest[0] ^= sha.digest[4];
453 digested[0] ^= digested[2]
454 digested[1] ^= digested[3]
455 digested[0] ^= digested[4]
456 # for (i = 0, j = 0; j < 8; i++, j += 4) {
457 # result[j] = (unsigned char)(sha.digest[i] & 0xff);
458 # result[j+1] = (unsigned char)((sha.digest[i] >> 8) & 0xff);
459 # result[j+2] = (unsigned char)((sha.digest[i] >> 16) & 0xff);
460 # result[j+3] = (unsigned char)((sha.digest[i] >> 24) & 0xff);
461 # }
462 # just hardcoding the two iterations for better efficiency
463 result[0] = digested[0] & 0xFF
464 result[1] = (digested[0] >> 8) & 0xFF
465 result[2] = (digested[0] >> 16) & 0xFF
466 result[3] = (digested[0] >> 24) & 0xFF
467 result[4] = digested[1] & 0xFF
468 result[5] = (digested[1] >> 8) & 0xFF
469 result[6] = (digested[1] >> 16) & 0xFF
470 result[7] = (digested[1] >> 24) & 0xFF
471 return bytes(result)
472
473 @staticmethod
474 def strxor(byte_str1: bytes, byte_str2: bytes) -> bytes:
475 """
476 Implementation of strxor similar to the one provided by pycrypto.
477
478 :param byte_str1: Byte-string 1
479 :type byte_str1: bytes
480
481 :param byte_str2: Byte-string 2
482 :type byte_str2: bytes
483
484 :return: The byte-string representing the result of byte_str1^byte_str2
485 :rtype: bytes
486 """
487 if not (isinstance(byte_str1, bytes) and isinstance(byte_str2, bytes)):
488 raise OTPGeneratorException(
489 'byte_str1 and byte_str2 must be of type bytes'
490 )
491 length = len(byte_str1)
492 if length != len(byte_str2) or length < 1:
493 raise OTPGeneratorException(
494 'byte_str1 and byte_str2 must be of the same length > 0'
495 )
496 return bytes([byte_str1[i] ^ byte_str2[i] for i in range(length)])
497
498 @staticmethod
499 def tokens_to_bytes(tokens_str: str) -> bytes:
500 """
501 Returns bytes from a 6 words token as specified by RFC-2289.
502
503 :param tokens_str: String representing 6 words tokens
504 :type tokens_str: str
505
506 :raises otp2289.OTPGeneratorException: When the tokens_str is invalid
507
508 :return: 6 words tokens
509 :rtype: bytes
510 """
511 if not isinstance(tokens_str, str):
512 raise OTPGeneratorException('tokens must be a str')
513 tokens = tokens_str.split()
514 if len(tokens) != 6:
515 raise OTPGeneratorException(
516 'Tokens-string does not contain 6 tokens'
517 )
518 token_ints = []
519 try:
520 token_ints = [
521 RFC1760_TOKENS.index(token.upper()) for token in tokens
522 ]
523 except ValueError:
524 raise OTPGeneratorException(
525 'One or more words not present in RFC1760'
526 ) from None
527 # now we build a string of bits
528 bit_stream = format(token_ints[0], '011b')
529 bit_stream += format(token_ints[1], '011b')
530 bit_stream += format(token_ints[2], '011b')
531 bit_stream += format(token_ints[3], '011b')
532 bit_stream += format(token_ints[4], '011b')
533 bit_stream += format(token_ints[5], '011b')
534 # we have 66 bits: 64 digest + 2 bit pair sum (control number)
535 # RFC-2289: All OTP generators MUST calculate this checksum and all
536 # OTP servers MUST verify this checksum explicitly as part of the
537 # operation of decoding this representation of the one-time password.
538 if (
539 f'{OTPGenerator.bit_pair_sum(bit_stream[:64]):0>8b}'[-2:]
540 != bit_stream[-2:]
541 ):
542 raise OTPGeneratorException('Invalid bit checksum')
543 return int(bit_stream[:64], 2).to_bytes(8, 'big')
544
545 @staticmethod
546 def validate_hash_algo(hash_algo) -> str:
547 """
548 Validates the provided hash-algorithm.
549
550 :param hash_algo: The hash algo, defaults to OTP_ALGO_MD5
551 :type hash_algo: int or str
552
553 :raises otp2289.OTPGeneratorException: If hash_algo does not validate
554
555 :return: The validated hash_algo in str-form
556 :rtype: str
557 """
558 if isinstance(hash_algo, int):
559 if hash_algo not in _ALGO_DICT:
560 raise OTPGeneratorException(
561 'hash_algo is not among the known algorithms'
562 )
563 hash_algo = _ALGO_DICT.get(hash_algo)
564 if not isinstance(hash_algo, str):
565 raise OTPGeneratorException('hash_algo must be an int or a str')
566 if hash_algo not in hashlib.algorithms_available:
567 raise OTPGeneratorException(
568 f'{hash_algo} is not supported by this version of the '
569 'hashlib module'
570 )
571 return hash_algo
572
573 @staticmethod
574 def validate_seed(seed: str) -> str:
575 """
576 Validates the provided seed as defined by RFC-2289.
577
578 :param seed: The seed received from the challenge, defaults to ''
579 :type seed: str
580
581 :raises otp2289.OTPGeneratorException: If seed does not validate
582
583 :return: The validated (and very same) seed
584 :rtype: str
585 """
586 if not isinstance(seed, str):
587 raise OTPGeneratorException('Seed must be a string')
588 if not seed or len(seed) > 16:
589 raise OTPGeneratorException(
590 'The seed MUST be of 1 to 16 characters in length'
591 )
592 for char in seed:
593 if char not in string.ascii_letters + string.digits:
594 raise OTPGeneratorException(
595 'The seed MUST consist of purely alphanumeric characters'
596 )
597 return seed
598
599 @staticmethod
600 def validate_step(step: int) -> int:
601 """
602 Validates the provided step as defined by RFC-2289.
603
604 :param seed: The step received from the challenge
605 :type seed: int
606
607 :raises otp2289.OTPGeneratorException: If step does not validate
608
609 :return: The validated (and very same) step
610 :rtype: int
611 """
612 if not isinstance(step, int):
613 raise OTPGeneratorException('Step value MUST be an int')
614 if step < 0:
615 raise OTPGeneratorException('Step value MUST be >= 0')
616 return step
617
618 def generate_otp_hexdigest(self, step: int) -> str:
619 """
620 Generates the OTP hexdigest for the given step.
621
622 :param step: The step to generate OTP for
623 :type step: int
624
625 :return: Hexdigest for the given step
626 :rtype: str
627 """
628 return '0x' + binascii.hexlify(self._generate_otp_bytes(step)).decode()
629
630 def generate_otp_hexdigest_from_challenge(self, challenge: str) -> str:
631 """
632 Same as generate_otp_hexdigest, but it generates hex. from a challenge.
633
634 RFC-2289 states:
635 The challenge MUST be in a standard syntax so
636 that automated generators can recognize the challenge in context and
637 extract these parameters. The syntax of the challenge is:
638 otp-<algorithm identifier> <sequence integer> <seed>
639
640 :param challenge: The challenge string
641 :type challenge: str
642
643 :return: Hexdigest for the given challenge
644 :rtype: str
645 """
646 seed, hash_algo, step = self.get_tokens_from_challenge(challenge)
647 self._seed = self.validate_seed(seed)
648 self._hash_algo = self.validate_hash_algo(hash_algo)
649 return self.generate_otp_hexdigest(step)
650
651 def generate_otp_words(self, step: int) -> str:
652 """
653 Generates the OTP six words token for the given step.
654
655 :param step: The step to generate OTP for
656 :type step: int
657
658 :return: Six words (separated by single space) token for the given step
659 :rtype: str
660 """
661 return self.bytes_to_tokens(self._generate_otp_bytes(step))
662
663 def generate_otp_words_from_challenge(self, challenge: str) -> str:
664 """
665 Same as generate_otp_words, but it generates words from a challenge.
666
667 RFC-2289 states:
668 The challenge MUST be in a standard syntax so
669 that automated generators can recognize the challenge in context and
670 extract these parameters. The syntax of the challenge is:
671 otp-<algorithm identifier> <sequence integer> <seed>
672
673 :param challenge: The challenge string
674 :type challenge: str
675
676 :return: Six words token for the given challenge
677 :rtype: str
678 """
679 seed, hash_algo, step = self.get_tokens_from_challenge(challenge)
680 self._seed = self.validate_seed(seed)
681 self._hash_algo = self.validate_hash_algo(hash_algo)
682 return self.generate_otp_words(step)
683
684 def hexdigest_range(self, start: int = 499, stop: int = 0):
685 """
686 Returns an iterator that providing hexdigests corresponding to steps
687 from `start` to and including `stop`.
688
689 :param start: The start of the range (default: 499)
690 :type start: int
691
692 :param stop: The last step (default: 0)
693 :type stop: int
694
695 :return: Iterator
696 :rtype: generator
697 """
698 if not isinstance(start, int) and isinstance(stop, int):
699 raise OTPGeneratorException('Step value MUST be an int')
700 if start < stop:
701 raise OTPGeneratorException(
702 'Start value can not be lower than stop'
703 )
704 for step in range(start, stop - 1, -1):
705 yield self.generate_otp_hexdigest(step)
706
707 def words_range(self, start: int = 499, stop: int = 0):
708 """
709 Returns an iterator that providing the words corresponding to steps
710 from `start` to and including `stop`.
711
712 :param start: The start of the range (default: 499)
713 :type start: int
714
715 :param stop: The last step (default: 0)
716 :type stop: int
717
718 :return: Iterator
719 :rtype: generator
720 """
721 if not isinstance(start, int) and isinstance(stop, int):
722 raise OTPGeneratorException('Step value MUST be an int')
723 if start < stop:
724 raise OTPGeneratorException(
725 'Start value can not be lower than stop'
726 )
727 for step in range(start, stop - 1, -1):
728 yield self.generate_otp_words(step)
729
730 def _generate_otp_bytes(self, step: int) -> bytes:
731 """
732 Generates the OTP bytes for the given step.
733
734 :param step: The step to generate OTP for
735 :type step: int
736
737 :return: The digest bytes for the given step
738 :rtype: bytes
739 """
740 step = self.validate_step(step)
741 digest = b''
742 for _ in range(step + 1):
743 hash_obj = hashlib.new(self._hash_algo)
744 if not digest:
745 # 0 step
746 hash_obj.update(self._seed.lower().encode() + self._password)
747 else:
748 hash_obj.update(digest)
749 large_digest = hash_obj.digest()
750 if self._hash_algo == 'md5':
751 # md4 and md5 128bit -> 64bit folding
752 digest = self.strxor(large_digest[0:8], large_digest[8:])
753 elif self._hash_algo == 'sha1':
754 # sha1 160bit -> 64bit folding
755 digest = self.sha1_digest_folding(large_digest)
756 else:
757 raise OTPGeneratorException(
758 f'{self._hash_algo} is not supported by this module'
759 )
760 return digest
diff --git a/src/otp2289/server.py b/src/otp2289/server.py
new file mode 100644
index 0000000..066cba2
--- /dev/null
+++ b/src/otp2289/server.py
@@ -0,0 +1,476 @@
1# -*- coding: utf-8 -*-
2# SPDX-License-Identifier: BSD-2-Clause-FreeBSD
3#
4# Copyright (c) 2020-2022 Simeon Simeonov
5# All rights reserved.
6#
7# Redistribution and use in source and binary forms, with or without
8# modification, are permitted provided that the following conditions
9# are met:
10# 1. Redistributions of source code must retain the above copyright
11# notice, this list of conditions and the following disclaimer.
12# 2. Redistributions in binary form must reproduce the above copyright notice,
13# this list of conditions and the following disclaimer in the documentation
14# and/or other materials provided with the distribution.
15#
16# THIS SOFTWARE IS PROVIDED BY THE AUTHORS ``AS IS'' AND ANY EXPRESS OR
17# IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
18# OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
19# IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT,
20# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
21# NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
22# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
23# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
24# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
25# THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
26"""A pure Python implementation of the RFC-2289 OTP server"""
27import binascii
28import hashlib
29
30from .generator import OTP_ALGO_MD5, OTPGenerator, OTPGeneratorException
31
32
33class OTPStateException(Exception):
34 """OTPStateException class"""
35
36
37class OTPStoreException(Exception):
38 """OTPStoreException class"""
39
40
41class OTPInvalidResponse(Exception):
42 """OTPInvalidResponse class"""
43
44
45class OTPState:
46 """
47 OTPState class
48
49 The OTPState class represents a single state on the server side that can:
50 - generate a challenge
51 - validate the corresponding generated response from the generator
52 """
53
54 def __init__(
55 self,
56 ot_hex: str,
57 current_step: int,
58 seed: str,
59 hash_algo=OTP_ALGO_MD5,
60 ):
61 """
62 Constructs an OTPState object with the given arguments.
63
64 Keyword Arguments:
65 :param ot_hex: The one-time hex from the last successful authentication
66 or None for a newly initialized sequence.
67 :type ot_hex: str or None
68
69 :param current_step: The current step that is sent with the challenge
70 :type current_step: int
71
72 :param seed: The seed that is sent with the challenge
73 :type seed: str
74
75 :param hash_algo: The hash algo, defaults to OTP_ALGO_MD5
76 :type hash_algo: int or str
77
78 :raises otp2289.OTPStateException: If the input does not validate
79 """
80 # enforce the rfc2289 constraints
81 try:
82 self._seed = OTPGenerator.validate_seed(seed)
83 self._hash_algo = OTPGenerator.validate_hash_algo(hash_algo)
84 self._step = OTPGenerator.validate_step(current_step)
85 except OTPGeneratorException as exp:
86 raise OTPStateException(exp.args[0]) from None
87 self._current_digest = None
88 if ot_hex is not None:
89 self._current_digest = self.validate_hex(ot_hex)
90 self._new_digest_hex = None # set upon a successful validation
91
92 def __repr__(self):
93 """repr implementation"""
94 return (
95 f'{self.__class__} at {id(self)} '
96 f'(ot_hex={self._current_digest}, current_step={self._step}, '
97 f'seed={self._seed}, '
98 f'hash_algo={self._hash_algo})'
99 )
100
101 @property
102 def challenge_string(self) -> str:
103 """challenge_string-property"""
104 # RFC-2289: "...the entire challenge string MUST be
105 # terminated with either a space or a new line."
106 return f'otp-{self._hash_algo} {self._step} {self._seed} '
107
108 @property
109 def current_digest(self) -> bytes:
110 """current_digest-property"""
111 return self._current_digest
112
113 @property
114 def hash_algo(self) -> str:
115 """hash_algo-property"""
116 return self._hash_algo
117
118 @property
119 def seed(self) -> str:
120 """seed-property"""
121 return self._seed
122
123 @property
124 def step(self) -> int:
125 """step-property"""
126 return self._step
127
128 @property
129 def validated(self) -> bool:
130 """validated-property"""
131 return bool(self._new_digest_hex)
132
133 @classmethod
134 def from_dict(cls, dict_obj: dict):
135 """
136 Returns an OTPState object from the dict-object
137
138 :param dict_obj: The dict object
139 :type dict_obj: dict
140
141 :return: A new OTPState object
142 :rtype: otp2289.OTPStore
143 """
144 return cls(**dict_obj)
145
146 @staticmethod
147 def response_to_bytes(response: str) -> bytes:
148 """
149 A wrapper that handles/validates the response as specified by RFC-2289.
150
151 The method first checks if response is a token and tries to convert
152 it to bytes. If that fails, the method assumes that response is a hex.
153 If neither of those attempts succeeds OTPInvalidResponse is raised.
154 It is up to the caller to run another iteration and compare the result
155 to an existing digest in this state.
156
157 :param response: The response to this state (its challenge)
158 :type response: str
159
160 :raises otp2289.OTPInvalidResponse: If the response is corrupt/illegal,
161 but not if it simply does not
162 validate
163
164 :return: The bytes representation of response (if any)
165 :rtype: bytes
166 """
167 try:
168 return OTPGenerator.tokens_to_bytes(response)
169 except OTPGeneratorException:
170 # now assume hex...
171 try:
172 return OTPState.validate_hex(response)
173 except OTPStateException:
174 raise OTPInvalidResponse(
175 'The response is neither a valid token or hex'
176 ) from None
177
178 @staticmethod
179 def validate_hex(ot_hex: str) -> bytes:
180 """
181 Validates the provided hexidigest.
182
183 :param ot_hex: The one-time hex to validate
184 :type ot_hex: str
185
186 :raises otp2289.OTPStateException: If hex does not validate
187
188 :return: The validated hex (without leading 0x) converted to bytes
189 :rtype: bytes
190 """
191 if not isinstance(ot_hex, str):
192 raise OTPStateException('OT-hex must be a str')
193 if ot_hex.startswith('0x'):
194 ot_hex = ot_hex[2:]
195 ot_hex = ot_hex.strip().lower()
196 if len(ot_hex) != 16:
197 raise OTPStateException(
198 'The length of the hex should be 16 '
199 '(representing 64 bits digest)'
200 )
201 try:
202 return binascii.unhexlify(ot_hex)
203 except binascii.Error:
204 raise OTPStateException('Invalid OT-hex') from None
205
206 def get_next_state(self):
207 """
208 Returns the next state for a validated OTPState.
209
210 This is a brand new OTPState object with the same hash_algo and seed
211 where step -= 1 and ot_hex = self._new_digest_hex
212
213 :return: The next OTPState if validated, None otherwise
214 :rtype: otp2289.OTPState or None
215 """
216 if self._new_digest_hex is None:
217 return None
218 return OTPState(
219 self._new_digest_hex,
220 self._step - 1,
221 self._seed,
222 self._hash_algo,
223 )
224
225 def response_validates(
226 self,
227 response: str,
228 store_valid_response: str = True,
229 ) -> bool:
230 """
231 Validates the incoming response as specified by RFC-2289.
232
233 :param response: The response to this state (its challenge)
234 :type response: str
235
236 :param store_valid_response: Should a valid response be stored
237 :type store_valid_response: bool
238
239 :raises otp2289.OTPInvalidResponse: If the response does not match
240 this state
241
242 :return: Returns True if response validates, False otherwise
243 :rtype: bool
244 """
245 # self.response_to_bytes raises OTPInvalidResponse in case response
246 # is corrupt or in a wrong format
247 response_bytes = self.response_to_bytes(response)
248 if self._hash_algo == 'md5':
249 digest = hashlib.md5(response_bytes).digest()
250 if (
251 self._current_digest is None
252 or OTPGenerator.strxor(digest[0:8], digest[8:])
253 == self._current_digest
254 ):
255 if store_valid_response:
256 self._new_digest_hex = binascii.hexlify(
257 response_bytes
258 ).decode()
259 return True
260 return False
261 if self._hash_algo == 'sha1':
262 digest = hashlib.sha1(response_bytes).digest()
263 if (
264 self._current_digest is None
265 or OTPGenerator.sha1_digest_folding(
266 hashlib.sha1(response_bytes).digest()
267 )
268 == self._current_digest
269 ):
270 if store_valid_response:
271 self._new_digest_hex = binascii.hexlify(
272 response_bytes
273 ).decode()
274 return True
275 return False
276 # this should not happen since the hash_algo is validated by the caller
277 raise OTPInvalidResponse(f'Ivalid hash_algo: {self._hash_algo}')
278
279 def to_dict(self) -> dict:
280 """
281 Returns a dict representation of the object.
282
283 This could be the base for a JSON serialization.
284
285 :return: The dict representation of the object
286 :rtype: dict
287 """
288 ot_hex = self._current_digest
289 if ot_hex is not None:
290 ot_hex = binascii.hexlify(self._current_digest).decode()
291 return {
292 'ot_hex': ot_hex,
293 'current_step': self._step,
294 'seed': self._seed,
295 'hash_algo': self._hash_algo,
296 }
297
298
299class OTPStore:
300 """
301 OTPStore class
302
303 A helper / container class that stores OTPState objects in a 2 layered
304 dict structure represented by [domain][key].
305
306 The class could serve as a base class when implementing store backends.
307 """
308
309 def __init__(self, data=None):
310 """
311 Constructs an OTPStore object from data
312
313 :param data: The data object, defaults to None
314 :type data: object or None
315 """
316 self._data = {} # {key1: {state1-data...}, key2: {state2-data...}}
317 self._states = {} # OTPState: (domain, key) - dict
318 if data is not None:
319 self._add_data(data)
320
321 def __contains__(self, state):
322 """membership test"""
323 return state in self._states
324
325 def __iter__(self):
326 """iterator for OTPStore"""
327 return iter(self._data)
328
329 def __len__(self):
330 """len() implementation"""
331 return len(self._data)
332
333 @property
334 def data(self) -> dict:
335 """
336 data-property
337
338 Exposes the entire raw-data structure (dict).
339 Use the high level methods when possible!
340 """
341 return self._data
342
343 @property
344 def states(self) -> dict:
345 """
346 states-property
347
348 Exposes the entire states structure (dict).
349 Use the high level methods when possible!
350 """
351 return self._states
352
353 def add_state(self, key: str, state: OTPState):
354 """
355 Adds an OTPState object with a given key.
356
357 :param key: The key under which to add the state
358 :type key: str
359
360 :param state: The OTPState object
361 :type state: otp2289.OTPState
362
363 :raises otp2289.OTPStoreException: On failure
364 """
365 if not isinstance(key, str):
366 raise OTPStoreException('key must be a str')
367 if not isinstance(state, OTPState):
368 raise OTPStoreException('state must be an OTPState-object')
369 self._data[key] = state
370 self._states[state] = key
371
372 def get(self, key, default=None):
373 """A wrapper for dict.get"""
374 return self._data.get(key, default)
375
376 def items(self):
377 """A wrapper for dict.items"""
378 return self._data.items()
379
380 def pop_state(self, key: str) -> OTPState:
381 """
382 Removes specified key and returns the corresponding OTPState-object.
383
384 :param key: The key
385 :type key: str
386
387 :raises KeyError: If key does not exist
388
389 :raises otp2289.OTPStoreException: On failure
390
391 :return: The state corresponding to the key
392 :rtype: otp2289.OTPState
393 """
394 if not isinstance(key, str):
395 raise OTPStoreException('key must be a str')
396 state = self._data.pop(key)
397 self._states.pop(state)
398 return state
399
400 def response_validates(
401 self,
402 key: str,
403 response: str,
404 store_valid_response: bool = True,
405 ) -> bool:
406 """
407 A method that wraps around OTPState.response_validates and
408 OTPState.get_next_state.
409
410 The response is validated against the OTPState object that corresponds
411 to key (if any). If store_valid_response is True, the state is replaced
412 by the next state on successful validation.
413
414 :param key: The key
415 :type key: str
416
417 :param response: The response to this state (its challenge)
418 :type response: str
419
420 :param store_valid_response: Should a valid response be stored
421 :type store_valid_response: bool
422
423 :raises KeyError: If the key is not present
424
425 :raises otp2289.OTPInvalidResponse: If the response does not match
426 this state
427
428 :return: Returns True if response validates, False otherwise
429 :rtype: bool
430 """
431 state = self._data[key]
432 rvalue = state.response_validates(response, store_valid_response)
433 if rvalue and store_valid_response:
434 next_state = state.get_next_state()
435 self._data[key] = next_state
436 self._states[next_state] = key
437 self._states.pop(state)
438 return rvalue
439
440 def to_dict(self) -> dict:
441 """
442 Returns a dict representation of the object.
443
444 This could be the base for a JSON serialization.
445
446 :return: The dict representation of the object
447 :rtype: dict
448 """
449 return {key: state.to_dict() for key, state in self._data.items()}
450
451 def _add_data(self, dict_obj: dict) -> dict:
452 """
453 Adds data from a dict object (dict_obj).
454
455 This method should probably be either overloaded or wrapped
456 in a child class.
457
458 dict_obj has the following format:
459 {
460 'key': {
461 'ot_hex': val1,
462 'current_step': val2,
463 'seed': val3,
464 'hash_algo': val4
465 },
466 ...,
467 ...,
468 }
469
470 :param dict_obj: The dict-object
471 :type dict_obj: dict
472 """
473 if not dict_obj:
474 return
475 for key, state_dict in dict_obj.items():
476 self.add_state(key, OTPState(**state_dict))