From 4ea3c077bd4111960af1b93195cb25bfb1d6edce Mon Sep 17 00:00:00 2001 From: Simeon Simeonov Date: Thu, 31 Mar 2022 07:21:33 +0200 Subject: Restructure the project in order to replace distutils with setuptools --- otp2289/__init__.py | 68 ----- otp2289/__main__.py | 385 ------------------------ otp2289/generator.py | 760 ----------------------------------------------- otp2289/server.py | 471 ----------------------------- pyproject.toml | 14 + setup.cfg | 42 +++ setup.py | 50 ---- sphinx_conf.py | 13 +- src/otp2289/__init__.py | 68 +++++ src/otp2289/__main__.py | 385 ++++++++++++++++++++++++ src/otp2289/generator.py | 760 +++++++++++++++++++++++++++++++++++++++++++++++ src/otp2289/server.py | 476 +++++++++++++++++++++++++++++ 12 files changed, 1753 insertions(+), 1739 deletions(-) delete mode 100644 otp2289/__init__.py delete mode 100644 otp2289/__main__.py delete mode 100644 otp2289/generator.py delete mode 100644 otp2289/server.py create mode 100644 pyproject.toml create mode 100644 setup.cfg delete mode 100644 setup.py create mode 100644 src/otp2289/__init__.py create mode 100644 src/otp2289/__main__.py create mode 100644 src/otp2289/generator.py create mode 100644 src/otp2289/server.py diff --git a/otp2289/__init__.py b/otp2289/__init__.py deleted file mode 100644 index 59694a7..0000000 --- a/otp2289/__init__.py +++ /dev/null @@ -1,68 +0,0 @@ -# -*- coding: utf-8 -*- -# SPDX-License-Identifier: BSD-2-Clause-FreeBSD -# -# Copyright (c) 2020-2022 Simeon Simeonov -# All rights reserved. -# -# Redistribution and use in source and binary forms, with or without -# modification, are permitted provided that the following conditions -# are met: -# 1. Redistributions of source code must retain the above copyright -# notice, this list of conditions and the following disclaimer. -# 2. Redistributions in binary form must reproduce the above copyright notice, -# this list of conditions and the following disclaimer in the documentation -# and/or other materials provided with the distribution. -# -# THIS SOFTWARE IS PROVIDED BY THE AUTHORS ``AS IS'' AND ANY EXPRESS OR -# IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES -# OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. -# IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, -# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT -# NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF -# THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -"""A pure Python implementation of RFC-2289""" -from .generator import ( - OTP_ALGO_MD5, - OTP_ALGO_SHA1, - OTPChallengeException, - OTPGenerator, - OTPGeneratorException, -) -from .server import ( - OTPInvalidResponse, - OTPState, - OTPStateException, - OTPStore, - OTPStoreException, -) - -__author__ = 'Simeon Simeonov' -__version__ = '1.1.0' -__license__ = 'BSD 2-Clause' - - -def int_or_str(value): - """Returns int value of value when possible""" - try: - return int(value) - except ValueError: - return value - - -VERSION = tuple(map(int_or_str, __version__.split('.'))) - -__all__ = [ - 'OTP_ALGO_MD5', - 'OTP_ALGO_SHA1', - 'OTPChallengeException', - 'OTPGenerator', - 'OTPGeneratorException', - 'OTPInvalidResponse', - 'OTPState', - 'OTPStateException', - 'OTPStore', - 'OTPStoreException', -] diff --git a/otp2289/__main__.py b/otp2289/__main__.py deleted file mode 100644 index 78e60e8..0000000 --- a/otp2289/__main__.py +++ /dev/null @@ -1,385 +0,0 @@ -# -*- coding: utf-8 -*- -# SPDX-License-Identifier: BSD-2-Clause-FreeBSD -# -# Copyright (c) 2020-2022 Simeon Simeonov -# All rights reserved. -# -# Redistribution and use in source and binary forms, with or without -# modification, are permitted provided that the following conditions -# are met: -# 1. Redistributions of source code must retain the above copyright -# notice, this list of conditions and the following disclaimer. -# 2. Redistributions in binary form must reproduce the above copyright notice, -# this list of conditions and the following disclaimer in the documentation -# and/or other materials provided with the distribution. -# -# THIS SOFTWARE IS PROVIDED BY THE AUTHORS ``AS IS'' AND ANY EXPRESS OR -# IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES -# OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. -# IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, -# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT -# NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF -# THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -""" -CLI entry point for the otp2289 package - -Examples: -python -m otp2289 --initiate-new-sequence -s TesT - -python -m otp2289 --generate-otp-response -c "otp-md5 499 TesT " -f token -python -m otp2289 --generate-otp-response -s TesT -i 499 -f token -""" -import argparse -import errno -import getpass -import io -import os -import secrets -import string -import sys - -import otp2289 - - -def eprint(*arg, **kwargs): - """stdderr print wrapper""" - print(*arg, file=sys.stderr, flush=True, **kwargs) - - -def generate_otp_response(args: argparse.Namespace) -> str: - """ - Generates a response based on the parameters sent from the parser - - :param args: The arguments assigned from argparse - :type args: argparse.Namespace - - :raises otp2289.OTPChallengeException: If the challenge is invalid - - :raises otp2289.OTPGeneratorException: If generator parameters are wrong - - :return: The response string - :rtype: str - """ - generator = otp2289.generator.OTPGenerator( - args.password.encode(), - args.seed, - args.hash_algo, - ) - if args.challenge_string: - if args.output_format == 'token': - return generator.generate_otp_words_from_challenge( - args.challenge_string - ) - return generator.generate_otp_hexdigest_from_challenge( - args.challenge_string - ) - # regular parameters - header = '' - if not args.quiet: - header = ( - f'Seed: {args.seed}, Step: {args.step}, ' - f'Hash: {args.hash_algo}{os.linesep}' - ) - if args.output_format == 'token': - return header + generator.generate_otp_words(args.step) - return header + generator.generate_otp_hexdigest(args.step) - - -def generate_otp_range(args: argparse.Namespace) -> str: - """ - Generates range of responses based on the parameters sent from the parser - - :param args: The arguments assigned from argparse - :type args: argparse.Namespace - - :raises otp2289.OTPChallengeException: If the challenge is invalid - - :raises otp2289.OTPGeneratorException: If generator parameters are wrong - - :return: The responses string - :rtype: str - """ - generator = otp2289.generator.OTPGenerator( - args.password.encode(), - args.seed, - args.hash_algo, - ) - if args.output_format == 'token': - method = generator.generate_otp_words - else: - method = generator.generate_otp_hexdigest - # handle most cases explicitly - if args.range == 1: - return f'{args.step}: ' + method(args.step) - if args.range > args.step + 1: - args.range = args.step + 1 - # any need for quiet? - header = '' - if not args.quiet: - header = ( - f'Seed: {args.seed}, Step: {args.step}, ' - f'Hash: {args.hash_algo}, Range: {args.range}{os.linesep}' - ) - return header + os.linesep.join( - [ - f'{step}: ' + method(step) - for step in range( - args.step, - args.step - args.range, - -1, - ) - ] - ) - - -def get_password(args: argparse.Namespace) -> str: - """ - Extract the provided password using the defined argparse arguments - - :param args: The arguments assigned from argparse - :type args: argparse.Namespace - - :raises KeyboardInterrupt: If the password prompt is interrupted - - :return: The extrated password string - :rtype: str - """ - if args.force_password_prompt: - while True: - password = getpass.getpass() - if not args.initiate_new_sequence or password == getpass.getpass( - 'Repeat password: ' - ): - break - eprint('The passwords do not match') - return password - - if not args.password: - password = os.environ.get('OTP2289_PASSWORD') - if password is not None: - return password - while True: - password = getpass.getpass() - if not args.initiate_new_sequence or password == getpass.getpass( - 'Repeat password: ' - ): - break - eprint('The passwords do not match') - return password - - if os.path.isfile(args.password): - with io.open(args.password, 'r', encoding='utf-8') as fp: - return fp.readline().strip() - - return args.password - - -def get_rnd_seed() -> str: - """ - Returns a random seed in the format: - - 2 random letters (capitalize()) + 5 random digits - """ - rnd = secrets.SystemRandom() - return ''.join( - rnd.choices(string.ascii_lowercase, k=2) - ).capitalize() + ''.join(rnd.choices(string.digits, k=5)) - - -def initiate_new_sequence(args: argparse.Namespace) -> str: - """ - Generates a new sequence based on the parameters sent from the parser. - - :param args: The arguments assigned from argparse - :type args: argparse.Namespace - - :raises otp2289.OTPChallengeException: If the challenge is invalid - - :raises otp2289.OTPGeneratorException: If generator parameters are wrong - - :return: The response string - :rtype: str - """ - if not args.seed: - args.seed = get_rnd_seed() - header = '' - if not args.quiet: - header = ( - f'Seed: {args.seed}, Step: {args.step}, ' - f'Hash: {args.hash_algo}{os.linesep}' - ) - generator = otp2289.generator.OTPGenerator( - args.password.encode(), - args.seed, - args.hash_algo, - ) - if args.challenge_string: - return header + generator.generate_otp_hexdigest_from_challenge( - args.challenge_string - ) - return header + generator.generate_otp_hexdigest(args.step) - - -def main(args=None): - """the main entry point""" - parser = argparse.ArgumentParser( - prog=__package__, - epilog=( - f'%(prog)s {otp2289.__version__} by Simeon Simeonov ' - '(sgs @ LiberaChat)' - ), - description='The following options are available', - ) - group = parser.add_mutually_exclusive_group(required=True) - password_group = parser.add_mutually_exclusive_group() - group.add_argument( - '--generate-otp-range', - action='store_true', - dest='generate_otp_range', - default=False, - help='Generates a range of OTP responses', - ) - group.add_argument( - '--generate-otp-response', - action='store_true', - dest='generate_otp_response', - default=False, - help='Generates a new OTP response', - ) - group.add_argument( - '--initiate-new-sequence', - action='store_true', - dest='initiate_new_sequence', - default=False, - help=( - 'Initiates a new OTP sequence. Essentially the same as ' - '--generate-otp-response only it prompts twice for password ' - 'and always outputs hex (ignores -f).' - ), - ) - password_group.add_argument( - '-P', - '--force-password-prompt', - dest='force_password_prompt', - action='store_true', - help=( - 'Force password prompt even if the env. variable ' - '"OTP2289_PASSWORD" is set' - ), - ) - password_group.add_argument( - '-p', - '--password', - metavar='', - type=str, - dest='password', - default='', - help=( - 'The password or path to password file ' - '(default & recommended: prompt for passwd)' - ), - ) - parser.add_argument( - '-a', - '--hash-algorithm', - metavar='', - type=str, - dest='hash_algo', - default='md5', - help='The hash algorithm to use. Possible values: md5 (default), sha1', - ) - parser.add_argument( - '-c', - '--challenge-string', - metavar='', - type=str, - dest='challenge_string', - default='', - help='Use challenge string when generating response', - ) - parser.add_argument( - '-f', - '--output-format', - metavar='', - type=str, - dest='output_format', - default='hex', - help='The output format to use. Possible values: hex (default), token', - ) - parser.add_argument( - '-i', - '--step', - metavar='', - type=int, - dest='step', - default=500, - help='The step. Default for initiating a new sequence is: 500', - ) - parser.add_argument( - '-q', - '--quiet', - action='store_true', - dest='quiet', - default=False, - help='Dot not show headers. Only hex / tokens', - ) - parser.add_argument( - '-r', - '--range', - metavar='', - type=int, - dest='range', - default=1, - help='Amount of consecutive OTP hex/tokens to generate. default: 1', - ) - parser.add_argument( - '-s', - '--seed', - metavar='[seed]', - type=str, - dest='seed', - default='', - help=( - 'The seed to use (1 to 16 alphanumeric characters) ' - '(default & recommended: random seed)' - ), - ) - parser.add_argument( - '-v', - '--version', - action='version', - version=f'%(prog)s {otp2289.__version__}', - help='display program-version and exit', - ) - args = parser.parse_args(args) - # handle the password before everything else - try: - args.password = get_password(args) - except KeyboardInterrupt: - eprint(os.linesep + 'Prompt terminated') - sys.exit(errno.EACCES) - except Exception as exp: - eprint(f'Unable to fetch password: {exp}') - sys.exit(1) - try: - if args.initiate_new_sequence: - print(initiate_new_sequence(args)) - if args.generate_otp_range: - print(generate_otp_range(args)) - if args.generate_otp_response: - print(generate_otp_response(args)) - sys.exit(0) - except otp2289.generator.OTPGeneratorException as exp: - eprint(f'GeneratorException: {exp}') - except otp2289.generator.OTPChallengeException as exp: - eprint(f'ChallengeException: {exp}') - except Exception as exp: - eprint(f'Unknown error: {exp}') - sys.exit(1) - - -if __name__ == '__main__': - main() diff --git a/otp2289/generator.py b/otp2289/generator.py deleted file mode 100644 index 7b86e1b..0000000 --- a/otp2289/generator.py +++ /dev/null @@ -1,760 +0,0 @@ -# -*- coding: utf-8 -*- -# SPDX-License-Identifier: BSD-2-Clause-FreeBSD -# -# Copyright (c) 2020-2022 Simeon Simeonov -# All rights reserved. -# -# Redistribution and use in source and binary forms, with or without -# modification, are permitted provided that the following conditions -# are met: -# 1. Redistributions of source code must retain the above copyright -# notice, this list of conditions and the following disclaimer. -# 2. Redistributions in binary form must reproduce the above copyright notice, -# this list of conditions and the following disclaimer in the documentation -# and/or other materials provided with the distribution. -# -# THIS SOFTWARE IS PROVIDED BY THE AUTHORS ``AS IS'' AND ANY EXPRESS OR -# IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES -# OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. -# IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, -# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT -# NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF -# THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -"""A pure Python implementation of the RFC-2289 OTP generator""" -import binascii -import hashlib -import string - -OTP_ALGO_MD5 = 1 -OTP_ALGO_SHA1 = 2 - -# the tokens are defined in https://tools.ietf.org/html/rfc2289 # -RFC1760_TOKENS = [ - 'A', 'ABE', 'ACE', 'ACT', 'AD', 'ADA', 'ADD', - 'AGO', 'AID', 'AIM', 'AIR', 'ALL', 'ALP', 'AM', 'AMY', - 'AN', 'ANA', 'AND', 'ANN', 'ANT', 'ANY', 'APE', 'APS', - 'APT', 'ARC', 'ARE', 'ARK', 'ARM', 'ART', 'AS', 'ASH', - 'ASK', 'AT', 'ATE', 'AUG', 'AUK', 'AVE', 'AWE', 'AWK', - 'AWL', 'AWN', 'AX', 'AYE', 'BAD', 'BAG', 'BAH', 'BAM', - 'BAN', 'BAR', 'BAT', 'BAY', 'BE', 'BED', 'BEE', 'BEG', - 'BEN', 'BET', 'BEY', 'BIB', 'BID', 'BIG', 'BIN', 'BIT', - 'BOB', 'BOG', 'BON', 'BOO', 'BOP', 'BOW', 'BOY', 'BUB', - 'BUD', 'BUG', 'BUM', 'BUN', 'BUS', 'BUT', 'BUY', 'BY', - 'BYE', 'CAB', 'CAL', 'CAM', 'CAN', 'CAP', 'CAR', 'CAT', - 'CAW', 'COD', 'COG', 'COL', 'CON', 'COO', 'COP', 'COT', - 'COW', 'COY', 'CRY', 'CUB', 'CUE', 'CUP', 'CUR', 'CUT', - 'DAB', 'DAD', 'DAM', 'DAN', 'DAR', 'DAY', 'DEE', 'DEL', - 'DEN', 'DES', 'DEW', 'DID', 'DIE', 'DIG', 'DIN', 'DIP', - 'DO', 'DOE', 'DOG', 'DON', 'DOT', 'DOW', 'DRY', 'DUB', - 'DUD', 'DUE', 'DUG', 'DUN', 'EAR', 'EAT', 'ED', 'EEL', - 'EGG', 'EGO', 'ELI', 'ELK', 'ELM', 'ELY', 'EM', 'END', - 'EST', 'ETC', 'EVA', 'EVE', 'EWE', 'EYE', 'FAD', 'FAN', - 'FAR', 'FAT', 'FAY', 'FED', 'FEE', 'FEW', 'FIB', 'FIG', - 'FIN', 'FIR', 'FIT', 'FLO', 'FLY', 'FOE', 'FOG', 'FOR', - 'FRY', 'FUM', 'FUN', 'FUR', 'GAB', 'GAD', 'GAG', 'GAL', - 'GAM', 'GAP', 'GAS', 'GAY', 'GEE', 'GEL', 'GEM', 'GET', - 'GIG', 'GIL', 'GIN', 'GO', 'GOT', 'GUM', 'GUN', 'GUS', - 'GUT', 'GUY', 'GYM', 'GYP', 'HA', 'HAD', 'HAL', 'HAM', - 'HAN', 'HAP', 'HAS', 'HAT', 'HAW', 'HAY', 'HE', 'HEM', - 'HEN', 'HER', 'HEW', 'HEY', 'HI', 'HID', 'HIM', 'HIP', - 'HIS', 'HIT', 'HO', 'HOB', 'HOC', 'HOE', 'HOG', 'HOP', - 'HOT', 'HOW', 'HUB', 'HUE', 'HUG', 'HUH', 'HUM', 'HUT', - 'I', 'ICY', 'IDA', 'IF', 'IKE', 'ILL', 'INK', 'INN', - 'IO', 'ION', 'IQ', 'IRA', 'IRE', 'IRK', 'IS', 'IT', - 'ITS', 'IVY', 'JAB', 'JAG', 'JAM', 'JAN', 'JAR', 'JAW', - 'JAY', 'JET', 'JIG', 'JIM', 'JO', 'JOB', 'JOE', 'JOG', - 'JOT', 'JOY', 'JUG', 'JUT', 'KAY', 'KEG', 'KEN', 'KEY', - 'KID', 'KIM', 'KIN', 'KIT', 'LA', 'LAB', 'LAC', 'LAD', - 'LAG', 'LAM', 'LAP', 'LAW', 'LAY', 'LEA', 'LED', 'LEE', - 'LEG', 'LEN', 'LEO', 'LET', 'LEW', 'LID', 'LIE', 'LIN', - 'LIP', 'LIT', 'LO', 'LOB', 'LOG', 'LOP', 'LOS', 'LOT', - 'LOU', 'LOW', 'LOY', 'LUG', 'LYE', 'MA', 'MAC', 'MAD', - 'MAE', 'MAN', 'MAO', 'MAP', 'MAT', 'MAW', 'MAY', 'ME', - 'MEG', 'MEL', 'MEN', 'MET', 'MEW', 'MID', 'MIN', 'MIT', - 'MOB', 'MOD', 'MOE', 'MOO', 'MOP', 'MOS', 'MOT', 'MOW', - 'MUD', 'MUG', 'MUM', 'MY', 'NAB', 'NAG', 'NAN', 'NAP', - 'NAT', 'NAY', 'NE', 'NED', 'NEE', 'NET', 'NEW', 'NIB', - 'NIL', 'NIP', 'NIT', 'NO', 'NOB', 'NOD', 'NON', 'NOR', - 'NOT', 'NOV', 'NOW', 'NU', 'NUN', 'NUT', 'O', 'OAF', - 'OAK', 'OAR', 'OAT', 'ODD', 'ODE', 'OF', 'OFF', 'OFT', - 'OH', 'OIL', 'OK', 'OLD', 'ON', 'ONE', 'OR', 'ORB', - 'ORE', 'ORR', 'OS', 'OTT', 'OUR', 'OUT', 'OVA', 'OW', - 'OWE', 'OWL', 'OWN', 'OX', 'PA', 'PAD', 'PAL', 'PAM', - 'PAN', 'PAP', 'PAR', 'PAT', 'PAW', 'PAY', 'PEA', 'PEG', - 'PEN', 'PEP', 'PER', 'PET', 'PEW', 'PHI', 'PI', 'PIE', - 'PIN', 'PIT', 'PLY', 'PO', 'POD', 'POE', 'POP', 'POT', - 'POW', 'PRO', 'PRY', 'PUB', 'PUG', 'PUN', 'PUP', 'PUT', - 'QUO', 'RAG', 'RAM', 'RAN', 'RAP', 'RAT', 'RAW', 'RAY', - 'REB', 'RED', 'REP', 'RET', 'RIB', 'RID', 'RIG', 'RIM', - 'RIO', 'RIP', 'ROB', 'ROD', 'ROE', 'RON', 'ROT', 'ROW', - 'ROY', 'RUB', 'RUE', 'RUG', 'RUM', 'RUN', 'RYE', 'SAC', - 'SAD', 'SAG', 'SAL', 'SAM', 'SAN', 'SAP', 'SAT', 'SAW', - 'SAY', 'SEA', 'SEC', 'SEE', 'SEN', 'SET', 'SEW', 'SHE', - 'SHY', 'SIN', 'SIP', 'SIR', 'SIS', 'SIT', 'SKI', 'SKY', - 'SLY', 'SO', 'SOB', 'SOD', 'SON', 'SOP', 'SOW', 'SOY', - 'SPA', 'SPY', 'SUB', 'SUD', 'SUE', 'SUM', 'SUN', 'SUP', - 'TAB', 'TAD', 'TAG', 'TAN', 'TAP', 'TAR', 'TEA', 'TED', - 'TEE', 'TEN', 'THE', 'THY', 'TIC', 'TIE', 'TIM', 'TIN', - 'TIP', 'TO', 'TOE', 'TOG', 'TOM', 'TON', 'TOO', 'TOP', - 'TOW', 'TOY', 'TRY', 'TUB', 'TUG', 'TUM', 'TUN', 'TWO', - 'UN', 'UP', 'US', 'USE', 'VAN', 'VAT', 'VET', 'VIE', - 'WAD', 'WAG', 'WAR', 'WAS', 'WAY', 'WE', 'WEB', 'WED', - 'WEE', 'WET', 'WHO', 'WHY', 'WIN', 'WIT', 'WOK', 'WON', - 'WOO', 'WOW', 'WRY', 'WU', 'YAM', 'YAP', 'YAW', 'YE', - 'YEA', 'YES', 'YET', 'YOU', 'ABED', 'ABEL', 'ABET', 'ABLE', - 'ABUT', 'ACHE', 'ACID', 'ACME', 'ACRE', 'ACTA', 'ACTS', 'ADAM', - 'ADDS', 'ADEN', 'AFAR', 'AFRO', 'AGEE', 'AHEM', 'AHOY', 'AIDA', - 'AIDE', 'AIDS', 'AIRY', 'AJAR', 'AKIN', 'ALAN', 'ALEC', 'ALGA', - 'ALIA', 'ALLY', 'ALMA', 'ALOE', 'ALSO', 'ALTO', 'ALUM', 'ALVA', - 'AMEN', 'AMES', 'AMID', 'AMMO', 'AMOK', 'AMOS', 'AMRA', 'ANDY', - 'ANEW', 'ANNA', 'ANNE', 'ANTE', 'ANTI', 'AQUA', 'ARAB', 'ARCH', - 'AREA', 'ARGO', 'ARID', 'ARMY', 'ARTS', 'ARTY', 'ASIA', 'ASKS', - 'ATOM', 'AUNT', 'AURA', 'AUTO', 'AVER', 'AVID', 'AVIS', 'AVON', - 'AVOW', 'AWAY', 'AWRY', 'BABE', 'BABY', 'BACH', 'BACK', 'BADE', - 'BAIL', 'BAIT', 'BAKE', 'BALD', 'BALE', 'BALI', 'BALK', 'BALL', - 'BALM', 'BAND', 'BANE', 'BANG', 'BANK', 'BARB', 'BARD', 'BARE', - 'BARK', 'BARN', 'BARR', 'BASE', 'BASH', 'BASK', 'BASS', 'BATE', - 'BATH', 'BAWD', 'BAWL', 'BEAD', 'BEAK', 'BEAM', 'BEAN', 'BEAR', - 'BEAT', 'BEAU', 'BECK', 'BEEF', 'BEEN', 'BEER', 'BEET', 'BELA', - 'BELL', 'BELT', 'BEND', 'BENT', 'BERG', 'BERN', 'BERT', 'BESS', - 'BEST', 'BETA', 'BETH', 'BHOY', 'BIAS', 'BIDE', 'BIEN', 'BILE', - 'BILK', 'BILL', 'BIND', 'BING', 'BIRD', 'BITE', 'BITS', 'BLAB', - 'BLAT', 'BLED', 'BLEW', 'BLOB', 'BLOC', 'BLOT', 'BLOW', 'BLUE', - 'BLUM', 'BLUR', 'BOAR', 'BOAT', 'BOCA', 'BOCK', 'BODE', 'BODY', - 'BOGY', 'BOHR', 'BOIL', 'BOLD', 'BOLO', 'BOLT', 'BOMB', 'BONA', - 'BOND', 'BONE', 'BONG', 'BONN', 'BONY', 'BOOK', 'BOOM', 'BOON', - 'BOOT', 'BORE', 'BORG', 'BORN', 'BOSE', 'BOSS', 'BOTH', 'BOUT', - 'BOWL', 'BOYD', 'BRAD', 'BRAE', 'BRAG', 'BRAN', 'BRAY', 'BRED', - 'BREW', 'BRIG', 'BRIM', 'BROW', 'BUCK', 'BUDD', 'BUFF', 'BULB', - 'BULK', 'BULL', 'BUNK', 'BUNT', 'BUOY', 'BURG', 'BURL', 'BURN', - 'BURR', 'BURT', 'BURY', 'BUSH', 'BUSS', 'BUST', 'BUSY', 'BYTE', - 'CADY', 'CAFE', 'CAGE', 'CAIN', 'CAKE', 'CALF', 'CALL', 'CALM', - 'CAME', 'CANE', 'CANT', 'CARD', 'CARE', 'CARL', 'CARR', 'CART', - 'CASE', 'CASH', 'CASK', 'CAST', 'CAVE', 'CEIL', 'CELL', 'CENT', - 'CERN', 'CHAD', 'CHAR', 'CHAT', 'CHAW', 'CHEF', 'CHEN', 'CHEW', - 'CHIC', 'CHIN', 'CHOU', 'CHOW', 'CHUB', 'CHUG', 'CHUM', 'CITE', - 'CITY', 'CLAD', 'CLAM', 'CLAN', 'CLAW', 'CLAY', 'CLOD', 'CLOG', - 'CLOT', 'CLUB', 'CLUE', 'COAL', 'COAT', 'COCA', 'COCK', 'COCO', - 'CODA', 'CODE', 'CODY', 'COED', 'COIL', 'COIN', 'COKE', 'COLA', - 'COLD', 'COLT', 'COMA', 'COMB', 'COME', 'COOK', 'COOL', 'COON', - 'COOT', 'CORD', 'CORE', 'CORK', 'CORN', 'COST', 'COVE', 'COWL', - 'CRAB', 'CRAG', 'CRAM', 'CRAY', 'CREW', 'CRIB', 'CROW', 'CRUD', - 'CUBA', 'CUBE', 'CUFF', 'CULL', 'CULT', 'CUNY', 'CURB', 'CURD', - 'CURE', 'CURL', 'CURT', 'CUTS', 'DADE', 'DALE', 'DAME', 'DANA', - 'DANE', 'DANG', 'DANK', 'DARE', 'DARK', 'DARN', 'DART', 'DASH', - 'DATA', 'DATE', 'DAVE', 'DAVY', 'DAWN', 'DAYS', 'DEAD', 'DEAF', - 'DEAL', 'DEAN', 'DEAR', 'DEBT', 'DECK', 'DEED', 'DEEM', 'DEER', - 'DEFT', 'DEFY', 'DELL', 'DENT', 'DENY', 'DESK', 'DIAL', 'DICE', - 'DIED', 'DIET', 'DIME', 'DINE', 'DING', 'DINT', 'DIRE', 'DIRT', - 'DISC', 'DISH', 'DISK', 'DIVE', 'DOCK', 'DOES', 'DOLE', 'DOLL', - 'DOLT', 'DOME', 'DONE', 'DOOM', 'DOOR', 'DORA', 'DOSE', 'DOTE', - 'DOUG', 'DOUR', 'DOVE', 'DOWN', 'DRAB', 'DRAG', 'DRAM', 'DRAW', - 'DREW', 'DRUB', 'DRUG', 'DRUM', 'DUAL', 'DUCK', 'DUCT', 'DUEL', - 'DUET', 'DUKE', 'DULL', 'DUMB', 'DUNE', 'DUNK', 'DUSK', 'DUST', - 'DUTY', 'EACH', 'EARL', 'EARN', 'EASE', 'EAST', 'EASY', 'EBEN', - 'ECHO', 'EDDY', 'EDEN', 'EDGE', 'EDGY', 'EDIT', 'EDNA', 'EGAN', - 'ELAN', 'ELBA', 'ELLA', 'ELSE', 'EMIL', 'EMIT', 'EMMA', 'ENDS', - 'ERIC', 'EROS', 'EVEN', 'EVER', 'EVIL', 'EYED', 'FACE', 'FACT', - 'FADE', 'FAIL', 'FAIN', 'FAIR', 'FAKE', 'FALL', 'FAME', 'FANG', - 'FARM', 'FAST', 'FATE', 'FAWN', 'FEAR', 'FEAT', 'FEED', 'FEEL', - 'FEET', 'FELL', 'FELT', 'FEND', 'FERN', 'FEST', 'FEUD', 'FIEF', - 'FIGS', 'FILE', 'FILL', 'FILM', 'FIND', 'FINE', 'FINK', 'FIRE', - 'FIRM', 'FISH', 'FISK', 'FIST', 'FITS', 'FIVE', 'FLAG', 'FLAK', - 'FLAM', 'FLAT', 'FLAW', 'FLEA', 'FLED', 'FLEW', 'FLIT', 'FLOC', - 'FLOG', 'FLOW', 'FLUB', 'FLUE', 'FOAL', 'FOAM', 'FOGY', 'FOIL', - 'FOLD', 'FOLK', 'FOND', 'FONT', 'FOOD', 'FOOL', 'FOOT', 'FORD', - 'FORE', 'FORK', 'FORM', 'FORT', 'FOSS', 'FOUL', 'FOUR', 'FOWL', - 'FRAU', 'FRAY', 'FRED', 'FREE', 'FRET', 'FREY', 'FROG', 'FROM', - 'FUEL', 'FULL', 'FUME', 'FUND', 'FUNK', 'FURY', 'FUSE', 'FUSS', - 'GAFF', 'GAGE', 'GAIL', 'GAIN', 'GAIT', 'GALA', 'GALE', 'GALL', - 'GALT', 'GAME', 'GANG', 'GARB', 'GARY', 'GASH', 'GATE', 'GAUL', - 'GAUR', 'GAVE', 'GAWK', 'GEAR', 'GELD', 'GENE', 'GENT', 'GERM', - 'GETS', 'GIBE', 'GIFT', 'GILD', 'GILL', 'GILT', 'GINA', 'GIRD', - 'GIRL', 'GIST', 'GIVE', 'GLAD', 'GLEE', 'GLEN', 'GLIB', 'GLOB', - 'GLOM', 'GLOW', 'GLUE', 'GLUM', 'GLUT', 'GOAD', 'GOAL', 'GOAT', - 'GOER', 'GOES', 'GOLD', 'GOLF', 'GONE', 'GONG', 'GOOD', 'GOOF', - 'GORE', 'GORY', 'GOSH', 'GOUT', 'GOWN', 'GRAB', 'GRAD', 'GRAY', - 'GREG', 'GREW', 'GREY', 'GRID', 'GRIM', 'GRIN', 'GRIT', 'GROW', - 'GRUB', 'GULF', 'GULL', 'GUNK', 'GURU', 'GUSH', 'GUST', 'GWEN', - 'GWYN', 'HAAG', 'HAAS', 'HACK', 'HAIL', 'HAIR', 'HALE', 'HALF', - 'HALL', 'HALO', 'HALT', 'HAND', 'HANG', 'HANK', 'HANS', 'HARD', - 'HARK', 'HARM', 'HART', 'HASH', 'HAST', 'HATE', 'HATH', 'HAUL', - 'HAVE', 'HAWK', 'HAYS', 'HEAD', 'HEAL', 'HEAR', 'HEAT', 'HEBE', - 'HECK', 'HEED', 'HEEL', 'HEFT', 'HELD', 'HELL', 'HELM', 'HERB', - 'HERD', 'HERE', 'HERO', 'HERS', 'HESS', 'HEWN', 'HICK', 'HIDE', - 'HIGH', 'HIKE', 'HILL', 'HILT', 'HIND', 'HINT', 'HIRE', 'HISS', - 'HIVE', 'HOBO', 'HOCK', 'HOFF', 'HOLD', 'HOLE', 'HOLM', 'HOLT', - 'HOME', 'HONE', 'HONK', 'HOOD', 'HOOF', 'HOOK', 'HOOT', 'HORN', - 'HOSE', 'HOST', 'HOUR', 'HOVE', 'HOWE', 'HOWL', 'HOYT', 'HUCK', - 'HUED', 'HUFF', 'HUGE', 'HUGH', 'HUGO', 'HULK', 'HULL', 'HUNK', - 'HUNT', 'HURD', 'HURL', 'HURT', 'HUSH', 'HYDE', 'HYMN', 'IBIS', - 'ICON', 'IDEA', 'IDLE', 'IFFY', 'INCA', 'INCH', 'INTO', 'IONS', - 'IOTA', 'IOWA', 'IRIS', 'IRMA', 'IRON', 'ISLE', 'ITCH', 'ITEM', - 'IVAN', 'JACK', 'JADE', 'JAIL', 'JAKE', 'JANE', 'JAVA', 'JEAN', - 'JEFF', 'JERK', 'JESS', 'JEST', 'JIBE', 'JILL', 'JILT', 'JIVE', - 'JOAN', 'JOBS', 'JOCK', 'JOEL', 'JOEY', 'JOHN', 'JOIN', 'JOKE', - 'JOLT', 'JOVE', 'JUDD', 'JUDE', 'JUDO', 'JUDY', 'JUJU', 'JUKE', - 'JULY', 'JUNE', 'JUNK', 'JUNO', 'JURY', 'JUST', 'JUTE', 'KAHN', - 'KALE', 'KANE', 'KANT', 'KARL', 'KATE', 'KEEL', 'KEEN', 'KENO', - 'KENT', 'KERN', 'KERR', 'KEYS', 'KICK', 'KILL', 'KIND', 'KING', - 'KIRK', 'KISS', 'KITE', 'KLAN', 'KNEE', 'KNEW', 'KNIT', 'KNOB', - 'KNOT', 'KNOW', 'KOCH', 'KONG', 'KUDO', 'KURD', 'KURT', 'KYLE', - 'LACE', 'LACK', 'LACY', 'LADY', 'LAID', 'LAIN', 'LAIR', 'LAKE', - 'LAMB', 'LAME', 'LAND', 'LANE', 'LANG', 'LARD', 'LARK', 'LASS', - 'LAST', 'LATE', 'LAUD', 'LAVA', 'LAWN', 'LAWS', 'LAYS', 'LEAD', - 'LEAF', 'LEAK', 'LEAN', 'LEAR', 'LEEK', 'LEER', 'LEFT', 'LEND', - 'LENS', 'LENT', 'LEON', 'LESK', 'LESS', 'LEST', 'LETS', 'LIAR', - 'LICE', 'LICK', 'LIED', 'LIEN', 'LIES', 'LIEU', 'LIFE', 'LIFT', - 'LIKE', 'LILA', 'LILT', 'LILY', 'LIMA', 'LIMB', 'LIME', 'LIND', - 'LINE', 'LINK', 'LINT', 'LION', 'LISA', 'LIST', 'LIVE', 'LOAD', - 'LOAF', 'LOAM', 'LOAN', 'LOCK', 'LOFT', 'LOGE', 'LOIS', 'LOLA', - 'LONE', 'LONG', 'LOOK', 'LOON', 'LOOT', 'LORD', 'LORE', 'LOSE', - 'LOSS', 'LOST', 'LOUD', 'LOVE', 'LOWE', 'LUCK', 'LUCY', 'LUGE', - 'LUKE', 'LULU', 'LUND', 'LUNG', 'LURA', 'LURE', 'LURK', 'LUSH', - 'LUST', 'LYLE', 'LYNN', 'LYON', 'LYRA', 'MACE', 'MADE', 'MAGI', - 'MAID', 'MAIL', 'MAIN', 'MAKE', 'MALE', 'MALI', 'MALL', 'MALT', - 'MANA', 'MANN', 'MANY', 'MARC', 'MARE', 'MARK', 'MARS', 'MART', - 'MARY', 'MASH', 'MASK', 'MASS', 'MAST', 'MATE', 'MATH', 'MAUL', - 'MAYO', 'MEAD', 'MEAL', 'MEAN', 'MEAT', 'MEEK', 'MEET', 'MELD', - 'MELT', 'MEMO', 'MEND', 'MENU', 'MERT', 'MESH', 'MESS', 'MICE', - 'MIKE', 'MILD', 'MILE', 'MILK', 'MILL', 'MILT', 'MIMI', 'MIND', - 'MINE', 'MINI', 'MINK', 'MINT', 'MIRE', 'MISS', 'MIST', 'MITE', - 'MITT', 'MOAN', 'MOAT', 'MOCK', 'MODE', 'MOLD', 'MOLE', 'MOLL', - 'MOLT', 'MONA', 'MONK', 'MONT', 'MOOD', 'MOON', 'MOOR', 'MOOT', - 'MORE', 'MORN', 'MORT', 'MOSS', 'MOST', 'MOTH', 'MOVE', 'MUCH', - 'MUCK', 'MUDD', 'MUFF', 'MULE', 'MULL', 'MURK', 'MUSH', 'MUST', - 'MUTE', 'MUTT', 'MYRA', 'MYTH', 'NAGY', 'NAIL', 'NAIR', 'NAME', - 'NARY', 'NASH', 'NAVE', 'NAVY', 'NEAL', 'NEAR', 'NEAT', 'NECK', - 'NEED', 'NEIL', 'NELL', 'NEON', 'NERO', 'NESS', 'NEST', 'NEWS', - 'NEWT', 'NIBS', 'NICE', 'NICK', 'NILE', 'NINA', 'NINE', 'NOAH', - 'NODE', 'NOEL', 'NOLL', 'NONE', 'NOOK', 'NOON', 'NORM', 'NOSE', - 'NOTE', 'NOUN', 'NOVA', 'NUDE', 'NULL', 'NUMB', 'OATH', 'OBEY', - 'OBOE', 'ODIN', 'OHIO', 'OILY', 'OINT', 'OKAY', 'OLAF', 'OLDY', - 'OLGA', 'OLIN', 'OMAN', 'OMEN', 'OMIT', 'ONCE', 'ONES', 'ONLY', - 'ONTO', 'ONUS', 'ORAL', 'ORGY', 'OSLO', 'OTIS', 'OTTO', 'OUCH', - 'OUST', 'OUTS', 'OVAL', 'OVEN', 'OVER', 'OWLY', 'OWNS', 'QUAD', - 'QUIT', 'QUOD', 'RACE', 'RACK', 'RACY', 'RAFT', 'RAGE', 'RAID', - 'RAIL', 'RAIN', 'RAKE', 'RANK', 'RANT', 'RARE', 'RASH', 'RATE', - 'RAVE', 'RAYS', 'READ', 'REAL', 'REAM', 'REAR', 'RECK', 'REED', - 'REEF', 'REEK', 'REEL', 'REID', 'REIN', 'RENA', 'REND', 'RENT', - 'REST', 'RICE', 'RICH', 'RICK', 'RIDE', 'RIFT', 'RILL', 'RIME', - 'RING', 'RINK', 'RISE', 'RISK', 'RITE', 'ROAD', 'ROAM', 'ROAR', - 'ROBE', 'ROCK', 'RODE', 'ROIL', 'ROLL', 'ROME', 'ROOD', 'ROOF', - 'ROOK', 'ROOM', 'ROOT', 'ROSA', 'ROSE', 'ROSS', 'ROSY', 'ROTH', - 'ROUT', 'ROVE', 'ROWE', 'ROWS', 'RUBE', 'RUBY', 'RUDE', 'RUDY', - 'RUIN', 'RULE', 'RUNG', 'RUNS', 'RUNT', 'RUSE', 'RUSH', 'RUSK', - 'RUSS', 'RUST', 'RUTH', 'SACK', 'SAFE', 'SAGE', 'SAID', 'SAIL', - 'SALE', 'SALK', 'SALT', 'SAME', 'SAND', 'SANE', 'SANG', 'SANK', - 'SARA', 'SAUL', 'SAVE', 'SAYS', 'SCAN', 'SCAR', 'SCAT', 'SCOT', - 'SEAL', 'SEAM', 'SEAR', 'SEAT', 'SEED', 'SEEK', 'SEEM', 'SEEN', - 'SEES', 'SELF', 'SELL', 'SEND', 'SENT', 'SETS', 'SEWN', 'SHAG', - 'SHAM', 'SHAW', 'SHAY', 'SHED', 'SHIM', 'SHIN', 'SHOD', 'SHOE', - 'SHOT', 'SHOW', 'SHUN', 'SHUT', 'SICK', 'SIDE', 'SIFT', 'SIGH', - 'SIGN', 'SILK', 'SILL', 'SILO', 'SILT', 'SINE', 'SING', 'SINK', - 'SIRE', 'SITE', 'SITS', 'SITU', 'SKAT', 'SKEW', 'SKID', 'SKIM', - 'SKIN', 'SKIT', 'SLAB', 'SLAM', 'SLAT', 'SLAY', 'SLED', 'SLEW', - 'SLID', 'SLIM', 'SLIT', 'SLOB', 'SLOG', 'SLOT', 'SLOW', 'SLUG', - 'SLUM', 'SLUR', 'SMOG', 'SMUG', 'SNAG', 'SNOB', 'SNOW', 'SNUB', - 'SNUG', 'SOAK', 'SOAR', 'SOCK', 'SODA', 'SOFA', 'SOFT', 'SOIL', - 'SOLD', 'SOME', 'SONG', 'SOON', 'SOOT', 'SORE', 'SORT', 'SOUL', - 'SOUR', 'SOWN', 'STAB', 'STAG', 'STAN', 'STAR', 'STAY', 'STEM', - 'STEW', 'STIR', 'STOW', 'STUB', 'STUN', 'SUCH', 'SUDS', 'SUIT', - 'SULK', 'SUMS', 'SUNG', 'SUNK', 'SURE', 'SURF', 'SWAB', 'SWAG', - 'SWAM', 'SWAN', 'SWAT', 'SWAY', 'SWIM', 'SWUM', 'TACK', 'TACT', - 'TAIL', 'TAKE', 'TALE', 'TALK', 'TALL', 'TANK', 'TASK', 'TATE', - 'TAUT', 'TEAL', 'TEAM', 'TEAR', 'TECH', 'TEEM', 'TEEN', 'TEET', - 'TELL', 'TEND', 'TENT', 'TERM', 'TERN', 'TESS', 'TEST', 'THAN', - 'THAT', 'THEE', 'THEM', 'THEN', 'THEY', 'THIN', 'THIS', 'THUD', - 'THUG', 'TICK', 'TIDE', 'TIDY', 'TIED', 'TIER', 'TILE', 'TILL', - 'TILT', 'TIME', 'TINA', 'TINE', 'TINT', 'TINY', 'TIRE', 'TOAD', - 'TOGO', 'TOIL', 'TOLD', 'TOLL', 'TONE', 'TONG', 'TONY', 'TOOK', - 'TOOL', 'TOOT', 'TORE', 'TORN', 'TOTE', 'TOUR', 'TOUT', 'TOWN', - 'TRAG', 'TRAM', 'TRAY', 'TREE', 'TREK', 'TRIG', 'TRIM', 'TRIO', - 'TROD', 'TROT', 'TROY', 'TRUE', 'TUBA', 'TUBE', 'TUCK', 'TUFT', - 'TUNA', 'TUNE', 'TUNG', 'TURF', 'TURN', 'TUSK', 'TWIG', 'TWIN', - 'TWIT', 'ULAN', 'UNIT', 'URGE', 'USED', 'USER', 'USES', 'UTAH', - 'VAIL', 'VAIN', 'VALE', 'VARY', 'VASE', 'VAST', 'VEAL', 'VEDA', - 'VEIL', 'VEIN', 'VEND', 'VENT', 'VERB', 'VERY', 'VETO', 'VICE', - 'VIEW', 'VINE', 'VISE', 'VOID', 'VOLT', 'VOTE', 'WACK', 'WADE', - 'WAGE', 'WAIL', 'WAIT', 'WAKE', 'WALE', 'WALK', 'WALL', 'WALT', - 'WAND', 'WANE', 'WANG', 'WANT', 'WARD', 'WARM', 'WARN', 'WART', - 'WASH', 'WAST', 'WATS', 'WATT', 'WAVE', 'WAVY', 'WAYS', 'WEAK', - 'WEAL', 'WEAN', 'WEAR', 'WEED', 'WEEK', 'WEIR', 'WELD', 'WELL', - 'WELT', 'WENT', 'WERE', 'WERT', 'WEST', 'WHAM', 'WHAT', 'WHEE', - 'WHEN', 'WHET', 'WHOA', 'WHOM', 'WICK', 'WIFE', 'WILD', 'WILL', - 'WIND', 'WINE', 'WING', 'WINK', 'WINO', 'WIRE', 'WISE', 'WISH', - 'WITH', 'WOLF', 'WONT', 'WOOD', 'WOOL', 'WORD', 'WORE', 'WORK', - 'WORM', 'WORN', 'WOVE', 'WRIT', 'WYNN', 'YALE', 'YANG', 'YANK', - 'YARD', 'YARN', 'YAWL', 'YAWN', 'YEAH', 'YEAR', 'YELL', 'YOGA', - 'YOKE'] - -_ALGO_DICT = {OTP_ALGO_MD5: 'md5', OTP_ALGO_SHA1: 'sha1'} - - -class OTPGeneratorException(Exception): - """OTPGeneratorException class""" - - -class OTPChallengeException(Exception): - """OTPChallengeException class""" - - -class OTPGenerator: - """OTPGenerator class""" - - def __init__( - self, - password: bytes, - seed: str = '', - hash_algo=OTP_ALGO_MD5, - ): - """ - Constructs an OTPGenerator object with a given password and seed. - - :param password: The password string - :type password: bytes - - :param seed: The seed received from the challenge, defaults to '' - :type seed: str - - :param hash_algo: The hash algo, defaults to OTP_ALGO_MD5 - :type hash_algo: int or str - - :raises otp2289.OTPGeneratorException: If the input does not validate - """ - # enforce the rfc2289 constraints - self._seed = seed - if self._seed: # the seed was set here. Validate it - self._seed = self.validate_seed(self._seed) - self._hash_algo = self.validate_hash_algo(hash_algo) - if not isinstance(password, bytes): - raise OTPGeneratorException('Password must be a byte-string') - if len(password) < 10: - raise OTPGeneratorException( - 'Password must be longer than 10 bytes' - ) - self._password = password - - def __repr__(self): - """repr implementation""" - return ( - f'{self.__class__} at {id(self)} (seed={self._seed}, ' - f'hash_algo={self._hash_algo})' - ) - - @staticmethod - def bit_pair_sum(bit_stream: str) -> int: - """ - Split bit_stream in bit-pairs and sum them all together. - - :param bit_stream: The bit-stream object - :type bit_stream: str - - :return: The sum of all bit-pairs in bit_stream - :rtype: int - """ - if not isinstance(bit_stream, str): - raise OTPGeneratorException('bit_stream must be of type str') - if len(bit_stream) != 64: - raise OTPGeneratorException('bit_stream must be of size 64') - value = 0 - for pair in zip(bit_stream[::2], bit_stream[1::2]): - value += int(''.join(pair), 2) - return value - - @staticmethod - def bytes_to_tokens(hash_bytes: bytes) -> str: - """ - Returns a 6 words token from bytes as specified by RFC-2289. - - :param hash_bytes: The input bytes - :type hash_bytes: bytes - - :return: 6 words tokens - :rtype: str - """ - bit_stream = ''.join([f'{byte:0>8b}' for byte in hash_bytes]) - bit_pair_sum = OTPGenerator.bit_pair_sum(bit_stream) - tokens = [] - tokens.append(RFC1760_TOKENS[int(bit_stream[:11], 2)]) - tokens.append(RFC1760_TOKENS[int(bit_stream[11:22], 2)]) - tokens.append(RFC1760_TOKENS[int(bit_stream[22:33], 2)]) - tokens.append(RFC1760_TOKENS[int(bit_stream[33:44], 2)]) - tokens.append(RFC1760_TOKENS[int(bit_stream[44:55], 2)]) - tokens.append( - RFC1760_TOKENS[ - int( - bit_stream[55:64] + f'{bit_pair_sum:0>8b}'[-2:], - 2, - ) - ] - ) - return ' '.join(tokens) - - @staticmethod - def get_tokens_from_challenge(challenge: str) -> tuple: - """ - Returns tokens (seed, hash_algo and step) from a challenge string. - - N.B. The tokens are not validated here. - - :param challenge: The challenge string described in RFC-2289 - :type challenge: str - - :raises otp2289.OTPChallengeException: If the challenge is invalid - - :return: (seed, hash_algo, step) tuple. - :rtype: tuple - """ - if not isinstance(challenge, str): - raise OTPChallengeException('Challenge must be str') - challenge = challenge.strip() - if not challenge.startswith('otp-'): - raise OTPChallengeException('Invalid challenge') - try: - hash_algo, step, seed = challenge[4:].split() - return (seed, hash_algo, int(step)) - except ValueError: - raise OTPChallengeException('Invalid challenge') from None - - @staticmethod - def sha1_digest_folding(sha1_digest: bytes) -> bytes: - """ - Implementation of the 160bit -> 64bit folding algorithm - for sha1 digest. - - :param sha1_digest: The SHA1 digest - :type sha1_digest: bytes - - :return: The byte-string representing the folded sha1-digest - :rtype: bytes - """ - if not (isinstance(sha1_digest, bytes)): - raise OTPGeneratorException('sha1_digest must be of type bytes') - if len(sha1_digest) != 20: - raise OTPGeneratorException( - 'sha1_digest must be 160 bits (20 bytes) long' - ) - digested = list(5 * b'i') # 5 bytes (40 bits) - result = list(8 * b'x') # 8 bytes (64 bits) - for i in range(5): - digested[i] = ( - ((sha1_digest[i * 4 + 0] & 0xFF) << 24) - | ((sha1_digest[i * 4 + 1] & 0xFF) << 16) - | ((sha1_digest[i * 4 + 2] & 0xFF) << 8) - | (sha1_digest[i * 4 + 3] & 0xFF) - ) - # sha.digest[0] ^= sha.digest[2]; - # sha.digest[1] ^= sha.digest[3]; - # sha.digest[0] ^= sha.digest[4]; - digested[0] ^= digested[2] - digested[1] ^= digested[3] - digested[0] ^= digested[4] - # for (i = 0, j = 0; j < 8; i++, j += 4) { - # result[j] = (unsigned char)(sha.digest[i] & 0xff); - # result[j+1] = (unsigned char)((sha.digest[i] >> 8) & 0xff); - # result[j+2] = (unsigned char)((sha.digest[i] >> 16) & 0xff); - # result[j+3] = (unsigned char)((sha.digest[i] >> 24) & 0xff); - # } - # just hardcoding the two iterations for better efficiency - result[0] = digested[0] & 0xFF - result[1] = (digested[0] >> 8) & 0xFF - result[2] = (digested[0] >> 16) & 0xFF - result[3] = (digested[0] >> 24) & 0xFF - result[4] = digested[1] & 0xFF - result[5] = (digested[1] >> 8) & 0xFF - result[6] = (digested[1] >> 16) & 0xFF - result[7] = (digested[1] >> 24) & 0xFF - return bytes(result) - - @staticmethod - def strxor(byte_str1: bytes, byte_str2: bytes) -> bytes: - """ - Implementation of strxor similar to the one provided by pycrypto. - - :param byte_str1: Byte-string 1 - :type byte_str1: bytes - - :param byte_str2: Byte-string 2 - :type byte_str2: bytes - - :return: The byte-string representing the result of byte_str1^byte_str2 - :rtype: bytes - """ - if not (isinstance(byte_str1, bytes) and isinstance(byte_str2, bytes)): - raise OTPGeneratorException( - 'byte_str1 and byte_str2 must be of type bytes' - ) - length = len(byte_str1) - if length != len(byte_str2) or length < 1: - raise OTPGeneratorException( - 'byte_str1 and byte_str2 must be of the same length > 0' - ) - return bytes([byte_str1[i] ^ byte_str2[i] for i in range(length)]) - - @staticmethod - def tokens_to_bytes(tokens_str: str) -> bytes: - """ - Returns bytes from a 6 words token as specified by RFC-2289. - - :param tokens_str: String representing 6 words tokens - :type tokens_str: str - - :raises otp2289.OTPGeneratorException: When the tokens_str is invalid - - :return: 6 words tokens - :rtype: bytes - """ - if not isinstance(tokens_str, str): - raise OTPGeneratorException('tokens must be a str') - tokens = tokens_str.split() - if len(tokens) != 6: - raise OTPGeneratorException( - 'Tokens-string does not contain 6 tokens' - ) - token_ints = [] - try: - token_ints = [ - RFC1760_TOKENS.index(token.upper()) for token in tokens - ] - except ValueError: - raise OTPGeneratorException( - 'One or more words not present in RFC1760' - ) from None - # now we build a string of bits - bit_stream = format(token_ints[0], '011b') - bit_stream += format(token_ints[1], '011b') - bit_stream += format(token_ints[2], '011b') - bit_stream += format(token_ints[3], '011b') - bit_stream += format(token_ints[4], '011b') - bit_stream += format(token_ints[5], '011b') - # we have 66 bits: 64 digest + 2 bit pair sum (control number) - # RFC-2289: All OTP generators MUST calculate this checksum and all - # OTP servers MUST verify this checksum explicitly as part of the - # operation of decoding this representation of the one-time password. - if ( - f'{OTPGenerator.bit_pair_sum(bit_stream[:64]):0>8b}'[-2:] - != bit_stream[-2:] - ): - raise OTPGeneratorException('Invalid bit checksum') - return int(bit_stream[:64], 2).to_bytes(8, 'big') - - @staticmethod - def validate_hash_algo(hash_algo) -> str: - """ - Validates the provided hash-algorithm. - - :param hash_algo: The hash algo, defaults to OTP_ALGO_MD5 - :type hash_algo: int or str - - :raises otp2289.OTPGeneratorException: If hash_algo does not validate - - :return: The validated hash_algo in str-form - :rtype: str - """ - if isinstance(hash_algo, int): - if hash_algo not in _ALGO_DICT: - raise OTPGeneratorException( - 'hash_algo is not among the known algorithms' - ) - hash_algo = _ALGO_DICT.get(hash_algo) - if not isinstance(hash_algo, str): - raise OTPGeneratorException('hash_algo must be an int or a str') - if hash_algo not in hashlib.algorithms_available: - raise OTPGeneratorException( - f'{hash_algo} is not supported by this version of the ' - 'hashlib module' - ) - return hash_algo - - @staticmethod - def validate_seed(seed: str) -> str: - """ - Validates the provided seed as defined by RFC-2289. - - :param seed: The seed received from the challenge, defaults to '' - :type seed: str - - :raises otp2289.OTPGeneratorException: If seed does not validate - - :return: The validated (and very same) seed - :rtype: str - """ - if not isinstance(seed, str): - raise OTPGeneratorException('Seed must be a string') - if not seed or len(seed) > 16: - raise OTPGeneratorException( - 'The seed MUST be of 1 to 16 characters in length' - ) - for char in seed: - if char not in string.ascii_letters + string.digits: - raise OTPGeneratorException( - 'The seed MUST consist of purely alphanumeric characters' - ) - return seed - - @staticmethod - def validate_step(step: int) -> int: - """ - Validates the provided step as defined by RFC-2289. - - :param seed: The step received from the challenge - :type seed: int - - :raises otp2289.OTPGeneratorException: If step does not validate - - :return: The validated (and very same) step - :rtype: int - """ - if not isinstance(step, int): - raise OTPGeneratorException('Step value MUST be an int') - if step < 0: - raise OTPGeneratorException('Step value MUST be >= 0') - return step - - def generate_otp_hexdigest(self, step: int) -> str: - """ - Generates the OTP hexdigest for the given step. - - :param step: The step to generate OTP for - :type step: int - - :return: Hexdigest for the given step - :rtype: str - """ - return '0x' + binascii.hexlify(self._generate_otp_bytes(step)).decode() - - def generate_otp_hexdigest_from_challenge(self, challenge: str) -> str: - """ - Same as generate_otp_hexdigest, but it generates hex. from a challenge. - - RFC-2289 states: - The challenge MUST be in a standard syntax so - that automated generators can recognize the challenge in context and - extract these parameters. The syntax of the challenge is: - otp- - - :param challenge: The challenge string - :type challenge: str - - :return: Hexdigest for the given challenge - :rtype: str - """ - seed, hash_algo, step = self.get_tokens_from_challenge(challenge) - self._seed = self.validate_seed(seed) - self._hash_algo = self.validate_hash_algo(hash_algo) - return self.generate_otp_hexdigest(step) - - def generate_otp_words(self, step: int) -> str: - """ - Generates the OTP six words token for the given step. - - :param step: The step to generate OTP for - :type step: int - - :return: Six words (separated by single space) token for the given step - :rtype: str - """ - return self.bytes_to_tokens(self._generate_otp_bytes(step)) - - def generate_otp_words_from_challenge(self, challenge: str) -> str: - """ - Same as generate_otp_words, but it generates words from a challenge. - - RFC-2289 states: - The challenge MUST be in a standard syntax so - that automated generators can recognize the challenge in context and - extract these parameters. The syntax of the challenge is: - otp- - - :param challenge: The challenge string - :type challenge: str - - :return: Six words token for the given challenge - :rtype: str - """ - seed, hash_algo, step = self.get_tokens_from_challenge(challenge) - self._seed = self.validate_seed(seed) - self._hash_algo = self.validate_hash_algo(hash_algo) - return self.generate_otp_words(step) - - def hexdigest_range(self, start: int = 499, stop: int = 0): - """ - Returns an iterator that providing hexdigests corresponding to steps - from `start` to and including `stop`. - - :param start: The start of the range (default: 499) - :type start: int - - :param stop: The last step (default: 0) - :type stop: int - - :return: Iterator - :rtype: generator - """ - if not isinstance(start, int) and isinstance(stop, int): - raise OTPGeneratorException('Step value MUST be an int') - if start < stop: - raise OTPGeneratorException( - 'Start value can not be lower than stop' - ) - for step in range(start, stop - 1, -1): - yield self.generate_otp_hexdigest(step) - - def words_range(self, start: int = 499, stop: int = 0): - """ - Returns an iterator that providing the words corresponding to steps - from `start` to and including `stop`. - - :param start: The start of the range (default: 499) - :type start: int - - :param stop: The last step (default: 0) - :type stop: int - - :return: Iterator - :rtype: generator - """ - if not isinstance(start, int) and isinstance(stop, int): - raise OTPGeneratorException('Step value MUST be an int') - if start < stop: - raise OTPGeneratorException( - 'Start value can not be lower than stop' - ) - for step in range(start, stop - 1, -1): - yield self.generate_otp_words(step) - - def _generate_otp_bytes(self, step: int) -> bytes: - """ - Generates the OTP bytes for the given step. - - :param step: The step to generate OTP for - :type step: int - - :return: The digest bytes for the given step - :rtype: bytes - """ - step = self.validate_step(step) - digest = b'' - for _ in range(step + 1): - hash_obj = hashlib.new(self._hash_algo) - if not digest: - # 0 step - hash_obj.update(self._seed.lower().encode() + self._password) - else: - hash_obj.update(digest) - large_digest = hash_obj.digest() - if self._hash_algo == 'md5': - # md4 and md5 128bit -> 64bit folding - digest = self.strxor(large_digest[0:8], large_digest[8:]) - elif self._hash_algo == 'sha1': - # sha1 160bit -> 64bit folding - digest = self.sha1_digest_folding(large_digest) - else: - raise OTPGeneratorException( - f'{self._hash_algo} is not supported by this module' - ) - return digest diff --git a/otp2289/server.py b/otp2289/server.py deleted file mode 100644 index 7f742cd..0000000 --- a/otp2289/server.py +++ /dev/null @@ -1,471 +0,0 @@ -# -*- coding: utf-8 -*- -# SPDX-License-Identifier: BSD-2-Clause-FreeBSD -# -# Copyright (c) 2020-2022 Simeon Simeonov -# All rights reserved. -# -# Redistribution and use in source and binary forms, with or without -# modification, are permitted provided that the following conditions -# are met: -# 1. Redistributions of source code must retain the above copyright -# notice, this list of conditions and the following disclaimer. -# 2. Redistributions in binary form must reproduce the above copyright notice, -# this list of conditions and the following disclaimer in the documentation -# and/or other materials provided with the distribution. -# -# THIS SOFTWARE IS PROVIDED BY THE AUTHORS ``AS IS'' AND ANY EXPRESS OR -# IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES -# OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. -# IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, -# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT -# NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF -# THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -"""A pure Python implementation of the RFC-2289 OTP server""" -import binascii -import hashlib - -from .generator import OTP_ALGO_MD5, OTPGenerator, OTPGeneratorException - - -class OTPStateException(Exception): - """OTPStateException class""" - - -class OTPStoreException(Exception): - """OTPStoreException class""" - - -class OTPInvalidResponse(Exception): - """OTPInvalidResponse class""" - - -class OTPState: - """ - OTPState class - - The OTPState class represents a single state on the server side that can: - - generate a challenge - - validate the corresponding generated response from the generator - """ - - def __init__( - self, - ot_hex: str, - current_step: int, - seed: str, - hash_algo=OTP_ALGO_MD5, - ): - """ - Constructs an OTPState object with the given arguments. - - Keyword Arguments: - :param ot_hex: The one-time hex from the last successful authentication - or None for a newly initialized sequence. - :type ot_hex: str or None - - :param current_step: The current step that is sent with the challenge - :type current_step: int - - :param seed: The seed that is sent with the challenge - :type seed: str - - :param hash_algo: The hash algo, defaults to OTP_ALGO_MD5 - :type hash_algo: int or str - - :raises otp2289.OTPStateException: If the input does not validate - """ - # enforce the rfc2289 constraints - try: - self._seed = OTPGenerator.validate_seed(seed) - self._hash_algo = OTPGenerator.validate_hash_algo(hash_algo) - self._step = OTPGenerator.validate_step(current_step) - except OTPGeneratorException as exp: - raise OTPStateException(exp.args[0]) from None - self._current_digest = None - if ot_hex is not None: - self._current_digest = self.validate_hex(ot_hex) - self._new_digest_hex = None # set upon a successful validation - - def __repr__(self): - """repr implementation""" - return ( - f'{self.__class__} at {id(self)} ' - f'(ot_hex={self._current_digest}, current_step={self._step}, ' - f'seed={self._seed}, ' - f'hash_algo={self._hash_algo})' - ) - - @property - def challenge_string(self) -> str: - """challenge_string-property""" - # RFC-2289: "...the entire challenge string MUST be - # terminated with either a space or a new line." - return f'otp-{self._hash_algo} {self._step} {self._seed} ' - - @property - def current_digest(self) -> bytes: - """current_digest-property""" - return self._current_digest - - @property - def hash_algo(self) -> str: - """hash_algo-property""" - return self._hash_algo - - @property - def seed(self) -> str: - """seed-property""" - return self._seed - - @property - def step(self) -> int: - """step-property""" - return self._step - - @property - def validated(self) -> bool: - """validated-property""" - return bool(self._new_digest_hex) - - @classmethod - def from_dict(cls, dict_obj: dict): - """ - Returns an OTPState object from the dict-object - - :param dict_obj: The dict object - :type dict_obj: dict - - :return: A new OTPState object - :rtype: otp2289.OTPStore - """ - return cls(**dict_obj) - - @staticmethod - def response_to_bytes(response: str) -> bytes: - """ - A wrapper that handles/validates the response as specified by RFC-2289. - - The method first checks if response is a token and tries to convert - it to bytes. If that fails, the method assumes that response is a hex. - If neither of those attempts succeeds OTPInvalidResponse is raised. - It is up to the caller to run another iteration and compare the result - to an existing digest in this state. - - :param response: The response to this state (its challenge) - :type response: str - - :raises otp2289.OTPInvalidResponse: If the response is corrupt/illegal, - but not if it simply does not - validate - - :return: The bytes representation of response (if any) - :rtype: bytes - """ - try: - return OTPGenerator.tokens_to_bytes(response) - except OTPGeneratorException: - # now assume hex... - try: - return OTPState.validate_hex(response) - except OTPStateException: - raise OTPInvalidResponse( - 'The response is neither a valid token or hex' - ) from None - - @staticmethod - def validate_hex(ot_hex: str) -> bytes: - """ - Validates the provided hexidigest. - - :param ot_hex: The one-time hex to validate - :type ot_hex: str - - :raises otp2289.OTPStateException: If hex does not validate - - :return: The validated hex (without leading 0x) converted to bytes - :rtype: bytes - """ - if not isinstance(ot_hex, str): - raise OTPStateException('OT-hex must be a str') - if ot_hex.startswith('0x'): - ot_hex = ot_hex[2:] - ot_hex = ot_hex.strip().lower() - if len(ot_hex) != 16: - raise OTPStateException( - 'The length of the hex should be 16 ' - '(representing 64 bits digest)' - ) - try: - return binascii.unhexlify(ot_hex) - except binascii.Error: - raise OTPStateException('Invalid OT-hex') from None - - def get_next_state(self): - """ - Returns the next state for a validated OTPState. - - This is a brand new OTPState object with the same hash_algo and seed - where step -= 1 and ot_hex = self._new_digest_hex - - :return: The next OTPState if validated, None otherwise - :rtype: otp2289.OTPState or None - """ - if self._new_digest_hex is None: - return None - return OTPState( - self._new_digest_hex, - self._step - 1, - self._seed, - self._hash_algo, - ) - - def response_validates( - self, - response: str, - store_valid_response: str = True, - ) -> bool: - """ - Validates the incoming response as specified by RFC-2289. - - :param response: The response to this state (its challenge) - :type response: str - - :param store_valid_response: Should a valid response be stored - :type store_valid_response: bool - - :raises otp2289.OTPInvalidResponse: If the response does not match - this state - - :return: Returns True if response validates, False otherwise - :rtype: bool - """ - # self.response_to_bytes raises OTPInvalidResponse in case response - # is corrupt or in a wrong format - response_bytes = self.response_to_bytes(response) - if self._hash_algo == 'md5': - digest = hashlib.md5(response_bytes).digest() - if ( - self._current_digest is None - or OTPGenerator.strxor(digest[0:8], digest[8:]) - == self._current_digest - ): - if store_valid_response: - self._new_digest_hex = binascii.hexlify( - response_bytes - ).decode() - return True - return False - if self._hash_algo == 'sha1': - digest = hashlib.sha1(response_bytes).digest() - if ( - self._current_digest is None - or OTPGenerator.sha1_digest_folding( - hashlib.sha1(response_bytes).digest() - ) - == self._current_digest - ): - if store_valid_response: - self._new_digest_hex = binascii.hexlify( - response_bytes - ).decode() - return True - return False - # this should not happen since the hash_algo is validated by the caller - raise OTPInvalidResponse(f'Ivalid hash_algo: {self._hash_algo}') - - def to_dict(self) -> dict: - """ - Returns a dict representation of the object. - - This could be the base for a JSON serialization. - - :return: The dict representation of the object - :rtype: dict - """ - ot_hex = self._current_digest - if ot_hex is not None: - ot_hex = binascii.hexlify(self._current_digest).decode() - return { - 'ot_hex': ot_hex, - 'current_step': self._step, - 'seed': self._seed, - 'hash_algo': self._hash_algo, - } - - -class OTPStore: - """ - OTPStore class - - A helper / container class that stores OTPState objects in a 2 layered - dict structure represented by [domain][key]. - - The class could serve as a base class when implementing store backends. - """ - - def __init__(self, data=None): - """ - Constructs an OTPStore object from data - - :param data: The data object, defaults to None - :type data: object or None - """ - self._data = {} # {key1: {state1-data...}, key2: {state2-data...}} - self._states = {} # OTPState: (domain, key) - dict - if data is not None: - self._add_data(data) - - def __contains__(self, state): - """membership test""" - return state in self._states - - def __iter__(self): - """iterator for OTPStore""" - return iter(self._data) - - def __len__(self): - """len() implementation""" - return len(self._data) - - @property - def data(self) -> dict: - """ - data-property - - Exposes the entire raw-data structure (dict). - Use the high level methods when possible! - """ - return self._data - - @property - def states(self) -> dict: - """ - states-property - - Exposes the entire states structure (dict). - Use the high level methods when possible! - """ - return self._states - - def add_state(self, key: str, state: OTPState): - """ - Adds an OTPState object with a given key. - - :param key: The key under which to add the state - :type key: str - - :param state: The OTPState object - :type state: otp2289.OTPState - - :raises otp2289.OTPStoreException: On failure - """ - if not isinstance(key, str): - raise OTPStoreException('key must be a str') - if not isinstance(state, OTPState): - raise OTPStoreException('state must be an OTPState-object') - self._data[key] = state - self._states[state] = key - - def get(self, key, default=None): - """A wrapper for dict.get""" - return self._data.get(key, default) - - def items(self): - """A wrapper for dict.items""" - return self._data.items() - - def pop_state(self, key: str) -> OTPState: - """ - Removes specified key and returns the corresponding OTPState-object. - - :param key: The key - :type key: str - - :raises KeyError: If key does not exist - - :raises otp2289.OTPStoreException: On failure - - :return: The state corresponding to the key - :rtype: otp2289.OTPState - """ - if not isinstance(key, str): - raise OTPStoreException('key must be a str') - state = self._data.pop(key) - self._states.pop(state) - return state - - def response_validates( - self, - key: str, - response: str, - store_valid_response: bool = True, - ) -> bool: - """ - A method that wraps around OTPState.response_validates and - OTPState.get_next_state. - - The response is validated against the OTPState object that corresponds - to key (if any). If store_valid_response is True, the state is replaced - by the next state on successful validation. - - :param key: The key - :type key: str - - :param response: The response to this state (its challenge) - :type response: str - - :param store_valid_response: Should a valid response be stored - :type store_valid_response: bool - - :raises KeyError: If the key is not present - - :raises otp2289.OTPInvalidResponse: If the response does not match - this state - - :return: Returns True if response validates, False otherwise - :rtype: bool - """ - state = self._data[key] - rvalue = state.response_validates(response, store_valid_response) - if rvalue and store_valid_response: - next_state = state.get_next_state() - self._data[key] = next_state - self._states[next_state] = key - self._states.pop(state) - return rvalue - - def to_dict(self) -> dict: - """ - Returns a dict representation of the object. - - This could be the base for a JSON serialization. - - :return: The dict representation of the object - :rtype: dict - """ - return {key: state.to_dict() for key, state in self._data.items()} - - def _add_data(self, dict_obj: dict) -> dict: - """ - Adds data from a dict object (dict_obj). - - This method should probably be either overloaded or wrapped - in a child class. - - dict_obj has the following format: - {'key': {'ot_hex': val1, - 'current_step': val2, - 'seed': val3, - 'hash_algo': val4}, - ...., ....} - - :param dict_obj: The dict-object - :type dict_obj: dict - """ - if not dict_obj: - return - for key, state_dict in dict_obj.items(): - self.add_state(key, OTPState(**state_dict)) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..21a01e2 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,14 @@ +[build-system] +requires = [ + "setuptools>=51", + "wheel" +] + +build-backend = "setuptools.build_meta" + + +[tool.pytest.ini_options] +minversion = "6.0" +testpaths = [ + "tests" +] diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..d6b2eb9 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,42 @@ +[metadata] +name = pyotp2289 +version = attr: otp2289.__version__ +author = attr: otp2289.__author__ +author_email = sgs@pichove.org +description = A pure Python implementation of "A One-Time Password System" +long_description = file: README.md +long_description_content_type = text/markdown +url = https://github.com/blackm0re/pyotp2289 + +classifiers = + Development Status :: 5 - Production/Stable + Intended Audience :: Developers + License :: OSI Approved :: BSD License + Programming Language :: Python :: 3 + Programming Language :: Python :: 3.6 + Programming Language :: Python :: 3.7 + Programming Language :: Python :: 3.8 + Programming Language :: Python :: 3.9 + Programming Language :: Python :: 3.10 + Programming Language :: Python :: 3.11 + Programming Language :: Python :: Implementation + Operating System :: OS Independent + Topic :: Security :: Cryptography + +project_urls = + Bug Tracker = https://github.com/blackm0re/pyotp2289/issues + Source = https://github.com/blackm0re/pyotp2289 + API Documentation = https://gnulover.simeonov.no/docs/api/pyotp2289/latest/ + +[options] +package_dir = + = src +packages = find: +python_requires = >=3.6 + +[options.packages.find] +where = src + +[options.entry_points] +console_scripts = + otp2289 = otp2289.__main__:main diff --git a/setup.py b/setup.py deleted file mode 100644 index 875d966..0000000 --- a/setup.py +++ /dev/null @@ -1,50 +0,0 @@ -# -*- coding: utf-8 -*- - -import setuptools - -import otp2289 - - -with open('README.md', 'r') as fh: - long_description = fh.read() - - -setuptools.setup( - name='pyotp2289', - version=otp2289.__version__, - author=otp2289.__author__, - author_email='sgs@pichove.org', - description='A pure Python implementation of "A One-Time Password System"', - license=otp2289.__license__, - long_description=long_description, - long_description_content_type='text/markdown', - url='https://github.com/blackm0re/pyotp2289', - packages=setuptools.find_packages(), - exclude_package_data={'': ['.gitignore']}, - entry_points={ - 'console_scripts': [ - 'otp2289=otp2289.__main__:main', - ], - }, - classifiers=[ - 'Development Status :: 5 - Production/Stable', - 'Intended Audience :: Developers', - 'License :: OSI Approved :: BSD License', - 'Programming Language :: Python :: 3 :: Only', - 'Programming Language :: Python :: 3.6', - 'Programming Language :: Python :: 3.7', - 'Programming Language :: Python :: 3.8', - 'Programming Language :: Python :: 3.9', - 'Programming Language :: Python :: Implementation', - 'Operating System :: OS Independent', - 'Topic :: Security :: Cryptography' - ], - keywords='2289 freebsd unix security cryptography otp password', - project_urls={ - 'Bug Reports': 'https://github.com/blackm0re/pyotp2289/issues', - 'Source': 'https://github.com/blackm0re/pyotp2289', - 'API Documentation': ( - 'http://gnulover.simeonov.no/docs/api/pyotp2289/latest/') - }, - python_requires='>=3.6', -) diff --git a/sphinx_conf.py b/sphinx_conf.py index c7247ee..561db43 100644 --- a/sphinx_conf.py +++ b/sphinx_conf.py @@ -12,20 +12,23 @@ # # import os # import sys -# sys.path.insert(0, '/home/sgs/development/gitprogs/python/pyotp2289/otp2289') +# sys.path.insert( +# 0, +# '/home/sgs/development/gitprogs/python/pyotp2289/src/otp2289', +# ) # -- Project information ----------------------------------------------------- project = 'pyotp2289' -copyright = '2020, Simeon Simeonov' +copyright = '2020-2022, Simeon Simeonov' author = 'Simeon Simeonov' # The short X.Y version -version = '1.0.0' +version = '1.1.0' # The full version, including alpha/beta/rc tags -release = '1.0.0' +release = '1.1.0' # -- General configuration --------------------------------------------------- @@ -81,4 +84,4 @@ html_static_path = ['_static'] todo_include_todos = True # Generated by running: sphinx-apidoc with: -# -P -F -o html -H pyotp2289 -A "Simeon Simeonov" -V "1.0.0" otp2289 +# -P -F -o html -H pyotp2289 -A "Simeon Simeonov" -V "1.1.0" otp2289 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 @@ +# -*- coding: utf-8 -*- +# SPDX-License-Identifier: BSD-2-Clause-FreeBSD +# +# Copyright (c) 2020-2022 Simeon Simeonov +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE AUTHORS ``AS IS'' AND ANY EXPRESS OR +# IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES +# OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. +# IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, +# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT +# NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF +# THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +"""A pure Python implementation of RFC-2289""" +from .generator import ( + OTP_ALGO_MD5, + OTP_ALGO_SHA1, + OTPChallengeException, + OTPGenerator, + OTPGeneratorException, +) +from .server import ( + OTPInvalidResponse, + OTPState, + OTPStateException, + OTPStore, + OTPStoreException, +) + +__author__ = 'Simeon Simeonov' +__version__ = '1.1.0' +__license__ = 'BSD 2-Clause' + + +def int_or_str(value): + """Returns int value of value when possible""" + try: + return int(value) + except ValueError: + return value + + +VERSION = tuple(map(int_or_str, __version__.split('.'))) + +__all__ = [ + 'OTP_ALGO_MD5', + 'OTP_ALGO_SHA1', + 'OTPChallengeException', + 'OTPGenerator', + 'OTPGeneratorException', + 'OTPInvalidResponse', + 'OTPState', + 'OTPStateException', + 'OTPStore', + 'OTPStoreException', +] 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 @@ +# -*- coding: utf-8 -*- +# SPDX-License-Identifier: BSD-2-Clause-FreeBSD +# +# Copyright (c) 2020-2022 Simeon Simeonov +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE AUTHORS ``AS IS'' AND ANY EXPRESS OR +# IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES +# OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. +# IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, +# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT +# NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF +# THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +""" +CLI entry point for the otp2289 package + +Examples: +python -m otp2289 --initiate-new-sequence -s TesT + +python -m otp2289 --generate-otp-response -c "otp-md5 499 TesT " -f token +python -m otp2289 --generate-otp-response -s TesT -i 499 -f token +""" +import argparse +import errno +import getpass +import io +import os +import secrets +import string +import sys + +import otp2289 + + +def eprint(*arg, **kwargs): + """stdderr print wrapper""" + print(*arg, file=sys.stderr, flush=True, **kwargs) + + +def generate_otp_response(args: argparse.Namespace) -> str: + """ + Generates a response based on the parameters sent from the parser + + :param args: The arguments assigned from argparse + :type args: argparse.Namespace + + :raises otp2289.OTPChallengeException: If the challenge is invalid + + :raises otp2289.OTPGeneratorException: If generator parameters are wrong + + :return: The response string + :rtype: str + """ + generator = otp2289.generator.OTPGenerator( + args.password.encode(), + args.seed, + args.hash_algo, + ) + if args.challenge_string: + if args.output_format == 'token': + return generator.generate_otp_words_from_challenge( + args.challenge_string + ) + return generator.generate_otp_hexdigest_from_challenge( + args.challenge_string + ) + # regular parameters + header = '' + if not args.quiet: + header = ( + f'Seed: {args.seed}, Step: {args.step}, ' + f'Hash: {args.hash_algo}{os.linesep}' + ) + if args.output_format == 'token': + return header + generator.generate_otp_words(args.step) + return header + generator.generate_otp_hexdigest(args.step) + + +def generate_otp_range(args: argparse.Namespace) -> str: + """ + Generates range of responses based on the parameters sent from the parser + + :param args: The arguments assigned from argparse + :type args: argparse.Namespace + + :raises otp2289.OTPChallengeException: If the challenge is invalid + + :raises otp2289.OTPGeneratorException: If generator parameters are wrong + + :return: The responses string + :rtype: str + """ + generator = otp2289.generator.OTPGenerator( + args.password.encode(), + args.seed, + args.hash_algo, + ) + if args.output_format == 'token': + method = generator.generate_otp_words + else: + method = generator.generate_otp_hexdigest + # handle most cases explicitly + if args.range == 1: + return f'{args.step}: ' + method(args.step) + if args.range > args.step + 1: + args.range = args.step + 1 + # any need for quiet? + header = '' + if not args.quiet: + header = ( + f'Seed: {args.seed}, Step: {args.step}, ' + f'Hash: {args.hash_algo}, Range: {args.range}{os.linesep}' + ) + return header + os.linesep.join( + [ + f'{step}: ' + method(step) + for step in range( + args.step, + args.step - args.range, + -1, + ) + ] + ) + + +def get_password(args: argparse.Namespace) -> str: + """ + Extract the provided password using the defined argparse arguments + + :param args: The arguments assigned from argparse + :type args: argparse.Namespace + + :raises KeyboardInterrupt: If the password prompt is interrupted + + :return: The extrated password string + :rtype: str + """ + if args.force_password_prompt: + while True: + password = getpass.getpass() + if not args.initiate_new_sequence or password == getpass.getpass( + 'Repeat password: ' + ): + break + eprint('The passwords do not match') + return password + + if not args.password: + password = os.environ.get('OTP2289_PASSWORD') + if password is not None: + return password + while True: + password = getpass.getpass() + if not args.initiate_new_sequence or password == getpass.getpass( + 'Repeat password: ' + ): + break + eprint('The passwords do not match') + return password + + if os.path.isfile(args.password): + with io.open(args.password, 'r', encoding='utf-8') as fp: + return fp.readline().strip() + + return args.password + + +def get_rnd_seed() -> str: + """ + Returns a random seed in the format: + + 2 random letters (capitalize()) + 5 random digits + """ + rnd = secrets.SystemRandom() + return ''.join( + rnd.choices(string.ascii_lowercase, k=2) + ).capitalize() + ''.join(rnd.choices(string.digits, k=5)) + + +def initiate_new_sequence(args: argparse.Namespace) -> str: + """ + Generates a new sequence based on the parameters sent from the parser. + + :param args: The arguments assigned from argparse + :type args: argparse.Namespace + + :raises otp2289.OTPChallengeException: If the challenge is invalid + + :raises otp2289.OTPGeneratorException: If generator parameters are wrong + + :return: The response string + :rtype: str + """ + if not args.seed: + args.seed = get_rnd_seed() + header = '' + if not args.quiet: + header = ( + f'Seed: {args.seed}, Step: {args.step}, ' + f'Hash: {args.hash_algo}{os.linesep}' + ) + generator = otp2289.generator.OTPGenerator( + args.password.encode(), + args.seed, + args.hash_algo, + ) + if args.challenge_string: + return header + generator.generate_otp_hexdigest_from_challenge( + args.challenge_string + ) + return header + generator.generate_otp_hexdigest(args.step) + + +def main(args=None): + """the main entry point""" + parser = argparse.ArgumentParser( + prog=__package__, + epilog=( + f'%(prog)s {otp2289.__version__} by Simeon Simeonov ' + '(sgs @ LiberaChat)' + ), + description='The following options are available', + ) + group = parser.add_mutually_exclusive_group(required=True) + password_group = parser.add_mutually_exclusive_group() + group.add_argument( + '--generate-otp-range', + action='store_true', + dest='generate_otp_range', + default=False, + help='Generates a range of OTP responses', + ) + group.add_argument( + '--generate-otp-response', + action='store_true', + dest='generate_otp_response', + default=False, + help='Generates a new OTP response', + ) + group.add_argument( + '--initiate-new-sequence', + action='store_true', + dest='initiate_new_sequence', + default=False, + help=( + 'Initiates a new OTP sequence. Essentially the same as ' + '--generate-otp-response only it prompts twice for password ' + 'and always outputs hex (ignores -f).' + ), + ) + password_group.add_argument( + '-P', + '--force-password-prompt', + dest='force_password_prompt', + action='store_true', + help=( + 'Force password prompt even if the env. variable ' + '"OTP2289_PASSWORD" is set' + ), + ) + password_group.add_argument( + '-p', + '--password', + metavar='', + type=str, + dest='password', + default='', + help=( + 'The password or path to password file ' + '(default & recommended: prompt for passwd)' + ), + ) + parser.add_argument( + '-a', + '--hash-algorithm', + metavar='', + type=str, + dest='hash_algo', + default='md5', + help='The hash algorithm to use. Possible values: md5 (default), sha1', + ) + parser.add_argument( + '-c', + '--challenge-string', + metavar='', + type=str, + dest='challenge_string', + default='', + help='Use challenge string when generating response', + ) + parser.add_argument( + '-f', + '--output-format', + metavar='', + type=str, + dest='output_format', + default='hex', + help='The output format to use. Possible values: hex (default), token', + ) + parser.add_argument( + '-i', + '--step', + metavar='', + type=int, + dest='step', + default=500, + help='The step. Default for initiating a new sequence is: 500', + ) + parser.add_argument( + '-q', + '--quiet', + action='store_true', + dest='quiet', + default=False, + help='Dot not show headers. Only hex / tokens', + ) + parser.add_argument( + '-r', + '--range', + metavar='', + type=int, + dest='range', + default=1, + help='Amount of consecutive OTP hex/tokens to generate. default: 1', + ) + parser.add_argument( + '-s', + '--seed', + metavar='[seed]', + type=str, + dest='seed', + default='', + help=( + 'The seed to use (1 to 16 alphanumeric characters) ' + '(default & recommended: random seed)' + ), + ) + parser.add_argument( + '-v', + '--version', + action='version', + version=f'%(prog)s {otp2289.__version__}', + help='display program-version and exit', + ) + args = parser.parse_args(args) + # handle the password before everything else + try: + args.password = get_password(args) + except KeyboardInterrupt: + eprint(os.linesep + 'Prompt terminated') + sys.exit(errno.EACCES) + except Exception as exp: + eprint(f'Unable to fetch password: {exp}') + sys.exit(1) + try: + if args.initiate_new_sequence: + print(initiate_new_sequence(args)) + if args.generate_otp_range: + print(generate_otp_range(args)) + if args.generate_otp_response: + print(generate_otp_response(args)) + sys.exit(0) + except otp2289.generator.OTPGeneratorException as exp: + eprint(f'GeneratorException: {exp}') + except otp2289.generator.OTPChallengeException as exp: + eprint(f'ChallengeException: {exp}') + except Exception as exp: + eprint(f'Unknown error: {exp}') + sys.exit(1) + + +if __name__ == '__main__': + 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 @@ +# -*- coding: utf-8 -*- +# SPDX-License-Identifier: BSD-2-Clause-FreeBSD +# +# Copyright (c) 2020-2022 Simeon Simeonov +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE AUTHORS ``AS IS'' AND ANY EXPRESS OR +# IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES +# OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. +# IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, +# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT +# NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF +# THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +"""A pure Python implementation of the RFC-2289 OTP generator""" +import binascii +import hashlib +import string + +OTP_ALGO_MD5 = 1 +OTP_ALGO_SHA1 = 2 + +# the tokens are defined in https://tools.ietf.org/html/rfc2289 # +RFC1760_TOKENS = [ + 'A', 'ABE', 'ACE', 'ACT', 'AD', 'ADA', 'ADD', + 'AGO', 'AID', 'AIM', 'AIR', 'ALL', 'ALP', 'AM', 'AMY', + 'AN', 'ANA', 'AND', 'ANN', 'ANT', 'ANY', 'APE', 'APS', + 'APT', 'ARC', 'ARE', 'ARK', 'ARM', 'ART', 'AS', 'ASH', + 'ASK', 'AT', 'ATE', 'AUG', 'AUK', 'AVE', 'AWE', 'AWK', + 'AWL', 'AWN', 'AX', 'AYE', 'BAD', 'BAG', 'BAH', 'BAM', + 'BAN', 'BAR', 'BAT', 'BAY', 'BE', 'BED', 'BEE', 'BEG', + 'BEN', 'BET', 'BEY', 'BIB', 'BID', 'BIG', 'BIN', 'BIT', + 'BOB', 'BOG', 'BON', 'BOO', 'BOP', 'BOW', 'BOY', 'BUB', + 'BUD', 'BUG', 'BUM', 'BUN', 'BUS', 'BUT', 'BUY', 'BY', + 'BYE', 'CAB', 'CAL', 'CAM', 'CAN', 'CAP', 'CAR', 'CAT', + 'CAW', 'COD', 'COG', 'COL', 'CON', 'COO', 'COP', 'COT', + 'COW', 'COY', 'CRY', 'CUB', 'CUE', 'CUP', 'CUR', 'CUT', + 'DAB', 'DAD', 'DAM', 'DAN', 'DAR', 'DAY', 'DEE', 'DEL', + 'DEN', 'DES', 'DEW', 'DID', 'DIE', 'DIG', 'DIN', 'DIP', + 'DO', 'DOE', 'DOG', 'DON', 'DOT', 'DOW', 'DRY', 'DUB', + 'DUD', 'DUE', 'DUG', 'DUN', 'EAR', 'EAT', 'ED', 'EEL', + 'EGG', 'EGO', 'ELI', 'ELK', 'ELM', 'ELY', 'EM', 'END', + 'EST', 'ETC', 'EVA', 'EVE', 'EWE', 'EYE', 'FAD', 'FAN', + 'FAR', 'FAT', 'FAY', 'FED', 'FEE', 'FEW', 'FIB', 'FIG', + 'FIN', 'FIR', 'FIT', 'FLO', 'FLY', 'FOE', 'FOG', 'FOR', + 'FRY', 'FUM', 'FUN', 'FUR', 'GAB', 'GAD', 'GAG', 'GAL', + 'GAM', 'GAP', 'GAS', 'GAY', 'GEE', 'GEL', 'GEM', 'GET', + 'GIG', 'GIL', 'GIN', 'GO', 'GOT', 'GUM', 'GUN', 'GUS', + 'GUT', 'GUY', 'GYM', 'GYP', 'HA', 'HAD', 'HAL', 'HAM', + 'HAN', 'HAP', 'HAS', 'HAT', 'HAW', 'HAY', 'HE', 'HEM', + 'HEN', 'HER', 'HEW', 'HEY', 'HI', 'HID', 'HIM', 'HIP', + 'HIS', 'HIT', 'HO', 'HOB', 'HOC', 'HOE', 'HOG', 'HOP', + 'HOT', 'HOW', 'HUB', 'HUE', 'HUG', 'HUH', 'HUM', 'HUT', + 'I', 'ICY', 'IDA', 'IF', 'IKE', 'ILL', 'INK', 'INN', + 'IO', 'ION', 'IQ', 'IRA', 'IRE', 'IRK', 'IS', 'IT', + 'ITS', 'IVY', 'JAB', 'JAG', 'JAM', 'JAN', 'JAR', 'JAW', + 'JAY', 'JET', 'JIG', 'JIM', 'JO', 'JOB', 'JOE', 'JOG', + 'JOT', 'JOY', 'JUG', 'JUT', 'KAY', 'KEG', 'KEN', 'KEY', + 'KID', 'KIM', 'KIN', 'KIT', 'LA', 'LAB', 'LAC', 'LAD', + 'LAG', 'LAM', 'LAP', 'LAW', 'LAY', 'LEA', 'LED', 'LEE', + 'LEG', 'LEN', 'LEO', 'LET', 'LEW', 'LID', 'LIE', 'LIN', + 'LIP', 'LIT', 'LO', 'LOB', 'LOG', 'LOP', 'LOS', 'LOT', + 'LOU', 'LOW', 'LOY', 'LUG', 'LYE', 'MA', 'MAC', 'MAD', + 'MAE', 'MAN', 'MAO', 'MAP', 'MAT', 'MAW', 'MAY', 'ME', + 'MEG', 'MEL', 'MEN', 'MET', 'MEW', 'MID', 'MIN', 'MIT', + 'MOB', 'MOD', 'MOE', 'MOO', 'MOP', 'MOS', 'MOT', 'MOW', + 'MUD', 'MUG', 'MUM', 'MY', 'NAB', 'NAG', 'NAN', 'NAP', + 'NAT', 'NAY', 'NE', 'NED', 'NEE', 'NET', 'NEW', 'NIB', + 'NIL', 'NIP', 'NIT', 'NO', 'NOB', 'NOD', 'NON', 'NOR', + 'NOT', 'NOV', 'NOW', 'NU', 'NUN', 'NUT', 'O', 'OAF', + 'OAK', 'OAR', 'OAT', 'ODD', 'ODE', 'OF', 'OFF', 'OFT', + 'OH', 'OIL', 'OK', 'OLD', 'ON', 'ONE', 'OR', 'ORB', + 'ORE', 'ORR', 'OS', 'OTT', 'OUR', 'OUT', 'OVA', 'OW', + 'OWE', 'OWL', 'OWN', 'OX', 'PA', 'PAD', 'PAL', 'PAM', + 'PAN', 'PAP', 'PAR', 'PAT', 'PAW', 'PAY', 'PEA', 'PEG', + 'PEN', 'PEP', 'PER', 'PET', 'PEW', 'PHI', 'PI', 'PIE', + 'PIN', 'PIT', 'PLY', 'PO', 'POD', 'POE', 'POP', 'POT', + 'POW', 'PRO', 'PRY', 'PUB', 'PUG', 'PUN', 'PUP', 'PUT', + 'QUO', 'RAG', 'RAM', 'RAN', 'RAP', 'RAT', 'RAW', 'RAY', + 'REB', 'RED', 'REP', 'RET', 'RIB', 'RID', 'RIG', 'RIM', + 'RIO', 'RIP', 'ROB', 'ROD', 'ROE', 'RON', 'ROT', 'ROW', + 'ROY', 'RUB', 'RUE', 'RUG', 'RUM', 'RUN', 'RYE', 'SAC', + 'SAD', 'SAG', 'SAL', 'SAM', 'SAN', 'SAP', 'SAT', 'SAW', + 'SAY', 'SEA', 'SEC', 'SEE', 'SEN', 'SET', 'SEW', 'SHE', + 'SHY', 'SIN', 'SIP', 'SIR', 'SIS', 'SIT', 'SKI', 'SKY', + 'SLY', 'SO', 'SOB', 'SOD', 'SON', 'SOP', 'SOW', 'SOY', + 'SPA', 'SPY', 'SUB', 'SUD', 'SUE', 'SUM', 'SUN', 'SUP', + 'TAB', 'TAD', 'TAG', 'TAN', 'TAP', 'TAR', 'TEA', 'TED', + 'TEE', 'TEN', 'THE', 'THY', 'TIC', 'TIE', 'TIM', 'TIN', + 'TIP', 'TO', 'TOE', 'TOG', 'TOM', 'TON', 'TOO', 'TOP', + 'TOW', 'TOY', 'TRY', 'TUB', 'TUG', 'TUM', 'TUN', 'TWO', + 'UN', 'UP', 'US', 'USE', 'VAN', 'VAT', 'VET', 'VIE', + 'WAD', 'WAG', 'WAR', 'WAS', 'WAY', 'WE', 'WEB', 'WED', + 'WEE', 'WET', 'WHO', 'WHY', 'WIN', 'WIT', 'WOK', 'WON', + 'WOO', 'WOW', 'WRY', 'WU', 'YAM', 'YAP', 'YAW', 'YE', + 'YEA', 'YES', 'YET', 'YOU', 'ABED', 'ABEL', 'ABET', 'ABLE', + 'ABUT', 'ACHE', 'ACID', 'ACME', 'ACRE', 'ACTA', 'ACTS', 'ADAM', + 'ADDS', 'ADEN', 'AFAR', 'AFRO', 'AGEE', 'AHEM', 'AHOY', 'AIDA', + 'AIDE', 'AIDS', 'AIRY', 'AJAR', 'AKIN', 'ALAN', 'ALEC', 'ALGA', + 'ALIA', 'ALLY', 'ALMA', 'ALOE', 'ALSO', 'ALTO', 'ALUM', 'ALVA', + 'AMEN', 'AMES', 'AMID', 'AMMO', 'AMOK', 'AMOS', 'AMRA', 'ANDY', + 'ANEW', 'ANNA', 'ANNE', 'ANTE', 'ANTI', 'AQUA', 'ARAB', 'ARCH', + 'AREA', 'ARGO', 'ARID', 'ARMY', 'ARTS', 'ARTY', 'ASIA', 'ASKS', + 'ATOM', 'AUNT', 'AURA', 'AUTO', 'AVER', 'AVID', 'AVIS', 'AVON', + 'AVOW', 'AWAY', 'AWRY', 'BABE', 'BABY', 'BACH', 'BACK', 'BADE', + 'BAIL', 'BAIT', 'BAKE', 'BALD', 'BALE', 'BALI', 'BALK', 'BALL', + 'BALM', 'BAND', 'BANE', 'BANG', 'BANK', 'BARB', 'BARD', 'BARE', + 'BARK', 'BARN', 'BARR', 'BASE', 'BASH', 'BASK', 'BASS', 'BATE', + 'BATH', 'BAWD', 'BAWL', 'BEAD', 'BEAK', 'BEAM', 'BEAN', 'BEAR', + 'BEAT', 'BEAU', 'BECK', 'BEEF', 'BEEN', 'BEER', 'BEET', 'BELA', + 'BELL', 'BELT', 'BEND', 'BENT', 'BERG', 'BERN', 'BERT', 'BESS', + 'BEST', 'BETA', 'BETH', 'BHOY', 'BIAS', 'BIDE', 'BIEN', 'BILE', + 'BILK', 'BILL', 'BIND', 'BING', 'BIRD', 'BITE', 'BITS', 'BLAB', + 'BLAT', 'BLED', 'BLEW', 'BLOB', 'BLOC', 'BLOT', 'BLOW', 'BLUE', + 'BLUM', 'BLUR', 'BOAR', 'BOAT', 'BOCA', 'BOCK', 'BODE', 'BODY', + 'BOGY', 'BOHR', 'BOIL', 'BOLD', 'BOLO', 'BOLT', 'BOMB', 'BONA', + 'BOND', 'BONE', 'BONG', 'BONN', 'BONY', 'BOOK', 'BOOM', 'BOON', + 'BOOT', 'BORE', 'BORG', 'BORN', 'BOSE', 'BOSS', 'BOTH', 'BOUT', + 'BOWL', 'BOYD', 'BRAD', 'BRAE', 'BRAG', 'BRAN', 'BRAY', 'BRED', + 'BREW', 'BRIG', 'BRIM', 'BROW', 'BUCK', 'BUDD', 'BUFF', 'BULB', + 'BULK', 'BULL', 'BUNK', 'BUNT', 'BUOY', 'BURG', 'BURL', 'BURN', + 'BURR', 'BURT', 'BURY', 'BUSH', 'BUSS', 'BUST', 'BUSY', 'BYTE', + 'CADY', 'CAFE', 'CAGE', 'CAIN', 'CAKE', 'CALF', 'CALL', 'CALM', + 'CAME', 'CANE', 'CANT', 'CARD', 'CARE', 'CARL', 'CARR', 'CART', + 'CASE', 'CASH', 'CASK', 'CAST', 'CAVE', 'CEIL', 'CELL', 'CENT', + 'CERN', 'CHAD', 'CHAR', 'CHAT', 'CHAW', 'CHEF', 'CHEN', 'CHEW', + 'CHIC', 'CHIN', 'CHOU', 'CHOW', 'CHUB', 'CHUG', 'CHUM', 'CITE', + 'CITY', 'CLAD', 'CLAM', 'CLAN', 'CLAW', 'CLAY', 'CLOD', 'CLOG', + 'CLOT', 'CLUB', 'CLUE', 'COAL', 'COAT', 'COCA', 'COCK', 'COCO', + 'CODA', 'CODE', 'CODY', 'COED', 'COIL', 'COIN', 'COKE', 'COLA', + 'COLD', 'COLT', 'COMA', 'COMB', 'COME', 'COOK', 'COOL', 'COON', + 'COOT', 'CORD', 'CORE', 'CORK', 'CORN', 'COST', 'COVE', 'COWL', + 'CRAB', 'CRAG', 'CRAM', 'CRAY', 'CREW', 'CRIB', 'CROW', 'CRUD', + 'CUBA', 'CUBE', 'CUFF', 'CULL', 'CULT', 'CUNY', 'CURB', 'CURD', + 'CURE', 'CURL', 'CURT', 'CUTS', 'DADE', 'DALE', 'DAME', 'DANA', + 'DANE', 'DANG', 'DANK', 'DARE', 'DARK', 'DARN', 'DART', 'DASH', + 'DATA', 'DATE', 'DAVE', 'DAVY', 'DAWN', 'DAYS', 'DEAD', 'DEAF', + 'DEAL', 'DEAN', 'DEAR', 'DEBT', 'DECK', 'DEED', 'DEEM', 'DEER', + 'DEFT', 'DEFY', 'DELL', 'DENT', 'DENY', 'DESK', 'DIAL', 'DICE', + 'DIED', 'DIET', 'DIME', 'DINE', 'DING', 'DINT', 'DIRE', 'DIRT', + 'DISC', 'DISH', 'DISK', 'DIVE', 'DOCK', 'DOES', 'DOLE', 'DOLL', + 'DOLT', 'DOME', 'DONE', 'DOOM', 'DOOR', 'DORA', 'DOSE', 'DOTE', + 'DOUG', 'DOUR', 'DOVE', 'DOWN', 'DRAB', 'DRAG', 'DRAM', 'DRAW', + 'DREW', 'DRUB', 'DRUG', 'DRUM', 'DUAL', 'DUCK', 'DUCT', 'DUEL', + 'DUET', 'DUKE', 'DULL', 'DUMB', 'DUNE', 'DUNK', 'DUSK', 'DUST', + 'DUTY', 'EACH', 'EARL', 'EARN', 'EASE', 'EAST', 'EASY', 'EBEN', + 'ECHO', 'EDDY', 'EDEN', 'EDGE', 'EDGY', 'EDIT', 'EDNA', 'EGAN', + 'ELAN', 'ELBA', 'ELLA', 'ELSE', 'EMIL', 'EMIT', 'EMMA', 'ENDS', + 'ERIC', 'EROS', 'EVEN', 'EVER', 'EVIL', 'EYED', 'FACE', 'FACT', + 'FADE', 'FAIL', 'FAIN', 'FAIR', 'FAKE', 'FALL', 'FAME', 'FANG', + 'FARM', 'FAST', 'FATE', 'FAWN', 'FEAR', 'FEAT', 'FEED', 'FEEL', + 'FEET', 'FELL', 'FELT', 'FEND', 'FERN', 'FEST', 'FEUD', 'FIEF', + 'FIGS', 'FILE', 'FILL', 'FILM', 'FIND', 'FINE', 'FINK', 'FIRE', + 'FIRM', 'FISH', 'FISK', 'FIST', 'FITS', 'FIVE', 'FLAG', 'FLAK', + 'FLAM', 'FLAT', 'FLAW', 'FLEA', 'FLED', 'FLEW', 'FLIT', 'FLOC', + 'FLOG', 'FLOW', 'FLUB', 'FLUE', 'FOAL', 'FOAM', 'FOGY', 'FOIL', + 'FOLD', 'FOLK', 'FOND', 'FONT', 'FOOD', 'FOOL', 'FOOT', 'FORD', + 'FORE', 'FORK', 'FORM', 'FORT', 'FOSS', 'FOUL', 'FOUR', 'FOWL', + 'FRAU', 'FRAY', 'FRED', 'FREE', 'FRET', 'FREY', 'FROG', 'FROM', + 'FUEL', 'FULL', 'FUME', 'FUND', 'FUNK', 'FURY', 'FUSE', 'FUSS', + 'GAFF', 'GAGE', 'GAIL', 'GAIN', 'GAIT', 'GALA', 'GALE', 'GALL', + 'GALT', 'GAME', 'GANG', 'GARB', 'GARY', 'GASH', 'GATE', 'GAUL', + 'GAUR', 'GAVE', 'GAWK', 'GEAR', 'GELD', 'GENE', 'GENT', 'GERM', + 'GETS', 'GIBE', 'GIFT', 'GILD', 'GILL', 'GILT', 'GINA', 'GIRD', + 'GIRL', 'GIST', 'GIVE', 'GLAD', 'GLEE', 'GLEN', 'GLIB', 'GLOB', + 'GLOM', 'GLOW', 'GLUE', 'GLUM', 'GLUT', 'GOAD', 'GOAL', 'GOAT', + 'GOER', 'GOES', 'GOLD', 'GOLF', 'GONE', 'GONG', 'GOOD', 'GOOF', + 'GORE', 'GORY', 'GOSH', 'GOUT', 'GOWN', 'GRAB', 'GRAD', 'GRAY', + 'GREG', 'GREW', 'GREY', 'GRID', 'GRIM', 'GRIN', 'GRIT', 'GROW', + 'GRUB', 'GULF', 'GULL', 'GUNK', 'GURU', 'GUSH', 'GUST', 'GWEN', + 'GWYN', 'HAAG', 'HAAS', 'HACK', 'HAIL', 'HAIR', 'HALE', 'HALF', + 'HALL', 'HALO', 'HALT', 'HAND', 'HANG', 'HANK', 'HANS', 'HARD', + 'HARK', 'HARM', 'HART', 'HASH', 'HAST', 'HATE', 'HATH', 'HAUL', + 'HAVE', 'HAWK', 'HAYS', 'HEAD', 'HEAL', 'HEAR', 'HEAT', 'HEBE', + 'HECK', 'HEED', 'HEEL', 'HEFT', 'HELD', 'HELL', 'HELM', 'HERB', + 'HERD', 'HERE', 'HERO', 'HERS', 'HESS', 'HEWN', 'HICK', 'HIDE', + 'HIGH', 'HIKE', 'HILL', 'HILT', 'HIND', 'HINT', 'HIRE', 'HISS', + 'HIVE', 'HOBO', 'HOCK', 'HOFF', 'HOLD', 'HOLE', 'HOLM', 'HOLT', + 'HOME', 'HONE', 'HONK', 'HOOD', 'HOOF', 'HOOK', 'HOOT', 'HORN', + 'HOSE', 'HOST', 'HOUR', 'HOVE', 'HOWE', 'HOWL', 'HOYT', 'HUCK', + 'HUED', 'HUFF', 'HUGE', 'HUGH', 'HUGO', 'HULK', 'HULL', 'HUNK', + 'HUNT', 'HURD', 'HURL', 'HURT', 'HUSH', 'HYDE', 'HYMN', 'IBIS', + 'ICON', 'IDEA', 'IDLE', 'IFFY', 'INCA', 'INCH', 'INTO', 'IONS', + 'IOTA', 'IOWA', 'IRIS', 'IRMA', 'IRON', 'ISLE', 'ITCH', 'ITEM', + 'IVAN', 'JACK', 'JADE', 'JAIL', 'JAKE', 'JANE', 'JAVA', 'JEAN', + 'JEFF', 'JERK', 'JESS', 'JEST', 'JIBE', 'JILL', 'JILT', 'JIVE', + 'JOAN', 'JOBS', 'JOCK', 'JOEL', 'JOEY', 'JOHN', 'JOIN', 'JOKE', + 'JOLT', 'JOVE', 'JUDD', 'JUDE', 'JUDO', 'JUDY', 'JUJU', 'JUKE', + 'JULY', 'JUNE', 'JUNK', 'JUNO', 'JURY', 'JUST', 'JUTE', 'KAHN', + 'KALE', 'KANE', 'KANT', 'KARL', 'KATE', 'KEEL', 'KEEN', 'KENO', + 'KENT', 'KERN', 'KERR', 'KEYS', 'KICK', 'KILL', 'KIND', 'KING', + 'KIRK', 'KISS', 'KITE', 'KLAN', 'KNEE', 'KNEW', 'KNIT', 'KNOB', + 'KNOT', 'KNOW', 'KOCH', 'KONG', 'KUDO', 'KURD', 'KURT', 'KYLE', + 'LACE', 'LACK', 'LACY', 'LADY', 'LAID', 'LAIN', 'LAIR', 'LAKE', + 'LAMB', 'LAME', 'LAND', 'LANE', 'LANG', 'LARD', 'LARK', 'LASS', + 'LAST', 'LATE', 'LAUD', 'LAVA', 'LAWN', 'LAWS', 'LAYS', 'LEAD', + 'LEAF', 'LEAK', 'LEAN', 'LEAR', 'LEEK', 'LEER', 'LEFT', 'LEND', + 'LENS', 'LENT', 'LEON', 'LESK', 'LESS', 'LEST', 'LETS', 'LIAR', + 'LICE', 'LICK', 'LIED', 'LIEN', 'LIES', 'LIEU', 'LIFE', 'LIFT', + 'LIKE', 'LILA', 'LILT', 'LILY', 'LIMA', 'LIMB', 'LIME', 'LIND', + 'LINE', 'LINK', 'LINT', 'LION', 'LISA', 'LIST', 'LIVE', 'LOAD', + 'LOAF', 'LOAM', 'LOAN', 'LOCK', 'LOFT', 'LOGE', 'LOIS', 'LOLA', + 'LONE', 'LONG', 'LOOK', 'LOON', 'LOOT', 'LORD', 'LORE', 'LOSE', + 'LOSS', 'LOST', 'LOUD', 'LOVE', 'LOWE', 'LUCK', 'LUCY', 'LUGE', + 'LUKE', 'LULU', 'LUND', 'LUNG', 'LURA', 'LURE', 'LURK', 'LUSH', + 'LUST', 'LYLE', 'LYNN', 'LYON', 'LYRA', 'MACE', 'MADE', 'MAGI', + 'MAID', 'MAIL', 'MAIN', 'MAKE', 'MALE', 'MALI', 'MALL', 'MALT', + 'MANA', 'MANN', 'MANY', 'MARC', 'MARE', 'MARK', 'MARS', 'MART', + 'MARY', 'MASH', 'MASK', 'MASS', 'MAST', 'MATE', 'MATH', 'MAUL', + 'MAYO', 'MEAD', 'MEAL', 'MEAN', 'MEAT', 'MEEK', 'MEET', 'MELD', + 'MELT', 'MEMO', 'MEND', 'MENU', 'MERT', 'MESH', 'MESS', 'MICE', + 'MIKE', 'MILD', 'MILE', 'MILK', 'MILL', 'MILT', 'MIMI', 'MIND', + 'MINE', 'MINI', 'MINK', 'MINT', 'MIRE', 'MISS', 'MIST', 'MITE', + 'MITT', 'MOAN', 'MOAT', 'MOCK', 'MODE', 'MOLD', 'MOLE', 'MOLL', + 'MOLT', 'MONA', 'MONK', 'MONT', 'MOOD', 'MOON', 'MOOR', 'MOOT', + 'MORE', 'MORN', 'MORT', 'MOSS', 'MOST', 'MOTH', 'MOVE', 'MUCH', + 'MUCK', 'MUDD', 'MUFF', 'MULE', 'MULL', 'MURK', 'MUSH', 'MUST', + 'MUTE', 'MUTT', 'MYRA', 'MYTH', 'NAGY', 'NAIL', 'NAIR', 'NAME', + 'NARY', 'NASH', 'NAVE', 'NAVY', 'NEAL', 'NEAR', 'NEAT', 'NECK', + 'NEED', 'NEIL', 'NELL', 'NEON', 'NERO', 'NESS', 'NEST', 'NEWS', + 'NEWT', 'NIBS', 'NICE', 'NICK', 'NILE', 'NINA', 'NINE', 'NOAH', + 'NODE', 'NOEL', 'NOLL', 'NONE', 'NOOK', 'NOON', 'NORM', 'NOSE', + 'NOTE', 'NOUN', 'NOVA', 'NUDE', 'NULL', 'NUMB', 'OATH', 'OBEY', + 'OBOE', 'ODIN', 'OHIO', 'OILY', 'OINT', 'OKAY', 'OLAF', 'OLDY', + 'OLGA', 'OLIN', 'OMAN', 'OMEN', 'OMIT', 'ONCE', 'ONES', 'ONLY', + 'ONTO', 'ONUS', 'ORAL', 'ORGY', 'OSLO', 'OTIS', 'OTTO', 'OUCH', + 'OUST', 'OUTS', 'OVAL', 'OVEN', 'OVER', 'OWLY', 'OWNS', 'QUAD', + 'QUIT', 'QUOD', 'RACE', 'RACK', 'RACY', 'RAFT', 'RAGE', 'RAID', + 'RAIL', 'RAIN', 'RAKE', 'RANK', 'RANT', 'RARE', 'RASH', 'RATE', + 'RAVE', 'RAYS', 'READ', 'REAL', 'REAM', 'REAR', 'RECK', 'REED', + 'REEF', 'REEK', 'REEL', 'REID', 'REIN', 'RENA', 'REND', 'RENT', + 'REST', 'RICE', 'RICH', 'RICK', 'RIDE', 'RIFT', 'RILL', 'RIME', + 'RING', 'RINK', 'RISE', 'RISK', 'RITE', 'ROAD', 'ROAM', 'ROAR', + 'ROBE', 'ROCK', 'RODE', 'ROIL', 'ROLL', 'ROME', 'ROOD', 'ROOF', + 'ROOK', 'ROOM', 'ROOT', 'ROSA', 'ROSE', 'ROSS', 'ROSY', 'ROTH', + 'ROUT', 'ROVE', 'ROWE', 'ROWS', 'RUBE', 'RUBY', 'RUDE', 'RUDY', + 'RUIN', 'RULE', 'RUNG', 'RUNS', 'RUNT', 'RUSE', 'RUSH', 'RUSK', + 'RUSS', 'RUST', 'RUTH', 'SACK', 'SAFE', 'SAGE', 'SAID', 'SAIL', + 'SALE', 'SALK', 'SALT', 'SAME', 'SAND', 'SANE', 'SANG', 'SANK', + 'SARA', 'SAUL', 'SAVE', 'SAYS', 'SCAN', 'SCAR', 'SCAT', 'SCOT', + 'SEAL', 'SEAM', 'SEAR', 'SEAT', 'SEED', 'SEEK', 'SEEM', 'SEEN', + 'SEES', 'SELF', 'SELL', 'SEND', 'SENT', 'SETS', 'SEWN', 'SHAG', + 'SHAM', 'SHAW', 'SHAY', 'SHED', 'SHIM', 'SHIN', 'SHOD', 'SHOE', + 'SHOT', 'SHOW', 'SHUN', 'SHUT', 'SICK', 'SIDE', 'SIFT', 'SIGH', + 'SIGN', 'SILK', 'SILL', 'SILO', 'SILT', 'SINE', 'SING', 'SINK', + 'SIRE', 'SITE', 'SITS', 'SITU', 'SKAT', 'SKEW', 'SKID', 'SKIM', + 'SKIN', 'SKIT', 'SLAB', 'SLAM', 'SLAT', 'SLAY', 'SLED', 'SLEW', + 'SLID', 'SLIM', 'SLIT', 'SLOB', 'SLOG', 'SLOT', 'SLOW', 'SLUG', + 'SLUM', 'SLUR', 'SMOG', 'SMUG', 'SNAG', 'SNOB', 'SNOW', 'SNUB', + 'SNUG', 'SOAK', 'SOAR', 'SOCK', 'SODA', 'SOFA', 'SOFT', 'SOIL', + 'SOLD', 'SOME', 'SONG', 'SOON', 'SOOT', 'SORE', 'SORT', 'SOUL', + 'SOUR', 'SOWN', 'STAB', 'STAG', 'STAN', 'STAR', 'STAY', 'STEM', + 'STEW', 'STIR', 'STOW', 'STUB', 'STUN', 'SUCH', 'SUDS', 'SUIT', + 'SULK', 'SUMS', 'SUNG', 'SUNK', 'SURE', 'SURF', 'SWAB', 'SWAG', + 'SWAM', 'SWAN', 'SWAT', 'SWAY', 'SWIM', 'SWUM', 'TACK', 'TACT', + 'TAIL', 'TAKE', 'TALE', 'TALK', 'TALL', 'TANK', 'TASK', 'TATE', + 'TAUT', 'TEAL', 'TEAM', 'TEAR', 'TECH', 'TEEM', 'TEEN', 'TEET', + 'TELL', 'TEND', 'TENT', 'TERM', 'TERN', 'TESS', 'TEST', 'THAN', + 'THAT', 'THEE', 'THEM', 'THEN', 'THEY', 'THIN', 'THIS', 'THUD', + 'THUG', 'TICK', 'TIDE', 'TIDY', 'TIED', 'TIER', 'TILE', 'TILL', + 'TILT', 'TIME', 'TINA', 'TINE', 'TINT', 'TINY', 'TIRE', 'TOAD', + 'TOGO', 'TOIL', 'TOLD', 'TOLL', 'TONE', 'TONG', 'TONY', 'TOOK', + 'TOOL', 'TOOT', 'TORE', 'TORN', 'TOTE', 'TOUR', 'TOUT', 'TOWN', + 'TRAG', 'TRAM', 'TRAY', 'TREE', 'TREK', 'TRIG', 'TRIM', 'TRIO', + 'TROD', 'TROT', 'TROY', 'TRUE', 'TUBA', 'TUBE', 'TUCK', 'TUFT', + 'TUNA', 'TUNE', 'TUNG', 'TURF', 'TURN', 'TUSK', 'TWIG', 'TWIN', + 'TWIT', 'ULAN', 'UNIT', 'URGE', 'USED', 'USER', 'USES', 'UTAH', + 'VAIL', 'VAIN', 'VALE', 'VARY', 'VASE', 'VAST', 'VEAL', 'VEDA', + 'VEIL', 'VEIN', 'VEND', 'VENT', 'VERB', 'VERY', 'VETO', 'VICE', + 'VIEW', 'VINE', 'VISE', 'VOID', 'VOLT', 'VOTE', 'WACK', 'WADE', + 'WAGE', 'WAIL', 'WAIT', 'WAKE', 'WALE', 'WALK', 'WALL', 'WALT', + 'WAND', 'WANE', 'WANG', 'WANT', 'WARD', 'WARM', 'WARN', 'WART', + 'WASH', 'WAST', 'WATS', 'WATT', 'WAVE', 'WAVY', 'WAYS', 'WEAK', + 'WEAL', 'WEAN', 'WEAR', 'WEED', 'WEEK', 'WEIR', 'WELD', 'WELL', + 'WELT', 'WENT', 'WERE', 'WERT', 'WEST', 'WHAM', 'WHAT', 'WHEE', + 'WHEN', 'WHET', 'WHOA', 'WHOM', 'WICK', 'WIFE', 'WILD', 'WILL', + 'WIND', 'WINE', 'WING', 'WINK', 'WINO', 'WIRE', 'WISE', 'WISH', + 'WITH', 'WOLF', 'WONT', 'WOOD', 'WOOL', 'WORD', 'WORE', 'WORK', + 'WORM', 'WORN', 'WOVE', 'WRIT', 'WYNN', 'YALE', 'YANG', 'YANK', + 'YARD', 'YARN', 'YAWL', 'YAWN', 'YEAH', 'YEAR', 'YELL', 'YOGA', + 'YOKE'] + +_ALGO_DICT = {OTP_ALGO_MD5: 'md5', OTP_ALGO_SHA1: 'sha1'} + + +class OTPGeneratorException(Exception): + """OTPGeneratorException class""" + + +class OTPChallengeException(Exception): + """OTPChallengeException class""" + + +class OTPGenerator: + """OTPGenerator class""" + + def __init__( + self, + password: bytes, + seed: str = '', + hash_algo=OTP_ALGO_MD5, + ): + """ + Constructs an OTPGenerator object with a given password and seed. + + :param password: The password string + :type password: bytes + + :param seed: The seed received from the challenge, defaults to '' + :type seed: str + + :param hash_algo: The hash algo, defaults to OTP_ALGO_MD5 + :type hash_algo: int or str + + :raises otp2289.OTPGeneratorException: If the input does not validate + """ + # enforce the rfc2289 constraints + self._seed = seed + if self._seed: # the seed was set here. Validate it + self._seed = self.validate_seed(self._seed) + self._hash_algo = self.validate_hash_algo(hash_algo) + if not isinstance(password, bytes): + raise OTPGeneratorException('Password must be a byte-string') + if len(password) < 10: + raise OTPGeneratorException( + 'Password must be longer than 10 bytes' + ) + self._password = password + + def __repr__(self): + """repr implementation""" + return ( + f'{self.__class__} at {id(self)} (seed={self._seed}, ' + f'hash_algo={self._hash_algo})' + ) + + @staticmethod + def bit_pair_sum(bit_stream: str) -> int: + """ + Split bit_stream in bit-pairs and sum them all together. + + :param bit_stream: The bit-stream object + :type bit_stream: str + + :return: The sum of all bit-pairs in bit_stream + :rtype: int + """ + if not isinstance(bit_stream, str): + raise OTPGeneratorException('bit_stream must be of type str') + if len(bit_stream) != 64: + raise OTPGeneratorException('bit_stream must be of size 64') + value = 0 + for pair in zip(bit_stream[::2], bit_stream[1::2]): + value += int(''.join(pair), 2) + return value + + @staticmethod + def bytes_to_tokens(hash_bytes: bytes) -> str: + """ + Returns a 6 words token from bytes as specified by RFC-2289. + + :param hash_bytes: The input bytes + :type hash_bytes: bytes + + :return: 6 words tokens + :rtype: str + """ + bit_stream = ''.join([f'{byte:0>8b}' for byte in hash_bytes]) + bit_pair_sum = OTPGenerator.bit_pair_sum(bit_stream) + tokens = [] + tokens.append(RFC1760_TOKENS[int(bit_stream[:11], 2)]) + tokens.append(RFC1760_TOKENS[int(bit_stream[11:22], 2)]) + tokens.append(RFC1760_TOKENS[int(bit_stream[22:33], 2)]) + tokens.append(RFC1760_TOKENS[int(bit_stream[33:44], 2)]) + tokens.append(RFC1760_TOKENS[int(bit_stream[44:55], 2)]) + tokens.append( + RFC1760_TOKENS[ + int( + bit_stream[55:64] + f'{bit_pair_sum:0>8b}'[-2:], + 2, + ) + ] + ) + return ' '.join(tokens) + + @staticmethod + def get_tokens_from_challenge(challenge: str) -> tuple: + """ + Returns tokens (seed, hash_algo and step) from a challenge string. + + N.B. The tokens are not validated here. + + :param challenge: The challenge string described in RFC-2289 + :type challenge: str + + :raises otp2289.OTPChallengeException: If the challenge is invalid + + :return: (seed, hash_algo, step) tuple. + :rtype: tuple + """ + if not isinstance(challenge, str): + raise OTPChallengeException('Challenge must be str') + challenge = challenge.strip() + if not challenge.startswith('otp-'): + raise OTPChallengeException('Invalid challenge') + try: + hash_algo, step, seed = challenge[4:].split() + return (seed, hash_algo, int(step)) + except ValueError: + raise OTPChallengeException('Invalid challenge') from None + + @staticmethod + def sha1_digest_folding(sha1_digest: bytes) -> bytes: + """ + Implementation of the 160bit -> 64bit folding algorithm + for sha1 digest. + + :param sha1_digest: The SHA1 digest + :type sha1_digest: bytes + + :return: The byte-string representing the folded sha1-digest + :rtype: bytes + """ + if not (isinstance(sha1_digest, bytes)): + raise OTPGeneratorException('sha1_digest must be of type bytes') + if len(sha1_digest) != 20: + raise OTPGeneratorException( + 'sha1_digest must be 160 bits (20 bytes) long' + ) + digested = list(5 * b'i') # 5 bytes (40 bits) + result = list(8 * b'x') # 8 bytes (64 bits) + for i in range(5): + digested[i] = ( + ((sha1_digest[i * 4 + 0] & 0xFF) << 24) + | ((sha1_digest[i * 4 + 1] & 0xFF) << 16) + | ((sha1_digest[i * 4 + 2] & 0xFF) << 8) + | (sha1_digest[i * 4 + 3] & 0xFF) + ) + # sha.digest[0] ^= sha.digest[2]; + # sha.digest[1] ^= sha.digest[3]; + # sha.digest[0] ^= sha.digest[4]; + digested[0] ^= digested[2] + digested[1] ^= digested[3] + digested[0] ^= digested[4] + # for (i = 0, j = 0; j < 8; i++, j += 4) { + # result[j] = (unsigned char)(sha.digest[i] & 0xff); + # result[j+1] = (unsigned char)((sha.digest[i] >> 8) & 0xff); + # result[j+2] = (unsigned char)((sha.digest[i] >> 16) & 0xff); + # result[j+3] = (unsigned char)((sha.digest[i] >> 24) & 0xff); + # } + # just hardcoding the two iterations for better efficiency + result[0] = digested[0] & 0xFF + result[1] = (digested[0] >> 8) & 0xFF + result[2] = (digested[0] >> 16) & 0xFF + result[3] = (digested[0] >> 24) & 0xFF + result[4] = digested[1] & 0xFF + result[5] = (digested[1] >> 8) & 0xFF + result[6] = (digested[1] >> 16) & 0xFF + result[7] = (digested[1] >> 24) & 0xFF + return bytes(result) + + @staticmethod + def strxor(byte_str1: bytes, byte_str2: bytes) -> bytes: + """ + Implementation of strxor similar to the one provided by pycrypto. + + :param byte_str1: Byte-string 1 + :type byte_str1: bytes + + :param byte_str2: Byte-string 2 + :type byte_str2: bytes + + :return: The byte-string representing the result of byte_str1^byte_str2 + :rtype: bytes + """ + if not (isinstance(byte_str1, bytes) and isinstance(byte_str2, bytes)): + raise OTPGeneratorException( + 'byte_str1 and byte_str2 must be of type bytes' + ) + length = len(byte_str1) + if length != len(byte_str2) or length < 1: + raise OTPGeneratorException( + 'byte_str1 and byte_str2 must be of the same length > 0' + ) + return bytes([byte_str1[i] ^ byte_str2[i] for i in range(length)]) + + @staticmethod + def tokens_to_bytes(tokens_str: str) -> bytes: + """ + Returns bytes from a 6 words token as specified by RFC-2289. + + :param tokens_str: String representing 6 words tokens + :type tokens_str: str + + :raises otp2289.OTPGeneratorException: When the tokens_str is invalid + + :return: 6 words tokens + :rtype: bytes + """ + if not isinstance(tokens_str, str): + raise OTPGeneratorException('tokens must be a str') + tokens = tokens_str.split() + if len(tokens) != 6: + raise OTPGeneratorException( + 'Tokens-string does not contain 6 tokens' + ) + token_ints = [] + try: + token_ints = [ + RFC1760_TOKENS.index(token.upper()) for token in tokens + ] + except ValueError: + raise OTPGeneratorException( + 'One or more words not present in RFC1760' + ) from None + # now we build a string of bits + bit_stream = format(token_ints[0], '011b') + bit_stream += format(token_ints[1], '011b') + bit_stream += format(token_ints[2], '011b') + bit_stream += format(token_ints[3], '011b') + bit_stream += format(token_ints[4], '011b') + bit_stream += format(token_ints[5], '011b') + # we have 66 bits: 64 digest + 2 bit pair sum (control number) + # RFC-2289: All OTP generators MUST calculate this checksum and all + # OTP servers MUST verify this checksum explicitly as part of the + # operation of decoding this representation of the one-time password. + if ( + f'{OTPGenerator.bit_pair_sum(bit_stream[:64]):0>8b}'[-2:] + != bit_stream[-2:] + ): + raise OTPGeneratorException('Invalid bit checksum') + return int(bit_stream[:64], 2).to_bytes(8, 'big') + + @staticmethod + def validate_hash_algo(hash_algo) -> str: + """ + Validates the provided hash-algorithm. + + :param hash_algo: The hash algo, defaults to OTP_ALGO_MD5 + :type hash_algo: int or str + + :raises otp2289.OTPGeneratorException: If hash_algo does not validate + + :return: The validated hash_algo in str-form + :rtype: str + """ + if isinstance(hash_algo, int): + if hash_algo not in _ALGO_DICT: + raise OTPGeneratorException( + 'hash_algo is not among the known algorithms' + ) + hash_algo = _ALGO_DICT.get(hash_algo) + if not isinstance(hash_algo, str): + raise OTPGeneratorException('hash_algo must be an int or a str') + if hash_algo not in hashlib.algorithms_available: + raise OTPGeneratorException( + f'{hash_algo} is not supported by this version of the ' + 'hashlib module' + ) + return hash_algo + + @staticmethod + def validate_seed(seed: str) -> str: + """ + Validates the provided seed as defined by RFC-2289. + + :param seed: The seed received from the challenge, defaults to '' + :type seed: str + + :raises otp2289.OTPGeneratorException: If seed does not validate + + :return: The validated (and very same) seed + :rtype: str + """ + if not isinstance(seed, str): + raise OTPGeneratorException('Seed must be a string') + if not seed or len(seed) > 16: + raise OTPGeneratorException( + 'The seed MUST be of 1 to 16 characters in length' + ) + for char in seed: + if char not in string.ascii_letters + string.digits: + raise OTPGeneratorException( + 'The seed MUST consist of purely alphanumeric characters' + ) + return seed + + @staticmethod + def validate_step(step: int) -> int: + """ + Validates the provided step as defined by RFC-2289. + + :param seed: The step received from the challenge + :type seed: int + + :raises otp2289.OTPGeneratorException: If step does not validate + + :return: The validated (and very same) step + :rtype: int + """ + if not isinstance(step, int): + raise OTPGeneratorException('Step value MUST be an int') + if step < 0: + raise OTPGeneratorException('Step value MUST be >= 0') + return step + + def generate_otp_hexdigest(self, step: int) -> str: + """ + Generates the OTP hexdigest for the given step. + + :param step: The step to generate OTP for + :type step: int + + :return: Hexdigest for the given step + :rtype: str + """ + return '0x' + binascii.hexlify(self._generate_otp_bytes(step)).decode() + + def generate_otp_hexdigest_from_challenge(self, challenge: str) -> str: + """ + Same as generate_otp_hexdigest, but it generates hex. from a challenge. + + RFC-2289 states: + The challenge MUST be in a standard syntax so + that automated generators can recognize the challenge in context and + extract these parameters. The syntax of the challenge is: + otp- + + :param challenge: The challenge string + :type challenge: str + + :return: Hexdigest for the given challenge + :rtype: str + """ + seed, hash_algo, step = self.get_tokens_from_challenge(challenge) + self._seed = self.validate_seed(seed) + self._hash_algo = self.validate_hash_algo(hash_algo) + return self.generate_otp_hexdigest(step) + + def generate_otp_words(self, step: int) -> str: + """ + Generates the OTP six words token for the given step. + + :param step: The step to generate OTP for + :type step: int + + :return: Six words (separated by single space) token for the given step + :rtype: str + """ + return self.bytes_to_tokens(self._generate_otp_bytes(step)) + + def generate_otp_words_from_challenge(self, challenge: str) -> str: + """ + Same as generate_otp_words, but it generates words from a challenge. + + RFC-2289 states: + The challenge MUST be in a standard syntax so + that automated generators can recognize the challenge in context and + extract these parameters. The syntax of the challenge is: + otp- + + :param challenge: The challenge string + :type challenge: str + + :return: Six words token for the given challenge + :rtype: str + """ + seed, hash_algo, step = self.get_tokens_from_challenge(challenge) + self._seed = self.validate_seed(seed) + self._hash_algo = self.validate_hash_algo(hash_algo) + return self.generate_otp_words(step) + + def hexdigest_range(self, start: int = 499, stop: int = 0): + """ + Returns an iterator that providing hexdigests corresponding to steps + from `start` to and including `stop`. + + :param start: The start of the range (default: 499) + :type start: int + + :param stop: The last step (default: 0) + :type stop: int + + :return: Iterator + :rtype: generator + """ + if not isinstance(start, int) and isinstance(stop, int): + raise OTPGeneratorException('Step value MUST be an int') + if start < stop: + raise OTPGeneratorException( + 'Start value can not be lower than stop' + ) + for step in range(start, stop - 1, -1): + yield self.generate_otp_hexdigest(step) + + def words_range(self, start: int = 499, stop: int = 0): + """ + Returns an iterator that providing the words corresponding to steps + from `start` to and including `stop`. + + :param start: The start of the range (default: 499) + :type start: int + + :param stop: The last step (default: 0) + :type stop: int + + :return: Iterator + :rtype: generator + """ + if not isinstance(start, int) and isinstance(stop, int): + raise OTPGeneratorException('Step value MUST be an int') + if start < stop: + raise OTPGeneratorException( + 'Start value can not be lower than stop' + ) + for step in range(start, stop - 1, -1): + yield self.generate_otp_words(step) + + def _generate_otp_bytes(self, step: int) -> bytes: + """ + Generates the OTP bytes for the given step. + + :param step: The step to generate OTP for + :type step: int + + :return: The digest bytes for the given step + :rtype: bytes + """ + step = self.validate_step(step) + digest = b'' + for _ in range(step + 1): + hash_obj = hashlib.new(self._hash_algo) + if not digest: + # 0 step + hash_obj.update(self._seed.lower().encode() + self._password) + else: + hash_obj.update(digest) + large_digest = hash_obj.digest() + if self._hash_algo == 'md5': + # md4 and md5 128bit -> 64bit folding + digest = self.strxor(large_digest[0:8], large_digest[8:]) + elif self._hash_algo == 'sha1': + # sha1 160bit -> 64bit folding + digest = self.sha1_digest_folding(large_digest) + else: + raise OTPGeneratorException( + f'{self._hash_algo} is not supported by this module' + ) + 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 @@ +# -*- coding: utf-8 -*- +# SPDX-License-Identifier: BSD-2-Clause-FreeBSD +# +# Copyright (c) 2020-2022 Simeon Simeonov +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE AUTHORS ``AS IS'' AND ANY EXPRESS OR +# IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES +# OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. +# IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, +# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT +# NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF +# THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +"""A pure Python implementation of the RFC-2289 OTP server""" +import binascii +import hashlib + +from .generator import OTP_ALGO_MD5, OTPGenerator, OTPGeneratorException + + +class OTPStateException(Exception): + """OTPStateException class""" + + +class OTPStoreException(Exception): + """OTPStoreException class""" + + +class OTPInvalidResponse(Exception): + """OTPInvalidResponse class""" + + +class OTPState: + """ + OTPState class + + The OTPState class represents a single state on the server side that can: + - generate a challenge + - validate the corresponding generated response from the generator + """ + + def __init__( + self, + ot_hex: str, + current_step: int, + seed: str, + hash_algo=OTP_ALGO_MD5, + ): + """ + Constructs an OTPState object with the given arguments. + + Keyword Arguments: + :param ot_hex: The one-time hex from the last successful authentication + or None for a newly initialized sequence. + :type ot_hex: str or None + + :param current_step: The current step that is sent with the challenge + :type current_step: int + + :param seed: The seed that is sent with the challenge + :type seed: str + + :param hash_algo: The hash algo, defaults to OTP_ALGO_MD5 + :type hash_algo: int or str + + :raises otp2289.OTPStateException: If the input does not validate + """ + # enforce the rfc2289 constraints + try: + self._seed = OTPGenerator.validate_seed(seed) + self._hash_algo = OTPGenerator.validate_hash_algo(hash_algo) + self._step = OTPGenerator.validate_step(current_step) + except OTPGeneratorException as exp: + raise OTPStateException(exp.args[0]) from None + self._current_digest = None + if ot_hex is not None: + self._current_digest = self.validate_hex(ot_hex) + self._new_digest_hex = None # set upon a successful validation + + def __repr__(self): + """repr implementation""" + return ( + f'{self.__class__} at {id(self)} ' + f'(ot_hex={self._current_digest}, current_step={self._step}, ' + f'seed={self._seed}, ' + f'hash_algo={self._hash_algo})' + ) + + @property + def challenge_string(self) -> str: + """challenge_string-property""" + # RFC-2289: "...the entire challenge string MUST be + # terminated with either a space or a new line." + return f'otp-{self._hash_algo} {self._step} {self._seed} ' + + @property + def current_digest(self) -> bytes: + """current_digest-property""" + return self._current_digest + + @property + def hash_algo(self) -> str: + """hash_algo-property""" + return self._hash_algo + + @property + def seed(self) -> str: + """seed-property""" + return self._seed + + @property + def step(self) -> int: + """step-property""" + return self._step + + @property + def validated(self) -> bool: + """validated-property""" + return bool(self._new_digest_hex) + + @classmethod + def from_dict(cls, dict_obj: dict): + """ + Returns an OTPState object from the dict-object + + :param dict_obj: The dict object + :type dict_obj: dict + + :return: A new OTPState object + :rtype: otp2289.OTPStore + """ + return cls(**dict_obj) + + @staticmethod + def response_to_bytes(response: str) -> bytes: + """ + A wrapper that handles/validates the response as specified by RFC-2289. + + The method first checks if response is a token and tries to convert + it to bytes. If that fails, the method assumes that response is a hex. + If neither of those attempts succeeds OTPInvalidResponse is raised. + It is up to the caller to run another iteration and compare the result + to an existing digest in this state. + + :param response: The response to this state (its challenge) + :type response: str + + :raises otp2289.OTPInvalidResponse: If the response is corrupt/illegal, + but not if it simply does not + validate + + :return: The bytes representation of response (if any) + :rtype: bytes + """ + try: + return OTPGenerator.tokens_to_bytes(response) + except OTPGeneratorException: + # now assume hex... + try: + return OTPState.validate_hex(response) + except OTPStateException: + raise OTPInvalidResponse( + 'The response is neither a valid token or hex' + ) from None + + @staticmethod + def validate_hex(ot_hex: str) -> bytes: + """ + Validates the provided hexidigest. + + :param ot_hex: The one-time hex to validate + :type ot_hex: str + + :raises otp2289.OTPStateException: If hex does not validate + + :return: The validated hex (without leading 0x) converted to bytes + :rtype: bytes + """ + if not isinstance(ot_hex, str): + raise OTPStateException('OT-hex must be a str') + if ot_hex.startswith('0x'): + ot_hex = ot_hex[2:] + ot_hex = ot_hex.strip().lower() + if len(ot_hex) != 16: + raise OTPStateException( + 'The length of the hex should be 16 ' + '(representing 64 bits digest)' + ) + try: + return binascii.unhexlify(ot_hex) + except binascii.Error: + raise OTPStateException('Invalid OT-hex') from None + + def get_next_state(self): + """ + Returns the next state for a validated OTPState. + + This is a brand new OTPState object with the same hash_algo and seed + where step -= 1 and ot_hex = self._new_digest_hex + + :return: The next OTPState if validated, None otherwise + :rtype: otp2289.OTPState or None + """ + if self._new_digest_hex is None: + return None + return OTPState( + self._new_digest_hex, + self._step - 1, + self._seed, + self._hash_algo, + ) + + def response_validates( + self, + response: str, + store_valid_response: str = True, + ) -> bool: + """ + Validates the incoming response as specified by RFC-2289. + + :param response: The response to this state (its challenge) + :type response: str + + :param store_valid_response: Should a valid response be stored + :type store_valid_response: bool + + :raises otp2289.OTPInvalidResponse: If the response does not match + this state + + :return: Returns True if response validates, False otherwise + :rtype: bool + """ + # self.response_to_bytes raises OTPInvalidResponse in case response + # is corrupt or in a wrong format + response_bytes = self.response_to_bytes(response) + if self._hash_algo == 'md5': + digest = hashlib.md5(response_bytes).digest() + if ( + self._current_digest is None + or OTPGenerator.strxor(digest[0:8], digest[8:]) + == self._current_digest + ): + if store_valid_response: + self._new_digest_hex = binascii.hexlify( + response_bytes + ).decode() + return True + return False + if self._hash_algo == 'sha1': + digest = hashlib.sha1(response_bytes).digest() + if ( + self._current_digest is None + or OTPGenerator.sha1_digest_folding( + hashlib.sha1(response_bytes).digest() + ) + == self._current_digest + ): + if store_valid_response: + self._new_digest_hex = binascii.hexlify( + response_bytes + ).decode() + return True + return False + # this should not happen since the hash_algo is validated by the caller + raise OTPInvalidResponse(f'Ivalid hash_algo: {self._hash_algo}') + + def to_dict(self) -> dict: + """ + Returns a dict representation of the object. + + This could be the base for a JSON serialization. + + :return: The dict representation of the object + :rtype: dict + """ + ot_hex = self._current_digest + if ot_hex is not None: + ot_hex = binascii.hexlify(self._current_digest).decode() + return { + 'ot_hex': ot_hex, + 'current_step': self._step, + 'seed': self._seed, + 'hash_algo': self._hash_algo, + } + + +class OTPStore: + """ + OTPStore class + + A helper / container class that stores OTPState objects in a 2 layered + dict structure represented by [domain][key]. + + The class could serve as a base class when implementing store backends. + """ + + def __init__(self, data=None): + """ + Constructs an OTPStore object from data + + :param data: The data object, defaults to None + :type data: object or None + """ + self._data = {} # {key1: {state1-data...}, key2: {state2-data...}} + self._states = {} # OTPState: (domain, key) - dict + if data is not None: + self._add_data(data) + + def __contains__(self, state): + """membership test""" + return state in self._states + + def __iter__(self): + """iterator for OTPStore""" + return iter(self._data) + + def __len__(self): + """len() implementation""" + return len(self._data) + + @property + def data(self) -> dict: + """ + data-property + + Exposes the entire raw-data structure (dict). + Use the high level methods when possible! + """ + return self._data + + @property + def states(self) -> dict: + """ + states-property + + Exposes the entire states structure (dict). + Use the high level methods when possible! + """ + return self._states + + def add_state(self, key: str, state: OTPState): + """ + Adds an OTPState object with a given key. + + :param key: The key under which to add the state + :type key: str + + :param state: The OTPState object + :type state: otp2289.OTPState + + :raises otp2289.OTPStoreException: On failure + """ + if not isinstance(key, str): + raise OTPStoreException('key must be a str') + if not isinstance(state, OTPState): + raise OTPStoreException('state must be an OTPState-object') + self._data[key] = state + self._states[state] = key + + def get(self, key, default=None): + """A wrapper for dict.get""" + return self._data.get(key, default) + + def items(self): + """A wrapper for dict.items""" + return self._data.items() + + def pop_state(self, key: str) -> OTPState: + """ + Removes specified key and returns the corresponding OTPState-object. + + :param key: The key + :type key: str + + :raises KeyError: If key does not exist + + :raises otp2289.OTPStoreException: On failure + + :return: The state corresponding to the key + :rtype: otp2289.OTPState + """ + if not isinstance(key, str): + raise OTPStoreException('key must be a str') + state = self._data.pop(key) + self._states.pop(state) + return state + + def response_validates( + self, + key: str, + response: str, + store_valid_response: bool = True, + ) -> bool: + """ + A method that wraps around OTPState.response_validates and + OTPState.get_next_state. + + The response is validated against the OTPState object that corresponds + to key (if any). If store_valid_response is True, the state is replaced + by the next state on successful validation. + + :param key: The key + :type key: str + + :param response: The response to this state (its challenge) + :type response: str + + :param store_valid_response: Should a valid response be stored + :type store_valid_response: bool + + :raises KeyError: If the key is not present + + :raises otp2289.OTPInvalidResponse: If the response does not match + this state + + :return: Returns True if response validates, False otherwise + :rtype: bool + """ + state = self._data[key] + rvalue = state.response_validates(response, store_valid_response) + if rvalue and store_valid_response: + next_state = state.get_next_state() + self._data[key] = next_state + self._states[next_state] = key + self._states.pop(state) + return rvalue + + def to_dict(self) -> dict: + """ + Returns a dict representation of the object. + + This could be the base for a JSON serialization. + + :return: The dict representation of the object + :rtype: dict + """ + return {key: state.to_dict() for key, state in self._data.items()} + + def _add_data(self, dict_obj: dict) -> dict: + """ + Adds data from a dict object (dict_obj). + + This method should probably be either overloaded or wrapped + in a child class. + + dict_obj has the following format: + { + 'key': { + 'ot_hex': val1, + 'current_step': val2, + 'seed': val3, + 'hash_algo': val4 + }, + ..., + ..., + } + + :param dict_obj: The dict-object + :type dict_obj: dict + """ + if not dict_obj: + return + for key, state_dict in dict_obj.items(): + self.add_state(key, OTPState(**state_dict)) -- cgit v1.3