From bfae04723bd3155801f079ab5238018ec8a0cb09 Mon Sep 17 00:00:00 2001 From: Simeon Simeonov Date: Fri, 1 May 2026 14:34:04 +0200 Subject: Redesign API by introducing otp2289.OTPResponse type --- CHANGELOG.md | 28 +++-- README.md | 31 +++-- src/otp2289/__init__.py | 4 +- src/otp2289/__main__.py | 153 +++++++++++++---------- src/otp2289/generator.py | 314 ++++++++++++++++++++++++++--------------------- src/otp2289/server.py | 104 ++++++++-------- tests/test_generator.py | 278 ++++++++++++++++++++++++++++------------- tests/test_server.py | 4 + tests/test_static.py | 38 +++--- 9 files changed, 560 insertions(+), 394 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b220de..fe4f0be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,44 +1,48 @@ # Changelog -## [2.0.0](https://github.com/blackm0re/pyotp2289/tree/2.0.0) (2025-01-03) +## [2.0.0](https://codeberg.org/sgs/pyotp2289/releases/tag/v2.0.0) (2026-05-01) -[Full Changelog](https://github.com/blackm0re/pyotp2289/compare/1.2.1...2.0.0) +[Full Changelog](https://codeberg.org/sgs/pyotp2289/compare/v1.2.1...v2.0.0) **Changes:** -- Rename *Exception to *Error and do some additional linting +- Redesign API by introducing the otp2289.OTPResponse type +- Add type checking and linting -## [1.2.1](https://github.com/blackm0re/pyotp2289/tree/1.2.1) (2023-01-08) +- Move the project from GitHub to Codeberg -[Full Changelog](https://github.com/blackm0re/pyotp2289/compare/1.2.0...1.2.1) + +## [1.2.1](https://codeberg.org/sgs/pyotp2289/releases/tag/v1.2.1) (2023-01-08) + +[Full Changelog](https://codeberg.org/sgs/pyotp2289/compare/v1.2.0...v1.2.1) **Changes:** - Include the unit tests in the sdist package -## [1.2.0](https://github.com/blackm0re/pyotp2289/tree/1.2.0) (2023-01-05) +## [1.2.0](https://codeberg.org/sgs/pyotp2289/releases/tag/v1.2.0) (2023-01-05) -[Full Changelog](https://github.com/blackm0re/pyotp2289/compare/1.1.1...1.2.0) +[Full Changelog](https://codeberg.org/sgs/pyotp2289/compare/v1.1.1...v1.2.0) **Changes:** - Add the `OTPState.ot_hex` property -## [1.1.1](https://github.com/blackm0re/pyotp2289/tree/1.1.1) (2022-04-02) +## [1.1.1](https://codeberg.org/sgs/pyotp2289/releases/tag/v1.1.1) (2022-04-02) -[Full Changelog](https://github.com/blackm0re/pyotp2289/compare/1.1.0...1.1.1) +[Full Changelog](https://codeberg.org/sgs/pyotp2289/compare/v1.1.0...v1.1.1) **Changes:** - Reintroduce setup.py for better campatibility -## [1.1.0](https://github.com/blackm0re/pyotp2289/tree/1.1.0) (2022-03-29) +## [1.1.0](https://codeberg.org/sgs/pyotp2289/releases/tag/v1.1.0) (2022-03-29) -[Full Changelog](https://github.com/blackm0re/pyotp2289/compare/1.0.0...1.1.0) +[Full Changelog](https://codeberg.org/sgs/pyotp2289/compare/v1.0.0...v1.1.0) **Changes:** @@ -51,7 +55,7 @@ - use *setuptools* instead of the deprecated *distutils* -# [1.0.0](https://github.com/blackm0re/pyotp2289/tree/1.0.0) (2020-04-07) +# [1.0.0](https://codeberg.org/sgs/pyotp2289/releases/tag/v1.0.0) (2020-04-07) **Changes:** diff --git a/README.md b/README.md index 38fa9a7..ef100aa 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ It requires no additional libraries. The main reason for writing this library was the need to login into my FreeBSD servers using [opiepasswd](https://en.wikipedia.org/wiki/OPIE_Authentication_System). -*opiepasswd* has since been removed from FreeBSD since version 14. +*opiepasswd* has meanwhile been removed from FreeBSD ( >=14). I decided to license the library under the [Simplified BSD License / 2-clause BSD license](https://codeberg.org/sgs/pyotp2289/src/branch/master/LICENSE) and not under the @@ -120,17 +120,16 @@ RFC-2289 consists of interactions between them. # initialization digest (hash). The password 'This is a test.' will give you # the same results as in the following example. passwd_bytes = getpass.getpass().encode() # Fetch the password as bytes - generator = otp2289.generator.OTPGenerator(passwd_bytes, - 'TesT', - otp2289.OTP_ALGO_MD5) - digest = generator.generate_otp_hexdigest(500) + generator = otp2289.OTPGenerator(passwd_bytes, 'TesT', otp2289.OTP_ALGO_MD5) + response = generator.generate_otp_response(500) # otp2289.OTPResponse + digest = response.hexdigest # digest is now: 0x2b8d82b6ac14346c # the client sends it to the server # the server creates the first state. Note that step is decremented by 1: - state = otp2289.server.OTPState(digest, 499, 'TesT', otp2289.OTP_ALGO_MD5) + state = otp2289.OTPState(digest, 499, 'TesT', otp2289.OTP_ALGO_MD5) # the state can be stored in a OTPStore container: - store = otp2289.server.OTPStore() + store = otp2289.OTPStore() # key can be any str that can be used to reference the state (f.i username) store.add_state('myusername', state) # where key can be any str that can be # OTPStore is provided only for convenience as it is not part of RFC-2289. @@ -145,12 +144,16 @@ RFC-2289 consists of interactions between them. # created earlier. RFC-2289 defines two types of responses: # - hex (like '0x2b8d82b6ac14346c') - more suited for automation # - tokens consisting of 6 short words - better when responding manually - hex_response = generator.generate_otp_hexdigest(499) # '0x6323f96296a2526b' - token_response = generator.generate_otp_words(499) + # + # OTPResponse object can represent a response in both formats: + response = generator.generate_otp_response(499) + hex_response = response.hexdigest # '0x6323f96296a2526b' + token_response = response.words # token_response is now: 'CANT JAW BITS NU LO PUP' # a possible shortcut may be to use the challenge-string directly: - hex_response = generator.generate_otp_hexdigest_from_challenge(challenge) - token_response = generator.generate_otp_words_from_challenge(challenge) + response = generator.generate_otp_response_from_challenge(challenge) + hex_response = response.hexdigest + token_response = response.words # ... giving the same results. # once the response is received, the server validates it by yet again using @@ -158,9 +161,11 @@ RFC-2289 consists of interactions between them. result = state.response_validates(hex_response) # or result = state.response_validates(token_response) + # or using the OTPResponse object directly + result = state.response_validates(response) # result should be True if the response matches the state, False if not # in case of invalid response or response checksum doesn't match, a - # otp2289.server.OTPInvalidResponse exception is raised. + # otp2289.OTPInvalidResponse exception is raised. # once the state has successfully validated the corresponding response, # the state **must never be used again** and a state corresponding to the @@ -170,7 +175,7 @@ RFC-2289 consists of interactions between them. # the next authentication attempt... challenge = state.challenge_string # challenge is now 'otp-md5 498 TesT ' # ... and on the client side... - hex_response = generator.generate_otp_hexdigest_from_challenge(challenge) + response = generator.generate_otp_response_from_challenge(challenge) # etc. etc... ``` diff --git a/src/otp2289/__init__.py b/src/otp2289/__init__.py index 8f8cf24..da65d9e 100644 --- a/src/otp2289/__init__.py +++ b/src/otp2289/__init__.py @@ -31,6 +31,7 @@ from .generator import ( OTPGenerator, OTPGeneratorError, OTPResponse, + OTPResponseError, ) from .server import ( OTPInvalidResponseError, @@ -41,7 +42,7 @@ from .server import ( ) __author__ = 'Simeon Simeonov' -__version__ = '2.0.0a' +__version__ = '2.0.0' __license__ = 'BSD 2-Clause' @@ -63,6 +64,7 @@ __all__ = [ 'OTPGeneratorError', 'OTPInvalidResponseError', 'OTPResponse', + 'OTPResponseError', 'OTPState', 'OTPStateError', 'OTPStore', diff --git a/src/otp2289/__main__.py b/src/otp2289/__main__.py index 0fdcc52..c5d28b6 100644 --- a/src/otp2289/__main__.py +++ b/src/otp2289/__main__.py @@ -51,6 +51,56 @@ def eprint( print(*value, sep=sep, end=end, file=sys.stderr, flush=True) +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.OTPChallengeError: If the challenge is invalid + + :raises otp2289.OTPGeneratorError: If generator parameters are wrong + + :raises otp2289.OTPResponseError: If tokens or hex can not be generated + + :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.OTPGenerator( + args.password.encode(), args.seed, args.hash_algo + ) + if args.challenge_string: + return ( + header + + generator.generate_otp_response_from_challenge( + args.challenge_string + ).hexdigest + ) + return header + generator.generate_otp_response(args.step).hexdigest + + def generate_otp_response(args: argparse.Namespace) -> str: """ Generates a response based on the parameters sent from the parser @@ -62,20 +112,22 @@ def generate_otp_response(args: argparse.Namespace) -> str: :raises otp2289.OTPGeneratorError: If generator parameters are wrong + :raises otp2289.OTPResponseError: If tokens or hex can not be generated + :return: The response string :rtype: str """ - generator = otp2289.generator.OTPGenerator( + generator = otp2289.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 + response = generator.generate_otp_response_from_challenge( + args.challange_string ) + if args.output_format == 'token': + return response.words + return response.hexdigest + # regular parameters header = '' if not args.quiet: @@ -83,9 +135,12 @@ def generate_otp_response(args: argparse.Namespace) -> str: f'Seed: {args.seed}, Step: {args.step}, ' f'Hash: {args.hash_algo}{os.linesep}' ) + + response = generator.generate_otp_response(args.step) + if args.output_format == 'token': - return header + generator.generate_otp_words(args.step) - return header + generator.generate_otp_hexdigest(args.step) + return header + response.words + return header + response.hexdigest def generate_otp_range(args: argparse.Namespace) -> str: @@ -99,20 +154,24 @@ def generate_otp_range(args: argparse.Namespace) -> str: :raises otp2289.OTPGeneratorError: If generator parameters are wrong + :raises otp2289.OTPResponseError: If tokens or hex can not be generated + :return: The responses string :rtype: str """ - generator = otp2289.generator.OTPGenerator( + generator = otp2289.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) + response = generator.generate_otp_response(args.step) + if args.output_format == 'token': + response_str = response.words + else: + response_str = response.hexdigest + return f'{args.step}: ' + response_str + args.range = min(args.range, args.step + 1) # any need for quiet? @@ -122,9 +181,19 @@ def generate_otp_range(args: argparse.Namespace) -> str: f'Seed: {args.seed}, Step: {args.step}, ' f'Hash: {args.hash_algo}, Range: {args.range}{os.linesep}' ) + + if args.output_format == 'token': + return header + os.linesep.join( + [ + f'{step}: ' + generator.generate_otp_response(step).words + for step in range(args.step, args.step - args.range, -1) + ] + ) + + # assume hex return header + os.linesep.join( [ - f'{step}: ' + method(step) + f'{step}: ' + generator.generate_otp_response(step).hexdigest for step in range(args.step, args.step - args.range, -1) ] ) @@ -172,50 +241,6 @@ def get_password(args: argparse.Namespace) -> str: 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.OTPChallengeError: If the challenge is invalid - - :raises otp2289.OTPGeneratorError: 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(inargs: list[str] | None = None) -> None: """the main entry point""" parser = argparse.ArgumentParser( @@ -278,8 +303,8 @@ def main(inargs: list[str] | None = None) -> None: parser.add_argument( '-a', '--hash-algorithm', - metavar='', type=str, + choices=['md5', 'sha1'], dest='hash_algo', default='md5', help='The hash algorithm to use. Possible values: md5 (default), sha1', @@ -296,8 +321,8 @@ def main(inargs: list[str] | None = None) -> None: parser.add_argument( '-f', '--output-format', - metavar='', type=str, + choices=['hex', 'token'], dest='output_format', default='hex', help='The output format to use. Possible values: hex (default), token', @@ -368,9 +393,9 @@ def main(inargs: list[str] | None = None) -> None: if args.generate_otp_response: print(generate_otp_response(args)) sys.exit(0) - except otp2289.generator.OTPGeneratorError as exp: + except otp2289.OTPGeneratorError as exp: eprint(f'GeneratorException: {exp}') - except otp2289.generator.OTPChallengeError as exp: + except otp2289.OTPChallengeError as exp: eprint(f'ChallengeException: {exp}') except Exception as exp: eprint(f'Unknown error: {exp}') diff --git a/src/otp2289/generator.py b/src/otp2289/generator.py index 32123b4..17024a2 100644 --- a/src/otp2289/generator.py +++ b/src/otp2289/generator.py @@ -24,16 +24,21 @@ # THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. """A pure Python implementation of the RFC-2289 OTP generator""" +from __future__ import annotations + import hashlib import string import typing -from collections.abc import Iterator + +if typing.TYPE_CHECKING: + from collections.abc import Iterator OTP_ALGO_MD5: typing.Final[int] = 1 OTP_ALGO_SHA1: typing.Final[int] = 2 # useful constants OTP2289_BITSTREAM_SIZE: typing.Final[int] = 64 +OTP2289_HEX_DIGEST_SIZE: typing.Final[int] = 16 OTP2289_MAX_SEED_LENGTH: typing.Final[int] = 16 OTP2289_MIN_PASSWORD_LENGTH: typing.Final[int] = 10 OTP2289_SHA1_DIGEST_SIZE: typing.Final[int] = 20 @@ -2094,12 +2099,16 @@ RFC1760_TOKENS = [ _ALGO_DICT = {OTP_ALGO_MD5: 'md5', OTP_ALGO_SHA1: 'sha1'} +class OTPChallengeError(Exception): + """OTPChallengeError class""" + + class OTPGeneratorError(Exception): """OTPGeneratorError class""" -class OTPChallengeError(Exception): - """OTPChallengeError class""" +class OTPResponseError(Exception): + """OTPResponseError class""" class OTPResponse: @@ -2120,10 +2129,24 @@ class OTPResponse: """bytes representation of the object""" return self._response_bytes + def __eq__(self, value: object, /) -> bool: + """definition for type equality""" + if not isinstance(value, OTPResponse): + return False + + return self._response_bytes == value.response_bytes + def __hash__(self) -> int: """Uses the hash value of _response_bytes""" return hash(self._response_bytes) + def __repr__(self) -> str: + """repr implementation""" + return ( + f'{self.__class__} at {id(self)} ' + f'(response_bytes={self._response_bytes!r})' + ) + @property def hexdigest(self) -> str: """Hexdigest representation of the OTP response""" @@ -2139,6 +2162,59 @@ class OTPResponse: """Tokens representation of the OTP response""" return self._words + @classmethod + def from_hex(cls, ot_hex: str) -> OTPResponse: + """ + Generates instance from the provided hexidigest with or without + leading 0x as specified by RFC-2289. + + :param ot_hex: The one-time hex to validate + :type ot_hex: str + + :raises otp2289.OTPResponseError: When the ot_hex is invalid + + :return: A new OTPResponse object + :rtype: otp2289.OTPResponse + """ + return cls(OTPResponse.hex_to_bytes(ot_hex)) + + @classmethod + def from_tokens(cls, tokens_str: str) -> OTPResponse: + """ + Generates instance from a 6 words token as specified by RFC-2289. + + :param tokens_str: String representing 6 words tokens + :type tokens_str: str + + :raises otp2289.OTPResponseError: When the tokens_str is invalid + + :return: A new OTPResponse object + :rtype: otp2289.OTPResponse + """ + return cls(OTPResponse.tokens_to_bytes(tokens_str)) + + @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 OTPResponseError('bit_stream must be of type str') + if len(bit_stream) != OTP2289_BITSTREAM_SIZE: + raise OTPResponseError( + f'bit_stream must be of size {OTP2289_BITSTREAM_SIZE}' + ) + value = 0 + for pair in zip(bit_stream[::2], bit_stream[1::2], strict=True): + value += int(''.join(pair), 2) + return value + @staticmethod def bytes_to_tokens(hash_bytes: bytes) -> str: """ @@ -2151,7 +2227,7 @@ class OTPResponse: :rtype: str """ bit_stream = ''.join([f'{byte:0>8b}' for byte in hash_bytes]) - bit_pair_sum = OTPGenerator.bit_pair_sum(bit_stream) + bit_pair_sum = OTPResponse.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)]) @@ -2165,6 +2241,81 @@ class OTPResponse: ) return ' '.join(tokens) + @staticmethod + def hex_to_bytes(ot_hex: str) -> bytes: + """ + Returns bytes from the provided hexidigest. + + :param ot_hex: The one-time hex to validate + :type ot_hex: str + + :raises otp2289.OTPResponseError: If hex does not validate + + :return: The validated hex (without leading 0x) converted to bytes + :rtype: bytes + """ + if not isinstance(ot_hex, str): + raise OTPResponseError('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) != OTP2289_HEX_DIGEST_SIZE: + raise OTPResponseError( + f'The length of the hex should be {OTP2289_HEX_DIGEST_SIZE} ' + '(representing 64 bits digest)' + ) + try: + return bytes.fromhex(ot_hex) + except ValueError: + raise OTPResponseError('Invalid OT-hex') from None + + @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.OTPResponseError: When the tokens_str is invalid + + :return: 6 words tokens + :rtype: bytes + """ + if not isinstance(tokens_str, str): + raise OTPResponseError('tokens must be a str') + tokens = tokens_str.split() + if len(tokens) != OTP2289_TOKENS_COUNT: + raise OTPResponseError( + f'Tokens-string does not contain {OTP2289_TOKENS_COUNT} tokens' + ) + token_ints = [] + try: + token_ints = [ + RFC1760_TOKENS.index(token.upper()) for token in tokens + ] + except ValueError: + raise OTPResponseError( + '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'{OTPResponse.bit_pair_sum(bit_stream[:64]):0>8b}'[-2:] + != bit_stream[-2:] + ): + raise OTPResponseError('Invalid bit checksum') + return int(bit_stream[:64], 2).to_bytes(8, 'big') + class OTPGenerator: """OTPGenerator class""" @@ -2210,28 +2361,6 @@ class OTPGenerator: 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 OTPGeneratorError('bit_stream must be of type str') - if len(bit_stream) != OTP2289_BITSTREAM_SIZE: - raise OTPGeneratorError( - f'bit_stream must be of size {OTP2289_BITSTREAM_SIZE}' - ) - value = 0 - for pair in zip(bit_stream[::2], bit_stream[1::2], strict=True): - value += int(''.join(pair), 2) - return value - @staticmethod def get_tokens_from_challenge(challenge: str) -> tuple[str, str, int]: """ @@ -2334,54 +2463,6 @@ class OTPGenerator: ) 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.OTPGeneratorError: When the tokens_str is invalid - - :return: 6 words tokens - :rtype: bytes - """ - if not isinstance(tokens_str, str): - raise OTPGeneratorError('tokens must be a str') - tokens = tokens_str.split() - if len(tokens) != OTP2289_TOKENS_COUNT: - raise OTPGeneratorError( - f'Tokens-string does not contain {OTP2289_SHA1_DIGEST_SIZE} ' - 'tokens' - ) - token_ints = [] - try: - token_ints = [ - RFC1760_TOKENS.index(token.upper()) for token in tokens - ] - except ValueError: - raise OTPGeneratorError( - '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 OTPGeneratorError('Invalid bit checksum') - return int(bit_stream[:64], 2).to_bytes(8, 'big') - @staticmethod def validate_hash_algo(hash_algo: int | str) -> str: """ @@ -2456,56 +2537,24 @@ class OTPGenerator: raise OTPGeneratorError('Step value MUST be >= 0') return step - def generate_otp_hexdigest(self, step: int) -> str: + def generate_otp_response(self, step: int) -> OTPResponse: """ - Generates the OTP hexdigest for the given step. + Generates OTPResponse instance for the given step :param step: The step to generate OTP for :type step: int - :return: Hexdigest for the given step - :rtype: str + :return: OTPResponse instance for the given step + :rtype: OTPResponse """ - response = OTPResponse(self._generate_otp_bytes(step)) - return response.hexdigest + return OTPResponse(self._generate_otp_bytes(step)) - def generate_otp_hexdigest_from_challenge(self, challenge: str) -> str: + def generate_otp_response_from_challenge( + self, challenge: str + ) -> OTPResponse: """ - 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 - """ - response = OTPResponse(self._generate_otp_bytes(step)) - return response.words - - def generate_otp_words_from_challenge(self, challenge: str) -> str: - """ - Same as generate_otp_words, but it generates words from a challenge. + Same as generate_otp_response, + but it generates OTPResponse from a challenge. RFC-2289 states: The challenge MUST be in a standard syntax so @@ -2522,35 +2571,14 @@ class OTPGenerator: 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) + return self.generate_otp_response(step) - def hexdigest_range( + def otp_response_range( self, start: int = 499, stop: int = 0 - ) -> Iterator[str]: - """ - 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 OTPGeneratorError('Step value MUST be an int') - if start < stop: - raise OTPGeneratorError('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) -> Iterator[str]: + ) -> Iterator[OTPResponse]: """ - Returns an iterator that providing the words corresponding to steps - from `start` to and including `stop`. + Returns an iterator that is providing OTPResponse instances + corresponding to steps from `start` to and including `stop` :param start: The start of the range (default: 499) :type start: int @@ -2566,7 +2594,7 @@ class OTPGenerator: if start < stop: raise OTPGeneratorError('Start value can not be lower than stop') for step in range(start, stop - 1, -1): - yield self.generate_otp_words(step) + yield self.generate_otp_response(step) def _generate_otp_bytes(self, step: int) -> bytes: """ diff --git a/src/otp2289/server.py b/src/otp2289/server.py index 99ee460..62e6205 100644 --- a/src/otp2289/server.py +++ b/src/otp2289/server.py @@ -32,9 +32,13 @@ import typing if typing.TYPE_CHECKING: from collections.abc import Iterator -from .generator import OTP_ALGO_MD5, OTPGenerator, OTPGeneratorError - -OTP2289_HEX_DIGEST_SIZE: typing.Final[int] = 16 +from .generator import ( + OTP_ALGO_MD5, + OTPGenerator, + OTPGeneratorError, + OTPResponse, + OTPResponseError, +) class OTPStateError(Exception): @@ -89,12 +93,15 @@ class OTPState: self._seed = OTPGenerator.validate_seed(seed) self._hash_algo = OTPGenerator.validate_hash_algo(hash_algo) self._step = OTPGenerator.validate_step(current_step) - except OTPGeneratorError as exp: - raise OTPStateError(exp.args[0]) from exp + except OTPGeneratorError as err: + raise OTPStateError(err.args[0]) from err self._current_digest = None if ot_hex is not None: - self._current_digest = self.validate_hex(ot_hex) + try: + self._current_digest = OTPResponse.hex_to_bytes(ot_hex) + except OTPResponseError as err: + raise OTPStateError(err.args[0]) from err self._new_digest_hex = None # set upon a successful validation def __repr__(self) -> str: @@ -154,70 +161,43 @@ class OTPState: :type dict_obj: dict :return: A new OTPState object - :rtype: otp2289.OTPStore + :rtype: otp2289.OTPState """ return cls(**dict_obj) @staticmethod - def response_to_bytes(response: str) -> bytes: + def response_string_to_otp_response(response_str: str) -> OTPResponse: """ 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 OTPInvalidResponseError is raised - It is up to the caller to run another iteration and compare the result - to an existing digest in this state. + The method first checks if the response string is a token and tries to + convert it to a OTPResponse instance. If that fails, the method assumes + that response string is a hex. If neither of those attempts succeeds + OTPInvalidResponseError 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 + :param response_str: The response string to this state (its challenge) + :type response_str: str :raises otp2289.OTPInvalidResponseError: If the response is corrupt/illegal, but not if it simply does not validate - :return: The bytes representation of response (if any) - :rtype: bytes + :return: OTPResponse instance + :rtype: otp2289.OTPResponse """ try: - return OTPGenerator.tokens_to_bytes(response) - except OTPGeneratorError: + return OTPResponse.from_tokens(response_str) + except OTPResponseError: # now assume hex... try: - return OTPState.validate_hex(response) - except OTPStateError: + return OTPResponse.from_hex(response_str) + except OTPResponseError: raise OTPInvalidResponseError( '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.OTPStateError: If hex does not validate - - :return: The validated hex (without leading 0x) converted to bytes - :rtype: bytes - """ - if not isinstance(ot_hex, str): - raise OTPStateError('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) != OTP2289_HEX_DIGEST_SIZE: - raise OTPStateError( - f'The length of the hex should be {OTP2289_HEX_DIGEST_SIZE} ' - '(representing 64 bits digest)' - ) - try: - return bytes.fromhex(ot_hex) - except ValueError: - raise OTPStateError('Invalid OT-hex') from None - def get_next_state(self) -> OTPState | None: """ Returns the next state for a validated OTPState. @@ -235,26 +215,38 @@ class OTPState: ) def response_validates( - self, response: str, *, store_valid_response: bool = True + self, response: str | OTPResponse, *, store_valid_response: bool = True ) -> bool: """ Validates the incoming response as specified by RFC-2289. :param response: The response to this state (its challenge) - :type response: str + :type response: str or OTPResponse :param store_valid_response: Should a valid response be stored :type store_valid_response: bool - :raises otp2289.OTPInvalidResponseError: If the response does not match - this state + :raises otp2289.OTPInvalidResponseError: If the response is corrupt + or invalid :return: Returns True if response validates, False otherwise :rtype: bool """ - # self.response_to_bytes raises OTPInvalidResponseError in case - # response is corrupt or in a wrong format - response_bytes = self.response_to_bytes(response) + # self.response_string_to_otp_response raises OTPInvalidResponseError + # in case response is corrupt or in a wrong format + if not isinstance(response, (str, OTPResponse)): + raise OTPInvalidResponseError( + 'response must be of type str or OTPResponse' + ) + + if isinstance(response, str): + response_bytes = self.response_string_to_otp_response( + response + ).response_bytes + else: + # assume OTPResponse + response_bytes = response.response_bytes + if self._hash_algo == 'md5': digest = hashlib.md5(response_bytes).digest() if ( diff --git a/tests/test_generator.py b/tests/test_generator.py index 08947e5..68d3bfe 100644 --- a/tests/test_generator.py +++ b/tests/test_generator.py @@ -35,26 +35,26 @@ def test_caller_exceptions() -> None: b'This is a test.', 'TeSt', otp2289.OTP_ALGO_MD5 ) with pytest.raises(otp2289.OTPGeneratorError) as exc_info: - gen.generate_otp_words('3') # ty: ignore[invalid-argument-type] + gen.generate_otp_response('3') # ty: ignore[invalid-argument-type] assert exc_info.type is otp2289.OTPGeneratorError assert exc_info.value.args[0] == 'Step value MUST be an int' with pytest.raises(otp2289.OTPGeneratorError) as exc_info: - gen.generate_otp_hexdigest(-1) + gen.generate_otp_response(-1) assert exc_info.type is otp2289.OTPGeneratorError assert exc_info.value.args[0] == 'Step value MUST be >= 0' with pytest.raises(otp2289.OTPChallengeError) as exc_info: - gen.generate_otp_hexdigest_from_challenge( + gen.generate_otp_response_from_challenge( b'md5 fbd TeSt' # ty: ignore[invalid-argument-type] ) assert exc_info.type is otp2289.OTPChallengeError assert exc_info.value.args[0] == 'Challenge must be str' with pytest.raises(otp2289.OTPChallengeError) as exc_info: - gen.generate_otp_hexdigest_from_challenge('md5 fbd TeSt') + gen.generate_otp_response_from_challenge('md5 fbd TeSt') assert exc_info.type is otp2289.OTPChallengeError assert exc_info.value.args[0] == 'Invalid challenge' - with pytest.raises(otp2289.generator.OTPChallengeError) as exc_info: - gen.generate_otp_hexdigest_from_challenge('otp-md5 fbd TeSt') - assert exc_info.type is otp2289.generator.OTPChallengeError + with pytest.raises(otp2289.OTPChallengeError) as exc_info: + gen.generate_otp_response_from_challenge('otp-md5 fbd TeSt') + assert exc_info.type is otp2289.OTPChallengeError assert exc_info.value.args[0] == 'Invalid challenge' @@ -102,9 +102,9 @@ def test_constructor_exceptions() -> None: assert exc_info.type is otp2289.OTPGeneratorError assert exc_info.value.args[0] == 'hash_algo must be an int or a str' # test the package structure as well - with pytest.raises(otp2289.generator.OTPGeneratorError) as exc_info: - otp2289.generator.OTPGenerator(b'This is a test.', 'TeSt', 'foo') - assert exc_info.type is otp2289.generator.OTPGeneratorError + with pytest.raises(otp2289.OTPGeneratorError) as exc_info: + otp2289.OTPGenerator(b'This is a test.', 'TeSt', 'foo') + assert exc_info.type is otp2289.OTPGeneratorError assert exc_info.value.args[0] == ( 'foo is not supported by this version of the hashlib module' ) @@ -133,59 +133,148 @@ def test_md5() -> None: gen = otp2289.OTPGenerator( b'This is a test.', 'TeSt', otp2289.OTP_ALGO_MD5 ) - res_words = gen.generate_otp_words(0) - res_hex = gen.generate_otp_hexdigest(0) - assert isinstance(res_words, str) - assert isinstance(res_hex, str) - assert res_hex == '0x9e876134d90499dd' - assert res_words == 'INCH SEA ANNE LONG AHEM TOUR' + response = gen.generate_otp_response(0) + assert isinstance(response, otp2289.OTPResponse) + assert isinstance(response.words, str) + assert isinstance(response.hexdigest, str) + assert response.hexdigest == '0x9e876134d90499dd' + assert response.words == 'INCH SEA ANNE LONG AHEM TOUR' + # step 1 - assert gen.generate_otp_hexdigest(1) == '0x7965e05436f5029f' - assert gen.generate_otp_words(1) == 'EASE OIL FUM CURE AWRY AVIS' - assert gen.generate_otp_hexdigest_from_challenge('otp-md5 1 TeSt') == ( - '0x7965e05436f5029f' + response = gen.generate_otp_response(1) + assert response.hexdigest == '0x7965e05436f5029f' + assert response.words == 'EASE OIL FUM CURE AWRY AVIS' + assert ( + gen.generate_otp_response_from_challenge('otp-md5 1 TeSt').hexdigest + == '0x7965e05436f5029f' ) - assert gen.generate_otp_words_from_challenge('otp-md5 1 TeSt') == ( - 'EASE OIL FUM CURE AWRY AVIS' + assert ( + gen.generate_otp_response_from_challenge('otp-md5 1 TeSt').words + == 'EASE OIL FUM CURE AWRY AVIS' ) + # step 99 - assert gen.generate_otp_hexdigest(99) == '0x50fe1962c4965880' - assert gen.generate_otp_words(99) == 'BAIL TUFT BITS GANG CHEF THY' - assert gen.generate_otp_hexdigest_from_challenge('otp-md5 99 TeSt') == ( - '0x50fe1962c4965880' + response = gen.generate_otp_response(99) + assert response.hexdigest == '0x50fe1962c4965880' + assert response.words == 'BAIL TUFT BITS GANG CHEF THY' + assert ( + gen.generate_otp_response_from_challenge('otp-md5 99 TeSt').hexdigest + == '0x50fe1962c4965880' ) - assert gen.generate_otp_words_from_challenge('otp-md5 99 TeSt') == ( - 'BAIL TUFT BITS GANG CHEF THY' + assert ( + gen.generate_otp_response_from_challenge('otp-md5 99 TeSt').words + == 'BAIL TUFT BITS GANG CHEF THY' ) + # iterator test - hexdigests = list(gen.hexdigest_range(105)) # testing the range itself - words = list(gen.words_range(99)) + hexdigests = list(gen.otp_response_range(105)) # testing the range itself + words = list(gen.otp_response_range(99)) hexdigests.reverse() words.reverse() - assert hexdigests[0] == '0x9e876134d90499dd' - assert hexdigests[1] == '0x7965e05436f5029f' - assert hexdigests[99] == '0x50fe1962c4965880' - assert words[0] == 'INCH SEA ANNE LONG AHEM TOUR' - assert words[1] == 'EASE OIL FUM CURE AWRY AVIS' - assert words[99] == 'BAIL TUFT BITS GANG CHEF THY' + assert hexdigests[0].hexdigest == '0x9e876134d90499dd' + assert hexdigests[1].hexdigest == '0x7965e05436f5029f' + assert hexdigests[99].hexdigest == '0x50fe1962c4965880' + assert words[0].words == 'INCH SEA ANNE LONG AHEM TOUR' + assert words[1].words == 'EASE OIL FUM CURE AWRY AVIS' + assert words[99].words == 'BAIL TUFT BITS GANG CHEF THY' + # pass='AbCdEfGhIjK', seed='alpha1' gen = otp2289.OTPGenerator(b'AbCdEfGhIjK', 'alpha1', otp2289.OTP_ALGO_MD5) - assert gen.generate_otp_hexdigest(0) == '0x87066dd9644bf206' - assert gen.generate_otp_words(0) == 'FULL PEW DOWN ONCE MORT ARC' - assert gen.generate_otp_hexdigest(1) == '0x7cd34c1040add14b' - assert gen.generate_otp_words(1) == 'FACT HOOF AT FIST SITE KENT' - assert gen.generate_otp_hexdigest(99) == '0x5aa37a81f212146c' - assert gen.generate_otp_words(99) == 'BODE HOP JAKE STOW JUT RAP' + response = gen.generate_otp_response(0) + assert response.hexdigest == '0x87066dd9644bf206' + assert response.words == 'FULL PEW DOWN ONCE MORT ARC' + response = gen.generate_otp_response(1) + assert response.hexdigest == '0x7cd34c1040add14b' + assert response.words == 'FACT HOOF AT FIST SITE KENT' + response = gen.generate_otp_response(99) + assert response.hexdigest == '0x5aa37a81f212146c' + assert response.words == 'BODE HOP JAKE STOW JUT RAP' + # pass="OTP's are good", seed='correct' gen = otp2289.OTPGenerator( b"OTP's are good", 'correct', otp2289.OTP_ALGO_MD5 ) - assert gen.generate_otp_hexdigest(0) == '0xf205753943de4cf9' - assert gen.generate_otp_words(0) == 'ULAN NEW ARMY FUSE SUIT EYED' - assert gen.generate_otp_hexdigest(1) == '0xddcdac956f234937' - assert gen.generate_otp_words(1) == 'SKIM CULT LOB SLAM POE HOWL' - assert gen.generate_otp_hexdigest(99) == '0xb203e28fa525be47' - assert gen.generate_otp_words(99) == 'LONG IVY JULY AJAR BOND LEE' + response = gen.generate_otp_response(0) + assert response.hexdigest == '0xf205753943de4cf9' + assert response.words == 'ULAN NEW ARMY FUSE SUIT EYED' + response = gen.generate_otp_response(1) + assert response.hexdigest == '0xddcdac956f234937' + assert response.words == 'SKIM CULT LOB SLAM POE HOWL' + response = gen.generate_otp_response(99) + assert response.hexdigest == '0xb203e28fa525be47' + assert response.words == 'LONG IVY JULY AJAR BOND LEE' + + +def test_otp_response() -> None: + """Tests OTPResponse""" + gen = otp2289.OTPGenerator( + b'This is a test.', 'TeSt', otp2289.OTP_ALGO_MD5 + ) + response = gen.generate_otp_response(0) + assert response.response_bytes == bytes(response) + + # From 'RFC-2289 Appendix C - OTP Verification Examples' + # hexdigest: 9e876134d90499dd + # words: INCH SEA ANNE LONG AHEM TOUR + response_from_tokens = otp2289.OTPResponse.from_tokens( + 'INCH SEA ANNE LONG AHEM TOUR' + ) + response_from_hex1 = otp2289.OTPResponse.from_hex('9e876134d90499dd') + response_from_hex2 = otp2289.OTPResponse.from_hex('0x9e876134d90499dd') + assert ( + response_from_tokens.response_bytes + == response_from_hex1.response_bytes + ) + assert ( + response_from_tokens.response_bytes + == response_from_hex2.response_bytes + ) + + # same tests, only using __eq__ + assert response_from_tokens == response_from_hex1 + assert response_from_tokens == response_from_hex2 + + +def test_otp_response_exceptions() -> None: + """Tests OTPResponse exceptions""" + with pytest.raises(otp2289.OTPResponseError) as exc_info: + otp2289.OTPResponse.from_hex( + b'9e876134d90499dd' # ty: ignore[invalid-argument-type] + ) + assert exc_info.type is otp2289.OTPResponseError + assert exc_info.value.args[0] == 'OT-hex must be a str' + with pytest.raises(otp2289.OTPResponseError) as exc_info: + otp2289.OTPResponse.from_hex('9e876134d90499d') + assert exc_info.type is otp2289.OTPResponseError + assert exc_info.value.args[0] == ( + 'The length of the hex should be 16 (representing 64 bits digest)' + ) + with pytest.raises(otp2289.OTPResponseError) as exc_info: + otp2289.OTPResponse.from_hex('9e876134d90499dg') + assert exc_info.type is otp2289.OTPResponseError + assert exc_info.value.args[0] == 'Invalid OT-hex' + + # .from_tokens + with pytest.raises(otp2289.OTPResponseError) as exc_info: + otp2289.OTPResponse.from_tokens( + b'INCH SEA ANNE LONG AHEM TOUR' # ty: ignore[invalid-argument-type] + ) + assert exc_info.type is otp2289.OTPResponseError + assert exc_info.value.args[0] == 'tokens must be a str' + with pytest.raises(otp2289.OTPResponseError) as exc_info: + otp2289.OTPResponse.from_tokens('INCH SEA ANNE LONG AHEM') + assert exc_info.type is otp2289.OTPResponseError + assert exc_info.value.args[0] == 'Tokens-string does not contain 6 tokens' + with pytest.raises(otp2289.OTPResponseError) as exc_info: + otp2289.OTPResponse.from_tokens('INCH SEA ANNE LONG AHEM SIMEON') + assert exc_info.type is otp2289.OTPResponseError + assert exc_info.value.args[0] == ( + 'One or more words not present in RFC1760' + ) + with pytest.raises(otp2289.OTPResponseError) as exc_info: + otp2289.OTPResponse.from_tokens('INCH SEA ANNE LONG AHEM LEE') + assert exc_info.type is otp2289.OTPResponseError + assert exc_info.value.args[0] == 'Invalid bit checksum' def test_sha1() -> None: @@ -198,54 +287,71 @@ def test_sha1() -> None: gen = otp2289.OTPGenerator( b'This is a test.', 'TeSt', otp2289.OTP_ALGO_SHA1 ) - res_hex = gen.generate_otp_hexdigest(step=0) - res_words = gen.generate_otp_words(step=0) - assert isinstance(res_words, str) - assert isinstance(res_hex, str) - assert res_hex == '0xbb9e6ae1979d8ff4' - assert res_words == 'MILT VARY MAST OK SEES WENT' - assert gen.generate_otp_hexdigest(1) == '0x63d936639734385b' - assert gen.generate_otp_words(1) == 'CART OTTO HIVE ODE VAT NUT' - assert gen.generate_otp_hexdigest_from_challenge('otp-sha1 1 TeSt') == ( - '0x63d936639734385b' + response = gen.generate_otp_response(step=0) + assert isinstance(response, otp2289.OTPResponse) + assert isinstance(response.words, str) + assert isinstance(response.hexdigest, str) + assert response.hexdigest == '0xbb9e6ae1979d8ff4' + assert response.words == 'MILT VARY MAST OK SEES WENT' + response = gen.generate_otp_response(1) + assert response.hexdigest == '0x63d936639734385b' + assert response.words == 'CART OTTO HIVE ODE VAT NUT' + assert ( + gen.generate_otp_response_from_challenge('otp-sha1 1 TeSt').hexdigest + == '0x63d936639734385b' ) - assert gen.generate_otp_words_from_challenge('otp-sha1 1 TeSt') == ( - 'CART OTTO HIVE ODE VAT NUT' + assert ( + gen.generate_otp_response_from_challenge('otp-sha1 1 TeSt').words + == 'CART OTTO HIVE ODE VAT NUT' ) - assert gen.generate_otp_hexdigest(99) == '0x87fec7768b73ccf9' - assert gen.generate_otp_words(99) == 'GAFF WAIT SKID GIG SKY EYED' - assert gen.generate_otp_hexdigest_from_challenge('otp-sha1 99 TeSt') == ( - '0x87fec7768b73ccf9' + response = gen.generate_otp_response(99) + assert response.hexdigest == '0x87fec7768b73ccf9' + assert response.words == 'GAFF WAIT SKID GIG SKY EYED' + assert ( + gen.generate_otp_response_from_challenge( + 'otp-sha1 99 TeSt' + ).hexdigest + == '0x87fec7768b73ccf9' ) - assert gen.generate_otp_words_from_challenge('otp-sha1 99 TeSt') == ( - 'GAFF WAIT SKID GIG SKY EYED' + assert ( + gen.generate_otp_response_from_challenge('otp-sha1 99 TeSt').words + == 'GAFF WAIT SKID GIG SKY EYED' ) + # iterator test - hexdigests = list(gen.hexdigest_range(105)) - words = list(gen.words_range(99)) + hexdigests = list(gen.otp_response_range(105)) + words = list(gen.otp_response_range(99)) hexdigests.reverse() words.reverse() - assert hexdigests[0] == '0xbb9e6ae1979d8ff4' - assert hexdigests[1] == '0x63d936639734385b' - assert hexdigests[99] == '0x87fec7768b73ccf9' - assert words[0] == 'MILT VARY MAST OK SEES WENT' - assert words[1] == 'CART OTTO HIVE ODE VAT NUT' - assert words[99] == 'GAFF WAIT SKID GIG SKY EYED' + assert hexdigests[0].hexdigest == '0xbb9e6ae1979d8ff4' + assert hexdigests[1].hexdigest == '0x63d936639734385b' + assert hexdigests[99].hexdigest == '0x87fec7768b73ccf9' + assert words[0].words == 'MILT VARY MAST OK SEES WENT' + assert words[1].words == 'CART OTTO HIVE ODE VAT NUT' + assert words[99].words == 'GAFF WAIT SKID GIG SKY EYED' + # pass='AbCdEfGhIjK', seed='alpha1' gen = otp2289.OTPGenerator(b'AbCdEfGhIjK', 'alpha1', otp2289.OTP_ALGO_SHA1) - assert gen.generate_otp_hexdigest(0) == '0xad85f658ebe383c9' - assert gen.generate_otp_words(0) == 'LEST OR HEEL SCOT ROB SUIT' - assert gen.generate_otp_hexdigest(1) == '0xd07ce229b5cf119b' - assert gen.generate_otp_words(1) == 'RITE TAKE GELD COST TUNE RECK' - assert gen.generate_otp_hexdigest(99) == '0x27bc71035aaf3dc6' - assert gen.generate_otp_words(99) == 'MAY STAR TIN LYON VEDA STAN' + response = gen.generate_otp_response(0) + assert response.hexdigest == '0xad85f658ebe383c9' + assert response.words == 'LEST OR HEEL SCOT ROB SUIT' + response = gen.generate_otp_response(1) + assert response.hexdigest == '0xd07ce229b5cf119b' + assert response.words == 'RITE TAKE GELD COST TUNE RECK' + response = gen.generate_otp_response(99) + assert response.hexdigest == '0x27bc71035aaf3dc6' + assert response.words == 'MAY STAR TIN LYON VEDA STAN' + # pass="OTP's are good", seed='correct' gen = otp2289.OTPGenerator( b"OTP's are good", 'correct', otp2289.OTP_ALGO_SHA1 ) - assert gen.generate_otp_hexdigest(0) == '0xd51f3e99bf8e6f0b' - assert gen.generate_otp_words(0) == 'RUST WELT KICK FELL TAIL FRAU' - assert gen.generate_otp_hexdigest(1) == '0x82aeb52d943774e4' - assert gen.generate_otp_words(1) == 'FLIT DOSE ALSO MEW DRUM DEFY' - assert gen.generate_otp_hexdigest(99) == '0x4f296a74fe1567ec' - assert gen.generate_otp_words(99) == 'AURA ALOE HURL WING BERG WAIT' + response = gen.generate_otp_response(0) + assert response.hexdigest == '0xd51f3e99bf8e6f0b' + assert response.words == 'RUST WELT KICK FELL TAIL FRAU' + response = gen.generate_otp_response(1) + assert response.hexdigest == '0x82aeb52d943774e4' + assert response.words == 'FLIT DOSE ALSO MEW DRUM DEFY' + response = gen.generate_otp_response(99) + assert response.hexdigest == '0x4f296a74fe1567ec' + assert response.words == 'AURA ALOE HURL WING BERG WAIT' diff --git a/tests/test_server.py b/tests/test_server.py index 3099cf7..41a6aea 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -75,6 +75,8 @@ def test_state_validation_md5() -> None: assert state.validated is False assert state.response_validates('0x9e876134d90499dd') is True assert state.response_validates('INCH SEA ANNE LONG AHEM TOUR') is True + response = otp2289.OTPResponse(bytes.fromhex('9e876134d90499dd')) + assert state.response_validates(response) is True assert state.ot_hex == '7965e05436f5029f' assert state.validated is True @@ -87,6 +89,8 @@ def test_state_validation_sha1() -> None: assert state.validated is False assert state.response_validates('0xbb9e6ae1979d8ff4') is True assert state.response_validates('MILT VARY MAST OK SEES WENT') is True + response = otp2289.OTPResponse(bytes.fromhex('bb9e6ae1979d8ff4')) + assert state.response_validates(response) is True assert state.ot_hex == '63d936639734385b' assert state.validated is True diff --git a/tests/test_static.py b/tests/test_static.py index 589fc17..7268e84 100644 --- a/tests/test_static.py +++ b/tests/test_static.py @@ -32,58 +32,58 @@ import otp2289 def test_bytes_and_tokens() -> None: """Tests the official hex and tokens defined in RFC2289""" assert bytes.fromhex('9e876134d90499dd') == ( - otp2289.OTPGenerator.tokens_to_bytes('INCH SEA ANNE LONG AHEM TOUR') + otp2289.OTPResponse.tokens_to_bytes('INCH SEA ANNE LONG AHEM TOUR') ) assert bytes.fromhex('7965e05436f5029f') == ( - otp2289.OTPGenerator.tokens_to_bytes('EASE OIL FUM CURE AWRY AVIS') + otp2289.OTPResponse.tokens_to_bytes('EASE OIL FUM CURE AWRY AVIS') ) assert bytes.fromhex('50fe1962c4965880') == ( - otp2289.OTPGenerator.tokens_to_bytes('BAIL TUFT BITS GANG CHEF THY') + otp2289.OTPResponse.tokens_to_bytes('BAIL TUFT BITS GANG CHEF THY') ) assert bytes.fromhex('87066dd9644bf206') == ( - otp2289.OTPGenerator.tokens_to_bytes('FULL PEW DOWN ONCE MORT ARC') + otp2289.OTPResponse.tokens_to_bytes('FULL PEW DOWN ONCE MORT ARC') ) assert bytes.fromhex('7cd34c1040add14b') == ( - otp2289.OTPGenerator.tokens_to_bytes('FACT HOOF AT FIST SITE KENT') + otp2289.OTPResponse.tokens_to_bytes('FACT HOOF AT FIST SITE KENT') ) assert bytes.fromhex('5aa37a81f212146c') == ( - otp2289.OTPGenerator.tokens_to_bytes('BODE HOP JAKE STOW JUT RAP') + otp2289.OTPResponse.tokens_to_bytes('BODE HOP JAKE STOW JUT RAP') ) assert bytes.fromhex('f205753943de4cf9') == ( - otp2289.OTPGenerator.tokens_to_bytes('ULAN NEW ARMY FUSE SUIT EYED') + otp2289.OTPResponse.tokens_to_bytes('ULAN NEW ARMY FUSE SUIT EYED') ) assert bytes.fromhex('ddcdac956f234937') == ( - otp2289.OTPGenerator.tokens_to_bytes('SKIM CULT LOB SLAM POE HOWL') + otp2289.OTPResponse.tokens_to_bytes('SKIM CULT LOB SLAM POE HOWL') ) assert bytes.fromhex('b203e28fa525be47') == ( - otp2289.OTPGenerator.tokens_to_bytes('LONG IVY JULY AJAR BOND LEE') + otp2289.OTPResponse.tokens_to_bytes('LONG IVY JULY AJAR BOND LEE') ) assert bytes.fromhex('bb9e6ae1979d8ff4') == ( - otp2289.OTPGenerator.tokens_to_bytes('MILT VARY MAST OK SEES WENT') + otp2289.OTPResponse.tokens_to_bytes('MILT VARY MAST OK SEES WENT') ) assert bytes.fromhex('63d936639734385b') == ( - otp2289.OTPGenerator.tokens_to_bytes('CART OTTO HIVE ODE VAT NUT') + otp2289.OTPResponse.tokens_to_bytes('CART OTTO HIVE ODE VAT NUT') ) assert bytes.fromhex('87fec7768b73ccf9') == ( - otp2289.OTPGenerator.tokens_to_bytes('GAFF WAIT SKID GIG SKY EYED') + otp2289.OTPResponse.tokens_to_bytes('GAFF WAIT SKID GIG SKY EYED') ) assert bytes.fromhex('ad85f658ebe383c9') == ( - otp2289.OTPGenerator.tokens_to_bytes('LEST OR HEEL SCOT ROB SUIT') + otp2289.OTPResponse.tokens_to_bytes('LEST OR HEEL SCOT ROB SUIT') ) assert bytes.fromhex('d07ce229b5cf119b') == ( - otp2289.OTPGenerator.tokens_to_bytes('RITE TAKE GELD COST TUNE RECK') + otp2289.OTPResponse.tokens_to_bytes('RITE TAKE GELD COST TUNE RECK') ) assert bytes.fromhex('27bc71035aaf3dc6') == ( - otp2289.OTPGenerator.tokens_to_bytes('MAY STAR TIN LYON VEDA STAN') + otp2289.OTPResponse.tokens_to_bytes('MAY STAR TIN LYON VEDA STAN') ) assert bytes.fromhex('d51f3e99bf8e6f0b') == ( - otp2289.OTPGenerator.tokens_to_bytes('RUST WELT KICK FELL TAIL FRAU') + otp2289.OTPResponse.tokens_to_bytes('RUST WELT KICK FELL TAIL FRAU') ) assert bytes.fromhex('82aeb52d943774e4') == ( - otp2289.OTPGenerator.tokens_to_bytes('FLIT DOSE ALSO MEW DRUM DEFY') + otp2289.OTPResponse.tokens_to_bytes('FLIT DOSE ALSO MEW DRUM DEFY') ) assert bytes.fromhex('4f296a74fe1567ec') == ( - otp2289.OTPGenerator.tokens_to_bytes('AURA ALOE HURL WING BERG WAIT') + otp2289.OTPResponse.tokens_to_bytes('AURA ALOE HURL WING BERG WAIT') ) @@ -92,4 +92,4 @@ def test_random_bytes() -> None: for _ in range(10): rnd_bytes = os.urandom(8) # 64 bits tokens = otp2289.OTPResponse.bytes_to_tokens(rnd_bytes) - assert rnd_bytes == otp2289.OTPGenerator.tokens_to_bytes(tokens) + assert rnd_bytes == otp2289.OTPResponse.tokens_to_bytes(tokens) -- cgit v1.3