From 751c74e7de1c78a151fdd8b76f220d8411a2108a Mon Sep 17 00:00:00 2001 From: Simeon Simeonov Date: Tue, 28 Apr 2026 14:27:05 +0200 Subject: Restructure the entire project, enforce linting and add support for type checkers --- .ruff.toml | 47 +++----- CHANGELOG.md | 9 ++ COPYING | 4 +- LICENSE | 4 +- README.md | 17 +-- pyproject.toml | 64 ++++++++++- setup.cfg | 42 ------- setup.py | 5 - sphinx_conf.py | 17 +-- src/otp2289/__init__.py | 10 +- src/otp2289/__main__.py | 22 ++-- src/otp2289/generator.py | 165 +++++++++++++++++++--------- src/otp2289/server.py | 88 ++++++++------- test/test_generator.py | 279 ----------------------------------------------- test/test_main.py | 264 -------------------------------------------- test/test_server.py | 127 --------------------- test/test_static.py | 96 ---------------- tests/test_generator.py | 251 ++++++++++++++++++++++++++++++++++++++++++ tests/test_main.py | 225 ++++++++++++++++++++++++++++++++++++++ tests/test_server.py | 121 ++++++++++++++++++++ tests/test_static.py | 95 ++++++++++++++++ 21 files changed, 980 insertions(+), 972 deletions(-) delete mode 100644 setup.cfg delete mode 100644 setup.py delete mode 100644 test/test_generator.py delete mode 100644 test/test_main.py delete mode 100644 test/test_server.py delete mode 100644 test/test_static.py create mode 100644 tests/test_generator.py create mode 100644 tests/test_main.py create mode 100644 tests/test_server.py create mode 100644 tests/test_static.py diff --git a/.ruff.toml b/.ruff.toml index c5617c1..a9fd42e 100644 --- a/.ruff.toml +++ b/.ruff.toml @@ -1,59 +1,40 @@ cache-dir = "~/.cache/ruff" indent-width = 4 line-length = 79 -target-version = "py312" +target-version = "py310" +namespace-packages = ["tests"] + [lint] -select = ["ALL", "D101", "D102", "D103", "D104"] +select = ["ALL"] ignore = [ - "ANN", "BLE001", "COM812", - "D", + "D2", + "D4", "EM101", # Exception must not use a string literal, assign to variable first "EM102", # Exception must not use an f-string literal, assign to variable first - "ERA001", - "FBT001", - "FBT002", "INP001", - "ISC001", - "N802", - "N806", - "PLR2004", - "PTH111", - "RUF012", - "RUF013", "S101", "S324", "T201", "TRY003", - "TRY300", - "UP020" ] +# ARG005 - Unused lambda argument: `args` +# BLE001 - Do not catch blind exception: `Exception` # D101 - Missing docstring in public class # D102 - Missing docstring in public method -# D200 - One-line docstring should fit on one line -# D203 - 1 blank line required before class docstring -# D205 - 1 blank line required between summary line and description -# D403 - First word of the first line should be capitalized: `str` -> `Str` -# FBT001 - Boolean-typed positional argument in function definition -# FBT002 - Boolean default positional argument in function definition +# E721 - Use `is` and `is not` for type comparisons, or `isinstance()` for isinstance checks +# ERA001 - Found commented-out code # INP001 - File `beinc_weechat.py` is part of an implicit namespace package. Add an `__init__.py` -# N802 - Function name `do_GET` should be lowercase -# N806 - Variable `POST_data` in function should be lowercase +# PT008 - Use `return_value=` instead of patching with `lambda` # PLR2004 - Magic value used in comparison, consider replacing `200` with a constant variable -# PTH111 - `os.path.expanduser()` should be replaced by `Path.expanduser()` -# PTH113 - `os.path.isfile()` should be replaced by `Path.is_file()` -# PTH123 - `open()` should be replaced by `Path.open()` -# RUF012 - Mutable class attributes should be annotated with `typing.ClassVar` -# RUF013 - PEP 484 prohibits implicit `Optional` # S101 - Use of `assert` detected # S324 - Probable use of insecure hash functions in `hashlib`: `md5` # T201 - `print` found # TRY003 - Avoid specifying long messages outside the exception class # TRY300 - Consider moving this statement to an `else` block -# UP020 - Use builtin `open` # Allow fix for all enabled rules (when `--fix`) is provided. fixable = ["ALL"] @@ -61,7 +42,8 @@ unfixable = [] # custom settings [lint.per-file-ignores] -"src/otp2289/__main__.py" = ["PTH113", "PTH123"] # "Readability counts" +"src/otp2289/generator.py" = ["ERA001"] # commented example code from RFC 2289 +# "tests/test_main.py" = ["ARG005", "E721", "PT008"] [format] @@ -93,6 +75,3 @@ docstring-code-line-length = "dynamic" [lint.flake8-quotes] inline-quotes = "single" - -[lint.isort] -split-on-trailing-comma = false diff --git a/CHANGELOG.md b/CHANGELOG.md index 90b2fd2..0b220de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## [2.0.0](https://github.com/blackm0re/pyotp2289/tree/2.0.0) (2025-01-03) + +[Full Changelog](https://github.com/blackm0re/pyotp2289/compare/1.2.1...2.0.0) + +**Changes:** + +- Rename *Exception to *Error and do some additional linting + + ## [1.2.1](https://github.com/blackm0re/pyotp2289/tree/1.2.1) (2023-01-08) [Full Changelog](https://github.com/blackm0re/pyotp2289/compare/1.2.0...1.2.1) diff --git a/COPYING b/COPYING index 43ef739..aba69ce 100644 --- a/COPYING +++ b/COPYING @@ -1,6 +1,6 @@ -SPDX-License-Identifier: BSD-2-Clause-FreeBSD +SPDX-License-Identifier: BSD-2-Clause -Copyright (c) 2020-2023, Simeon Simeonov +Copyright (c) 2020-2026, Simeon Simeonov All rights reserved. Redistribution and use in source and binary forms, with or without diff --git a/LICENSE b/LICENSE index 43ef739..aba69ce 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ -SPDX-License-Identifier: BSD-2-Clause-FreeBSD +SPDX-License-Identifier: BSD-2-Clause -Copyright (c) 2020-2023, Simeon Simeonov +Copyright (c) 2020-2026, Simeon Simeonov All rights reserved. Redistribution and use in source and binary forms, with or without diff --git a/README.md b/README.md index 7ef9344..38fa9a7 100644 --- a/README.md +++ b/README.md @@ -8,11 +8,12 @@ It requires no additional libraries. ## General The main reason for writing this library was the need to login into my -FreeBSD servers using [opiepasswd(1)](https://www.freebsd.org/cgi/man.cgi?query=opiepasswd&sektion=1&manpath=FreeBSD) -as [described in the FreeBSD Handbook](https://docs.freebsd.org/en/books/handbook/security/#one-time-passwords). +FreeBSD servers using [opiepasswd](https://en.wikipedia.org/wiki/OPIE_Authentication_System). + +*opiepasswd* has since been removed from FreeBSD since version 14. I decided to license the library under the -[Simplified BSD License / 2-clause BSD license](https://github.com/blackm0re/pyotp2289/blob/master/LICENSE) and not under the +[Simplified BSD License / 2-clause BSD license](https://codeberg.org/sgs/pyotp2289/src/branch/master/LICENSE) and not under the (L)GPL-3 as I usually do. I hope that somebody will find it useful. @@ -197,7 +198,7 @@ starting from (and including) 498. ## Support and contributing -*pyotp2289* is hosted on GitHub: https://github.com/blackm0re/pyotp2289 +*pyotp2289* is hosted on Codeberg: https://codeberg.org/sgs/pyotp2289 ## Author @@ -205,10 +206,10 @@ starting from (and including) 498. Simeon Simeonov - sgs @ LiberaChat -## [License](https://github.com/blackm0re/pyotp2289/blob/master/LICENSE) +## [License](https://codeberg.org/sgs/pyotp2289/src/branch/master/LICENSE) -Copyright (c) 2020-2023 Simeon Simeonov +Copyright (c) 2020-2026 Simeon Simeonov All rights reserved. -[Licensed](https://github.com/blackm0re/pyotp2289/blob/master/LICENSE) under the BSD 2-clause. -SPDX-License-Identifier: BSD-2-Clause-FreeBSD +[Licensed](https://codeberg.org/sgs/pyotp2289/src/branch/master/LICENSE) under the BSD 2-clause. +SPDX-License-Identifier: BSD-2-Clause diff --git a/pyproject.toml b/pyproject.toml index 272c00f..0c7cc65 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,17 +1,71 @@ [build-system] requires = [ - "setuptools>=51", + "setuptools >= 77.0.3", "wheel" ] build-backend = "setuptools.build_meta" +[project] +name = "pyotp2289" +description = "A pure Python implementation of 'A One-Time Password System'" +dynamic = ["version"] +license = "BSD-2-Clause" +authors = [{name = "Simeon Simeonov"}] +readme = "README.md" +requires-python = ">= 3.10" + +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "Intended Audience :: System Administrators", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: 3.15", + "Operating System :: OS Independent", + "Topic :: Security :: Cryptography", +] + + +[dependency-groups] +dev = [ + "pytest>=7", + "ruff>=0.15.0", + "ty>=0.0.31", +] + + +[project.scripts] +otp2289 = "otp2289.__main__:main" + + +[project.urls] +Homepage = "https://codeberg.org/sgs/pyotp2289" +Repository = "https://codeberg.org/sgs/pyotp2289" +Issues = "https://codeberg.org/sgs/pyotp2289/issues" +Changelog = "https://codeberg.org/sgs/pyotp2289/src/branch/master/CHANGELOG.md" + + [tool.pytest.ini_options] minversion = "7.0" -testpaths = [ - "test" -] +cache_dir = "~/.cache/pytest_cache" pythonpath = [ - "src" + "src" +] +testpaths = [ + "tests" ] + + +[tool.setuptools.dynamic] +version = {attr = "otp2289.__version__"} + + +[tool.ty.environment] +python-version = "3.10" +root = ["./src"] diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 2a54a66..0000000 --- a/setup.cfg +++ /dev/null @@ -1,42 +0,0 @@ -[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.7 - Programming Language :: Python :: 3.8 - Programming Language :: Python :: 3.9 - Programming Language :: Python :: 3.10 - Programming Language :: Python :: 3.11 - Programming Language :: Python :: 3.12 - 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.7 - -[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 a5d21d8..0000000 --- a/setup.py +++ /dev/null @@ -1,5 +0,0 @@ -# Legacy setup for some build / install systems - -from setuptools import setup - -setup() diff --git a/sphinx_conf.py b/sphinx_conf.py index 3bef39e..0e2d594 100644 --- a/sphinx_conf.py +++ b/sphinx_conf.py @@ -1,3 +1,4 @@ +"""pyotp2289 sphinx configuration""" # Configuration file for the Sphinx documentation builder. # # For the full list of built-in configuration values, see the documentation: @@ -7,20 +8,16 @@ # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information project = 'pyotp2289' -copyright = '2020-2023, Simeon Simeonov' +copyright = '2020-2026, Simeon Simeonov' # noqa: A001 author = 'Simeon Simeonov' -version = '1.2.1' -release = '1.2.1' +version = '2.0.0' +release = '2.0.0' # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration -extensions = [ - 'sphinx.ext.autodoc', - 'sphinx.ext.viewcode', - 'sphinx.ext.todo', -] +extensions = ['sphinx.ext.autodoc', 'sphinx.ext.viewcode', 'sphinx.ext.todo'] templates_path = ['_templates'] exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] @@ -32,8 +29,12 @@ language = 'en' html_theme = 'nature' html_static_path = ['_static'] +html_sidebars = {'**': ['globaltoc.html', 'searchbox.html']} # -- Options for todo extension ---------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/extensions/todo.html#configuration todo_include_todos = True + +# Generated by running: sphinx-apidoc with: +# -P -F -o html -H pyotp2289 -A "Simeon Simeonov" -V "2.0.0" src/otp2289 diff --git a/src/otp2289/__init__.py b/src/otp2289/__init__.py index c9e3c74..8f8cf24 100644 --- a/src/otp2289/__init__.py +++ b/src/otp2289/__init__.py @@ -1,6 +1,6 @@ -# SPDX-License-Identifier: BSD-2-Clause-FreeBSD +# SPDX-License-Identifier: BSD-2-Clause # -# Copyright (c) 2020-2025 Simeon Simeonov +# Copyright (c) 2020-2026 Simeon Simeonov # All rights reserved. # # Redistribution and use in source and binary forms, with or without @@ -30,6 +30,7 @@ from .generator import ( OTPChallengeError, OTPGenerator, OTPGeneratorError, + OTPResponse, ) from .server import ( OTPInvalidResponseError, @@ -40,11 +41,11 @@ from .server import ( ) __author__ = 'Simeon Simeonov' -__version__ = '1.2.2' +__version__ = '2.0.0a' __license__ = 'BSD 2-Clause' -def int_or_str(value): +def int_or_str(value: int | str) -> int | str: """Returns int value of value when possible""" try: return int(value) @@ -61,6 +62,7 @@ __all__ = [ 'OTPGenerator', 'OTPGeneratorError', 'OTPInvalidResponseError', + 'OTPResponse', 'OTPState', 'OTPStateError', 'OTPStore', diff --git a/src/otp2289/__main__.py b/src/otp2289/__main__.py index 9f1aab8..0fdcc52 100644 --- a/src/otp2289/__main__.py +++ b/src/otp2289/__main__.py @@ -1,6 +1,6 @@ -# SPDX-License-Identifier: BSD-2-Clause-FreeBSD +# SPDX-License-Identifier: BSD-2-Clause # -# Copyright (c) 2020-2025 Simeon Simeonov +# Copyright (c) 2020-2026 Simeon Simeonov # All rights reserved. # # Redistribution and use in source and binary forms, with or without @@ -36,6 +36,7 @@ import argparse import errno import getpass import os +import pathlib import secrets import string import sys @@ -43,9 +44,11 @@ import sys import otp2289 -def eprint(*arg, **kwargs): +def eprint( + *value: object, sep: str | None = ' ', end: str | None = '\n' +) -> None: """stdderr print wrapper""" - print(*arg, file=sys.stderr, flush=True, **kwargs) + print(*value, sep=sep, end=end, file=sys.stderr, flush=True) def generate_otp_response(args: argparse.Namespace) -> str: @@ -162,8 +165,8 @@ def get_password(args: argparse.Namespace) -> str: eprint('The passwords do not match') return password - if os.path.isfile(args.password): - with open(args.password, encoding='utf-8') as fp: + if pathlib.Path(args.password).is_file(): + with pathlib.Path(args.password).open(encoding='utf-8') as fp: return fp.readline().strip() return args.password @@ -213,7 +216,7 @@ def initiate_new_sequence(args: argparse.Namespace) -> str: return header + generator.generate_otp_hexdigest(args.step) -def main(args=None): +def main(inargs: list[str] | None = None) -> None: """the main entry point""" parser = argparse.ArgumentParser( prog=__package__, @@ -344,8 +347,10 @@ def main(args=None): version=f'%(prog)s {otp2289.__version__}', help='display program-version and exit', ) - args = parser.parse_args(args) + + args = parser.parse_args(inargs) # handle the password before everything else + try: args.password = get_password(args) except KeyboardInterrupt: @@ -354,6 +359,7 @@ def main(args=None): 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)) diff --git a/src/otp2289/generator.py b/src/otp2289/generator.py index c03d289..32123b4 100644 --- a/src/otp2289/generator.py +++ b/src/otp2289/generator.py @@ -1,6 +1,6 @@ -# SPDX-License-Identifier: BSD-2-Clause-FreeBSD +# SPDX-License-Identifier: BSD-2-Clause # -# Copyright (c) 2020-2025 Simeon Simeonov +# Copyright (c) 2020-2026 Simeon Simeonov # All rights reserved. # # Redistribution and use in source and binary forms, with or without @@ -24,12 +24,20 @@ # 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 +import typing +from collections.abc import Iterator -OTP_ALGO_MD5 = 1 -OTP_ALGO_SHA1 = 2 +OTP_ALGO_MD5: typing.Final[int] = 1 +OTP_ALGO_SHA1: typing.Final[int] = 2 + +# useful constants +OTP2289_BITSTREAM_SIZE: typing.Final[int] = 64 +OTP2289_MAX_SEED_LENGTH: typing.Final[int] = 16 +OTP2289_MIN_PASSWORD_LENGTH: typing.Final[int] = 10 +OTP2289_SHA1_DIGEST_SIZE: typing.Final[int] = 20 +OTP2289_TOKENS_COUNT: typing.Final[int] = 6 # the tokens are defined in https://tools.ietf.org/html/rfc2289 # RFC1760_TOKENS = [ @@ -2094,12 +2102,79 @@ class OTPChallengeError(Exception): """OTPChallengeError class""" +class OTPResponse: + """Encapsulates the functionality for a single OTP response""" + + def __init__(self, response_bytes: bytes) -> None: + """ + Constructs a single OTP response + + :param response_bytes: The response state + :type response_bytes: bytes + """ + self._response_bytes = response_bytes + self._hexdigest = '0x' + response_bytes.hex() + self._words = self.bytes_to_tokens(response_bytes) + + def __bytes__(self) -> bytes: + """bytes representation of the object""" + return self._response_bytes + + def __hash__(self) -> int: + """Uses the hash value of _response_bytes""" + return hash(self._response_bytes) + + @property + def hexdigest(self) -> str: + """Hexdigest representation of the OTP response""" + return self._hexdigest + + @property + def response_bytes(self) -> bytes: + """response_bytes read-only property""" + return self._response_bytes + + @property + def words(self) -> str: + """Tokens representation of the OTP response""" + return self._words + + @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) + + class OTPGenerator: """OTPGenerator class""" def __init__( - self, password: bytes, seed: str = '', hash_algo=OTP_ALGO_MD5 - ): + self, + password: bytes, + seed: str = '', + hash_algo: int | str = OTP_ALGO_MD5, + ) -> None: """ Constructs an OTPGenerator object with a given password and seed. @@ -2121,11 +2196,14 @@ class OTPGenerator: self._hash_algo = self.validate_hash_algo(hash_algo) if not isinstance(password, bytes): raise OTPGeneratorError('Password must be a byte-string') - if len(password) < 10: - raise OTPGeneratorError('Password must be longer than 10 bytes') + if len(password) < OTP2289_MIN_PASSWORD_LENGTH: + raise OTPGeneratorError( + f'Password must be longer than {OTP2289_MIN_PASSWORD_LENGTH} ' + 'bytes' + ) self._password = password - def __repr__(self): + def __repr__(self) -> str: """repr implementation""" return ( f'{self.__class__} at {id(self)} (seed={self._seed}, ' @@ -2145,41 +2223,17 @@ class OTPGenerator: """ if not isinstance(bit_stream, str): raise OTPGeneratorError('bit_stream must be of type str') - if len(bit_stream) != 64: - raise OTPGeneratorError('bit_stream must be of size 64') + 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 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: + def get_tokens_from_challenge(challenge: str) -> tuple[str, str, int]: """ Returns tokens (seed, hash_algo and step) from a challenge string. @@ -2218,9 +2272,10 @@ class OTPGenerator: """ if not isinstance(sha1_digest, bytes): raise OTPGeneratorError('sha1_digest must be of type bytes') - if len(sha1_digest) != 20: + if len(sha1_digest) != OTP2289_SHA1_DIGEST_SIZE: raise OTPGeneratorError( - 'sha1_digest must be 160 bits (20 bytes) long' + f'sha1_digest must be {OTP2289_SHA1_DIGEST_SIZE * 2} bits ' + f'({OTP2289_SHA1_DIGEST_SIZE} bytes) long' ) digested = list(5 * b'i') # 5 bytes (40 bits) result = list(8 * b'x') # 8 bytes (64 bits) @@ -2295,8 +2350,11 @@ class OTPGenerator: if not isinstance(tokens_str, str): raise OTPGeneratorError('tokens must be a str') tokens = tokens_str.split() - if len(tokens) != 6: - raise OTPGeneratorError('Tokens-string does not contain 6 tokens') + if len(tokens) != OTP2289_TOKENS_COUNT: + raise OTPGeneratorError( + f'Tokens-string does not contain {OTP2289_SHA1_DIGEST_SIZE} ' + 'tokens' + ) token_ints = [] try: token_ints = [ @@ -2325,7 +2383,7 @@ class OTPGenerator: return int(bit_stream[:64], 2).to_bytes(8, 'big') @staticmethod - def validate_hash_algo(hash_algo) -> str: + def validate_hash_algo(hash_algo: int | str) -> str: """ Validates the provided hash-algorithm. @@ -2342,7 +2400,7 @@ class OTPGenerator: raise OTPGeneratorError( 'hash_algo is not among the known algorithms' ) - hash_algo = _ALGO_DICT.get(hash_algo) + hash_algo = _ALGO_DICT[hash_algo] if not isinstance(hash_algo, str): raise OTPGeneratorError('hash_algo must be an int or a str') if hash_algo not in hashlib.algorithms_available: @@ -2367,9 +2425,10 @@ class OTPGenerator: """ if not isinstance(seed, str): raise OTPGeneratorError('Seed must be a string') - if not seed or len(seed) > 16: + if not seed or len(seed) > OTP2289_MAX_SEED_LENGTH: raise OTPGeneratorError( - 'The seed MUST be of 1 to 16 characters in length' + f'The seed MUST be of 1 to {OTP2289_MAX_SEED_LENGTH} ' + 'characters in length' ) for char in seed: if char not in string.ascii_letters + string.digits: @@ -2407,7 +2466,8 @@ class OTPGenerator: :return: Hexdigest for the given step :rtype: str """ - return '0x' + binascii.hexlify(self._generate_otp_bytes(step)).decode() + response = OTPResponse(self._generate_otp_bytes(step)) + return response.hexdigest def generate_otp_hexdigest_from_challenge(self, challenge: str) -> str: """ @@ -2440,7 +2500,8 @@ class OTPGenerator: :return: Six words (separated by single space) token for the given step :rtype: str """ - return self.bytes_to_tokens(self._generate_otp_bytes(step)) + response = OTPResponse(self._generate_otp_bytes(step)) + return response.words def generate_otp_words_from_challenge(self, challenge: str) -> str: """ @@ -2463,7 +2524,9 @@ class OTPGenerator: 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): + def hexdigest_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`. @@ -2484,7 +2547,7 @@ class OTPGenerator: for step in range(start, stop - 1, -1): yield self.generate_otp_hexdigest(step) - def words_range(self, start: int = 499, stop: int = 0): + def words_range(self, start: int = 499, stop: int = 0) -> Iterator[str]: """ Returns an iterator that providing the words corresponding to steps from `start` to and including `stop`. diff --git a/src/otp2289/server.py b/src/otp2289/server.py index e5ee0f2..99ee460 100644 --- a/src/otp2289/server.py +++ b/src/otp2289/server.py @@ -1,6 +1,6 @@ -# SPDX-License-Identifier: BSD-2-Clause-FreeBSD +# SPDX-License-Identifier: BSD-2-Clause # -# Copyright (c) 2020-2025 Simeon Simeonov +# Copyright (c) 2020-2026 Simeon Simeonov # All rights reserved. # # Redistribution and use in source and binary forms, with or without @@ -24,11 +24,18 @@ # THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. """A pure Python implementation of the RFC-2289 OTP server""" -import binascii +from __future__ import annotations + import hashlib +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 + class OTPStateError(Exception): """OTPStateError class""" @@ -52,8 +59,12 @@ class OTPState: """ def __init__( - self, ot_hex: str, current_step: int, seed: str, hash_algo=OTP_ALGO_MD5 - ): + self, + ot_hex: str | None, + current_step: int, + seed: str, + hash_algo: int | str = OTP_ALGO_MD5, + ) -> None: """ Constructs an OTPState object with the given arguments. @@ -79,13 +90,14 @@ class OTPState: 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 None + raise OTPStateError(exp.args[0]) from exp + 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): + def __repr__(self) -> str: """repr implementation""" return ( f'{self.__class__} at {id(self)} ' @@ -102,7 +114,7 @@ class OTPState: return f'otp-{self._hash_algo} {self._step} {self._seed} ' @property - def current_digest(self) -> bytes: + def current_digest(self) -> bytes | None: """current_digest-property""" return self._current_digest @@ -116,7 +128,7 @@ class OTPState: """ot_hex-property""" if self._current_digest is None: return '' - return binascii.hexlify(self._current_digest).decode() + return self._current_digest.hex() @property def seed(self) -> str: @@ -134,7 +146,7 @@ class OTPState: return bool(self._new_digest_hex) @classmethod - def from_dict(cls, dict_obj: dict): + def from_dict(cls, dict_obj: dict) -> OTPState: """ Returns an OTPState object from the dict-object @@ -196,17 +208,17 @@ class OTPState: if ot_hex.startswith('0x'): ot_hex = ot_hex[2:] ot_hex = ot_hex.strip().lower() - if len(ot_hex) != 16: + if len(ot_hex) != OTP2289_HEX_DIGEST_SIZE: raise OTPStateError( - 'The length of the hex should be 16 ' + f'The length of the hex should be {OTP2289_HEX_DIGEST_SIZE} ' '(representing 64 bits digest)' ) try: - return binascii.unhexlify(ot_hex) - except binascii.Error: + return bytes.fromhex(ot_hex) + except ValueError: raise OTPStateError('Invalid OT-hex') from None - def get_next_state(self): + def get_next_state(self) -> OTPState | None: """ Returns the next state for a validated OTPState. @@ -223,7 +235,7 @@ class OTPState: ) def response_validates( - self, response: str, store_valid_response: str = True + self, response: str, *, store_valid_response: bool = True ) -> bool: """ Validates the incoming response as specified by RFC-2289. @@ -251,9 +263,7 @@ class OTPState: == self._current_digest ): if store_valid_response: - self._new_digest_hex = binascii.hexlify( - response_bytes - ).decode() + self._new_digest_hex = response_bytes.hex() return True return False if self._hash_algo == 'sha1': @@ -266,9 +276,7 @@ class OTPState: == self._current_digest ): if store_valid_response: - self._new_digest_hex = binascii.hexlify( - response_bytes - ).decode() + self._new_digest_hex = response_bytes.hex() return True return False # this should not happen since the hash_algo is validated by the caller @@ -283,9 +291,11 @@ class OTPState: :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() + ot_hex = ( + self._current_digest.hex() + if self._current_digest is not None + else None + ) return { 'ot_hex': ot_hex, 'current_step': self._step, @@ -304,27 +314,27 @@ class OTPStore: The class could serve as a base class when implementing store backends. """ - def __init__(self, data=None): + def __init__(self, data: dict | None = None) -> None: """ Constructs an OTPStore object from data - :param data: The data object, defaults to None - :type data: object or None + :param data: The data dict, defaults to None + :type data: dict 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): + def __contains__(self, state: OTPState) -> bool: """membership test""" return state in self._states - def __iter__(self): + def __iter__(self) -> Iterator: """iterator for OTPStore""" return iter(self._data) - def __len__(self): + def __len__(self) -> int: """len() implementation""" return len(self._data) @@ -348,7 +358,7 @@ class OTPStore: """ return self._states - def add_state(self, key: str, state: OTPState): + def add_state(self, key: str, state: OTPState) -> None: """ Adds an OTPState object with a given key. @@ -367,11 +377,13 @@ class OTPStore: self._data[key] = state self._states[state] = key - def get(self, key, default=None): + def get( + self, key: str, default: OTPState | None = None + ) -> OTPState | None: """A wrapper for dict.get""" return self._data.get(key, default) - def items(self): + def items(self) -> typing.ItemsView: """A wrapper for dict.items""" return self._data.items() @@ -396,7 +408,7 @@ class OTPStore: return state def response_validates( - self, key: str, response: str, store_valid_response: bool = True + self, key: str, response: str, *, store_valid_response: bool = True ) -> bool: """ A method that wraps around OTPState.response_validates and @@ -424,7 +436,9 @@ class OTPStore: :rtype: bool """ state = self._data[key] - rvalue = state.response_validates(response, store_valid_response) + rvalue = state.response_validates( + response, store_valid_response=store_valid_response + ) if rvalue and store_valid_response: next_state = state.get_next_state() self._data[key] = next_state @@ -443,7 +457,7 @@ class OTPStore: """ return {key: state.to_dict() for key, state in self._data.items()} - def _add_data(self, dict_obj: dict) -> dict: + def _add_data(self, dict_obj: dict) -> None: """ Adds data from a dict object (dict_obj). diff --git a/test/test_generator.py b/test/test_generator.py deleted file mode 100644 index 4329ae4..0000000 --- a/test/test_generator.py +++ /dev/null @@ -1,279 +0,0 @@ -# SPDX-License-Identifier: BSD-2-Clause-FreeBSD -# -# Copyright (c) 2020-2025, 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. -"""Tests for otp2289.generator""" -import pytest - -import otp2289 - - -def test_caller_exceptions(): - """Tests the exceptions when calling an initialized object""" - gen = otp2289.OTPGenerator( - 'This is a test.'.encode(), - 'TeSt', - otp2289.OTP_ALGO_MD5, - ) - with pytest.raises(otp2289.OTPGeneratorError) as exc_info: - gen.generate_otp_words('3') - 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) - 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(b'md5 fbd TeSt') - 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') - 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 - assert exc_info.value.args[0] == 'Invalid challenge' - - -def test_constructor_exceptions(): - """ - Tests the exceptions when initializing a new object (in the constructor) - """ - # test the otp2289.OTPGenerator __init__ and validators - with pytest.raises(otp2289.OTPGeneratorError) as exc_info: - otp2289.OTPGenerator( - 'This is a test.'.encode(), - 'TeStø'.encode(), - otp2289.OTP_ALGO_MD5, - ) - assert exc_info.type is otp2289.OTPGeneratorError - assert exc_info.value.args[0] == 'Seed must be a string' - with pytest.raises(otp2289.OTPGeneratorError) as exc_info: - otp2289.OTPGenerator( - 'This is a test.'.encode(), - 'TeStøtEsTteSTteStTest', - otp2289.OTP_ALGO_SHA1, - ) - assert exc_info.type is otp2289.OTPGeneratorError - assert exc_info.value.args[0] == ( - 'The seed MUST be of 1 to 16 characters in length' - ) - with pytest.raises(otp2289.OTPGeneratorError) as exc_info: - otp2289.OTPGenerator( - 'This is a test.'.encode(), - 'TeStø', - otp2289.OTP_ALGO_SHA1, - ) - assert exc_info.type is otp2289.OTPGeneratorError - assert exc_info.value.args[0] == ( - 'The seed MUST consist of purely alphanumeric characters' - ) - with pytest.raises(otp2289.OTPGeneratorError) as exc_info: - otp2289.OTPGenerator( - 'This is a test.'.encode(), - 'TeSt', - 9, - ) - assert exc_info.type is otp2289.OTPGeneratorError - assert exc_info.value.args[0] == ( - 'hash_algo is not among the known algorithms' - ) - with pytest.raises(otp2289.OTPGeneratorError) as exc_info: - otp2289.OTPGenerator( - 'This is a test.'.encode(), - 'TeSt', - b'md5', - ) - 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( - 'This is a test.'.encode(), - 'TeSt', - 'foo', - ) - assert exc_info.type is otp2289.generator.OTPGeneratorError - assert exc_info.value.args[0] == ( - 'foo is not supported by this version of the hashlib module' - ) - with pytest.raises(otp2289.OTPGeneratorError) as exc_info: - otp2289.OTPGenerator('1234567', 'TeSt', otp2289.OTP_ALGO_MD5) - assert exc_info.type is otp2289.OTPGeneratorError - assert exc_info.value.args[0] == 'Password must be a byte-string' - with pytest.raises(otp2289.OTPGeneratorError) as exc_info: - otp2289.OTPGenerator( - '1234567'.encode(), - 'TeSt', - otp2289.OTP_ALGO_MD5, - ) - assert exc_info.type is otp2289.OTPGeneratorError - assert exc_info.value.args[0] == 'Password must be longer than 10 bytes' - - -def test_md5(): - """ - Tests the MD5 functionality of the OTPGenerator as described in the RFC - - Those are the tests from 'RFC-2289 Appendix C - OTP Verification Examples' - """ - # We could run this in a loop, but I guess "Readability counts." - # pass='This is a test.', seed='TeSt' - gen = otp2289.OTPGenerator( - 'This is a test.'.encode(), - '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' - # 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' - ) - assert gen.generate_otp_words_from_challenge('otp-md5 1 TeSt') == ( - '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' - ) - assert gen.generate_otp_words_from_challenge('otp-md5 99 TeSt') == ( - '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.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' - # pass='AbCdEfGhIjK', seed='alpha1' - gen = otp2289.OTPGenerator( - 'AbCdEfGhIjK'.encode(), - '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' - # pass="OTP's are good", seed='correct' - gen = otp2289.OTPGenerator( - "OTP's are good".encode(), - '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' - - -def test_sha1(): - """ - Tests the SHA-1 functionality of the OTPGenerator as described in the RFC - - Those are the tests from 'RFC-2289 Appendix C - OTP Verification Examples' - """ - # pass='This is a test.', seed='TeSt' - gen = otp2289.OTPGenerator( - 'This is a test.'.encode(), - 'TeSt', - otp2289.OTP_ALGO_SHA1, - ) - # step=0 - res_hex = gen.generate_otp_hexdigest(0) - res_words = gen.generate_otp_words(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' - ) - assert gen.generate_otp_words_from_challenge('otp-sha1 1 TeSt') == ( - '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' - ) - assert gen.generate_otp_words_from_challenge('otp-sha1 99 TeSt') == ( - 'GAFF WAIT SKID GIG SKY EYED' - ) - # iterator test - hexdigests = list(gen.hexdigest_range(105)) - words = list(gen.words_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' - # pass='AbCdEfGhIjK', seed='alpha1' - gen = otp2289.OTPGenerator( - 'AbCdEfGhIjK'.encode(), - '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' - # pass="OTP's are good", seed='correct' - gen = otp2289.OTPGenerator( - "OTP's are good".encode(), - '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' diff --git a/test/test_main.py b/test/test_main.py deleted file mode 100644 index 593bf80..0000000 --- a/test/test_main.py +++ /dev/null @@ -1,264 +0,0 @@ -# SPDX-License-Identifier: BSD-2-Clause-FreeBSD -# -# Copyright (c) 2020-2025, 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. -"""Tests for otp2289.__main__""" -import os -import unittest.mock - -import pytest - -from otp2289.__main__ import main - - -def test_main_generate_otp_response(capsys): - """tests main""" - args = [ - '--generate-otp-response', - '-a', - 'sha1', - '-i', - '99', - '-s', - 'TesT', - '-p', - 'This is a test.', - ] - with pytest.raises(SystemExit) as exit_info: - main(args) - captured = capsys.readouterr() - assert captured.out == ( - f'Seed: TesT, Step: 99, Hash: sha1{os.linesep}' - f'0x87fec7768b73ccf9{os.linesep}' - ) - assert exit_info.type == SystemExit - assert exit_info.value.code == 0 - args.extend(['-f', 'token']) - with pytest.raises(SystemExit) as exit_info: - main(args) - captured = capsys.readouterr() - assert captured.out == ( - f'Seed: TesT, Step: 99, Hash: sha1{os.linesep}' - f'GAFF WAIT SKID GIG SKY EYED{os.linesep}' - ) - assert exit_info.type == SystemExit - assert exit_info.value.code == 0 - args.append('-q') - with pytest.raises(SystemExit) as exit_info: - main(args) - captured = capsys.readouterr() - assert captured.out == f'GAFF WAIT SKID GIG SKY EYED{os.linesep}' - assert exit_info.type == SystemExit - assert exit_info.value.code == 0 - - -def test_main_generate_otp_response_env_passwd(capsys): - """tests main by fetching password from the env. var. 'OTP2289_PASSWORD'""" - args = [ - '--generate-otp-response', - '-a', - 'sha1', - '-i', - '99', - '-s', - 'TesT', - ] - with unittest.mock.patch.dict( - os.environ, {'OTP2289_PASSWORD': 'This is a test.'} - ): - with pytest.raises(SystemExit) as exit_info: - main(args) - captured = capsys.readouterr() - assert captured.out == ( - f'Seed: TesT, Step: 99, Hash: sha1{os.linesep}' - f'0x87fec7768b73ccf9{os.linesep}' - ) - assert exit_info.type == SystemExit - assert exit_info.value.code == 0 - args.extend(['-f', 'token']) - with pytest.raises(SystemExit) as exit_info: - main(args) - captured = capsys.readouterr() - assert captured.out == ( - f'Seed: TesT, Step: 99, Hash: sha1{os.linesep}' - f'GAFF WAIT SKID GIG SKY EYED{os.linesep}' - ) - assert exit_info.type == SystemExit - assert exit_info.value.code == 0 - args.append('-q') - with pytest.raises(SystemExit) as exit_info: - main(args) - captured = capsys.readouterr() - assert captured.out == f'GAFF WAIT SKID GIG SKY EYED{os.linesep}' - assert exit_info.type == SystemExit - assert exit_info.value.code == 0 - - -def test_main_generate_otp_range(capsys): - """tests main""" - args = [ - '--generate-otp-range', - '-i', - '2', - '-s', - 'TesT', - '-r', - '5', - '-p', - 'This is a test.', - ] - with pytest.raises(SystemExit) as exit_info: - main(args) - captured = capsys.readouterr() - assert captured.out == ( - f'Seed: TesT, Step: 2, Hash: md5, Range: 3' - f'{os.linesep}' - f'2: 0x4049f8b161669b7b{os.linesep}' - f'1: 0x7965e05436f5029f{os.linesep}' - f'0: 0x9e876134d90499dd{os.linesep}' - ) - assert exit_info.type == SystemExit - assert exit_info.value.code == 0 - args.append('-q') - with pytest.raises(SystemExit) as exit_info: - main(args) - captured = capsys.readouterr() - assert captured.out == ( - f'2: 0x4049f8b161669b7b{os.linesep}' - f'1: 0x7965e05436f5029f{os.linesep}' - f'0: 0x9e876134d90499dd{os.linesep}' - ) - assert exit_info.type == SystemExit - assert exit_info.value.code == 0 - args.extend(['-f', 'token']) - with pytest.raises(SystemExit) as exit_info: - main(args) - captured = capsys.readouterr() - assert captured.out == ( - f'2: THY AVON NO NECK COKE MOLL{os.linesep}' - f'1: EASE OIL FUM CURE AWRY AVIS{os.linesep}' - f'0: INCH SEA ANNE LONG AHEM TOUR{os.linesep}' - ) - assert exit_info.type == SystemExit - assert exit_info.value.code == 0 - - -@pytest.mark.parametrize( - 'args', - [ - [ - '--generate-otp-range', - '-i', - '2', - '-s', - 'TesT', - '-r', - '5', - ], - [ - '--generate-otp-range', - '-i', - '2', - '-s', - 'TesT', - '-r', - '5', - '-P', - ], - ], -) -@unittest.mock.patch('getpass.getpass', lambda *args: 'This is a test.') -def test_main_generate_otp_range_passwd_prompt(capsys, args): - """tests main by prompting for password (with or without -P)""" - args = [ - '--generate-otp-range', - '-i', - '2', - '-s', - 'TesT', - '-r', - '5', - ] - with pytest.raises(SystemExit) as exit_info: - main(args) - captured = capsys.readouterr() - assert captured.out == ( - f'Seed: TesT, Step: 2, Hash: md5, Range: 3' - f'{os.linesep}' - f'2: 0x4049f8b161669b7b{os.linesep}' - f'1: 0x7965e05436f5029f{os.linesep}' - f'0: 0x9e876134d90499dd{os.linesep}' - ) - assert exit_info.type == SystemExit - assert exit_info.value.code == 0 - args.append('-q') - with pytest.raises(SystemExit) as exit_info: - main(args) - captured = capsys.readouterr() - assert captured.out == ( - f'2: 0x4049f8b161669b7b{os.linesep}' - f'1: 0x7965e05436f5029f{os.linesep}' - f'0: 0x9e876134d90499dd{os.linesep}' - ) - assert exit_info.type == SystemExit - assert exit_info.value.code == 0 - args.extend(['-f', 'token']) - with pytest.raises(SystemExit) as exit_info: - main(args) - captured = capsys.readouterr() - assert captured.out == ( - f'2: THY AVON NO NECK COKE MOLL{os.linesep}' - f'1: EASE OIL FUM CURE AWRY AVIS{os.linesep}' - f'0: INCH SEA ANNE LONG AHEM TOUR{os.linesep}' - ) - assert exit_info.type == SystemExit - assert exit_info.value.code == 0 - - -def test_main_initiate(capsys): - """tests main""" - args = [ - '--initiate-new-sequence', - '-i', - '500', - '-s', - 'TesT', - '-p', - 'This is a test.', - ] - with pytest.raises(SystemExit) as exit_info: - main(args) - captured = capsys.readouterr() - assert captured.out == ( - f'Seed: TesT, Step: 500, Hash: md5{os.linesep}' - f'0x2b8d82b6ac14346c{os.linesep}' - ) - assert exit_info.type == SystemExit - assert exit_info.value.code == 0 - args.append('-q') - with pytest.raises(SystemExit) as exit_info: - main(args) - captured = capsys.readouterr() - assert captured.out == f'0x2b8d82b6ac14346c{os.linesep}' - assert exit_info.type == SystemExit - assert exit_info.value.code == 0 diff --git a/test/test_server.py b/test/test_server.py deleted file mode 100644 index e81532f..0000000 --- a/test/test_server.py +++ /dev/null @@ -1,127 +0,0 @@ -# SPDX-License-Identifier: BSD-2-Clause-FreeBSD -# -# Copyright (c) 2020-2025, 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. -"""Tests for otp2289.server""" -import json - -import pytest - -import otp2289 - - -def test_state_caller_exceptions(): - """Tests the exceptions when calling the OTPState objects""" - state = otp2289.OTPState( - '0x7965e05436f5029f', - 1, - 'TeSt', - otp2289.OTP_ALGO_MD5, - ) - with pytest.raises(otp2289.OTPInvalidResponseError) as exc_info: - state.response_validates('bla') - assert exc_info.type is otp2289.OTPInvalidResponseError - assert exc_info.value.args[0] == ( - 'The response is neither a valid token or hex' - ) - - -def test_state_constructor_exceptions(): - """Tests the exceptions when initializing new OTPState objects""" - with pytest.raises(otp2289.OTPStateError) as exc_info: - otp2289.OTPState( - '0x7965e05436f5029t', - 1, - 'TeStø'.encode(), - otp2289.OTP_ALGO_MD5, - ) - assert exc_info.type is otp2289.OTPStateError - assert exc_info.value.args[0] == 'Seed must be a string' - with pytest.raises(otp2289.OTPStateError) as exc_info: - otp2289.OTPState( - '0x7965e05436f5029t', - '1', - 'TeSt', - otp2289.OTP_ALGO_MD5, - ) - assert exc_info.type is otp2289.OTPStateError - assert exc_info.value.args[0] == 'Step value MUST be an int' - - -def test_state_validation_md5(): - """Tests the OTPState validation functionality for MD5""" - state = otp2289.OTPState( - '0x7965e05436f5029f', - 1, - 'TeSt', - otp2289.OTP_ALGO_MD5, - ) - assert state.validated is False - assert state.response_validates('0x9e876134d90499dd') is True - assert state.response_validates('INCH SEA ANNE LONG AHEM TOUR') is True - assert state.ot_hex == '7965e05436f5029f' - assert state.validated is True - - -def test_state_validation_sha1(): - """Tests the OTPState validation functionality for SHA1""" - state = otp2289.OTPState( - '0x63d936639734385b', - 1, - 'TeSt', - otp2289.OTP_ALGO_SHA1, - ) - assert state.validated is False - assert state.response_validates('0xbb9e6ae1979d8ff4') is True - assert state.response_validates('MILT VARY MAST OK SEES WENT') is True - assert state.ot_hex == '63d936639734385b' - assert state.validated is True - - -def test_store(): - """Tests the OTPStore functionality""" - store_data = { - 'sgs': { - 'ot_hex': '0x7965e05436f5029f', - 'current_step': 1, - 'seed': 'TeSt', - 'hash_algo': 'md5', - }, - 'blackmore': { - 'ot_hex': '0x63d936639734385b', - 'current_step': 1, - 'seed': 'TeSt', - 'hash_algo': 'sha1', - }, - } - store = otp2289.OTPStore(store_data) - assert len(store) == 2 - assert isinstance(json.dumps(store.to_dict()), str) # serializable? - assert store.response_validates('sgs', '0x9e876134d90499dd') is True - assert store.response_validates('sgs', '0x9e876134d90499dd') is False - sgs_state = store.get('sgs') - assert sgs_state in store - store.pop_state('sgs') - assert bool(store) is True - store.pop_state('blackmore') - assert bool(store) is False diff --git a/test/test_static.py b/test/test_static.py deleted file mode 100644 index 7635e5e..0000000 --- a/test/test_static.py +++ /dev/null @@ -1,96 +0,0 @@ -# -*- coding: utf-8 -*- -# SPDX-License-Identifier: BSD-2-Clause-FreeBSD -# -# Copyright (c) 2020-2023, 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. -"""Tests for the static methods and basic bit, byte, token functionality""" -import binascii -import os - -import otp2289 - - -def test_bytes_and_tokens(): - """Tests the official hex and tokens defined in RFC2289""" - assert binascii.unhexlify('9e876134d90499dd') == ( - otp2289.OTPGenerator.tokens_to_bytes('INCH SEA ANNE LONG AHEM TOUR') - ) - assert binascii.unhexlify('7965e05436f5029f') == ( - otp2289.OTPGenerator.tokens_to_bytes('EASE OIL FUM CURE AWRY AVIS') - ) - assert binascii.unhexlify('50fe1962c4965880') == ( - otp2289.OTPGenerator.tokens_to_bytes('BAIL TUFT BITS GANG CHEF THY') - ) - assert binascii.unhexlify('87066dd9644bf206') == ( - otp2289.OTPGenerator.tokens_to_bytes('FULL PEW DOWN ONCE MORT ARC') - ) - assert binascii.unhexlify('7cd34c1040add14b') == ( - otp2289.OTPGenerator.tokens_to_bytes('FACT HOOF AT FIST SITE KENT') - ) - assert binascii.unhexlify('5aa37a81f212146c') == ( - otp2289.OTPGenerator.tokens_to_bytes('BODE HOP JAKE STOW JUT RAP') - ) - assert binascii.unhexlify('f205753943de4cf9') == ( - otp2289.OTPGenerator.tokens_to_bytes('ULAN NEW ARMY FUSE SUIT EYED') - ) - assert binascii.unhexlify('ddcdac956f234937') == ( - otp2289.OTPGenerator.tokens_to_bytes('SKIM CULT LOB SLAM POE HOWL') - ) - assert binascii.unhexlify('b203e28fa525be47') == ( - otp2289.OTPGenerator.tokens_to_bytes('LONG IVY JULY AJAR BOND LEE') - ) - assert binascii.unhexlify('bb9e6ae1979d8ff4') == ( - otp2289.OTPGenerator.tokens_to_bytes('MILT VARY MAST OK SEES WENT') - ) - assert binascii.unhexlify('63d936639734385b') == ( - otp2289.OTPGenerator.tokens_to_bytes('CART OTTO HIVE ODE VAT NUT') - ) - assert binascii.unhexlify('87fec7768b73ccf9') == ( - otp2289.OTPGenerator.tokens_to_bytes('GAFF WAIT SKID GIG SKY EYED') - ) - assert binascii.unhexlify('ad85f658ebe383c9') == ( - otp2289.OTPGenerator.tokens_to_bytes('LEST OR HEEL SCOT ROB SUIT') - ) - assert binascii.unhexlify('d07ce229b5cf119b') == ( - otp2289.OTPGenerator.tokens_to_bytes('RITE TAKE GELD COST TUNE RECK') - ) - assert binascii.unhexlify('27bc71035aaf3dc6') == ( - otp2289.OTPGenerator.tokens_to_bytes('MAY STAR TIN LYON VEDA STAN') - ) - assert binascii.unhexlify('d51f3e99bf8e6f0b') == ( - otp2289.OTPGenerator.tokens_to_bytes('RUST WELT KICK FELL TAIL FRAU') - ) - assert binascii.unhexlify('82aeb52d943774e4') == ( - otp2289.OTPGenerator.tokens_to_bytes('FLIT DOSE ALSO MEW DRUM DEFY') - ) - assert binascii.unhexlify('4f296a74fe1567ec') == ( - otp2289.OTPGenerator.tokens_to_bytes('AURA ALOE HURL WING BERG WAIT') - ) - - -def test_random_bytes(): - """Implement a few tests with random bytes""" - for _ in range(10): - rnd_bytes = os.urandom(8) # 64 bits - tokens = otp2289.OTPGenerator.bytes_to_tokens(rnd_bytes) - assert rnd_bytes == otp2289.OTPGenerator.tokens_to_bytes(tokens) diff --git a/tests/test_generator.py b/tests/test_generator.py new file mode 100644 index 0000000..08947e5 --- /dev/null +++ b/tests/test_generator.py @@ -0,0 +1,251 @@ +# SPDX-License-Identifier: BSD-2-Clause +# +# Copyright (c) 2020-2026, 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. +"""Tests for otp2289.generator""" + +import pytest + +import otp2289 + + +def test_caller_exceptions() -> None: + """Tests the exceptions when calling an initialized object""" + gen = otp2289.OTPGenerator( + 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] + 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) + 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( + 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') + 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 + assert exc_info.value.args[0] == 'Invalid challenge' + + +def test_constructor_exceptions() -> None: + """ + Tests the exceptions when initializing a new object (in the constructor) + """ + # test the otp2289.OTPGenerator __init__ and validators + with pytest.raises(otp2289.OTPGeneratorError) as exc_info: + otp2289.OTPGenerator( + b'This is a test.', + 'TeStø'.encode(), # ty: ignore[invalid-argument-type] + otp2289.OTP_ALGO_MD5, + ) + assert exc_info.type is otp2289.OTPGeneratorError + assert exc_info.value.args[0] == 'Seed must be a string' + with pytest.raises(otp2289.OTPGeneratorError) as exc_info: + otp2289.OTPGenerator( + b'This is a test.', 'TeStøtEsTteSTteStTest', otp2289.OTP_ALGO_SHA1 + ) + assert exc_info.type is otp2289.OTPGeneratorError + assert exc_info.value.args[0] == ( + 'The seed MUST be of 1 to 16 characters in length' + ) + with pytest.raises(otp2289.OTPGeneratorError) as exc_info: + otp2289.OTPGenerator( + b'This is a test.', 'TeStø', otp2289.OTP_ALGO_SHA1 + ) + assert exc_info.type is otp2289.OTPGeneratorError + assert exc_info.value.args[0] == ( + 'The seed MUST consist of purely alphanumeric characters' + ) + with pytest.raises(otp2289.OTPGeneratorError) as exc_info: + otp2289.OTPGenerator(b'This is a test.', 'TeSt', 9) + assert exc_info.type is otp2289.OTPGeneratorError + assert exc_info.value.args[0] == ( + 'hash_algo is not among the known algorithms' + ) + with pytest.raises(otp2289.OTPGeneratorError) as exc_info: + otp2289.OTPGenerator( + b'This is a test.', + 'TeSt', + b'md5', # ty: ignore[invalid-argument-type] + ) + 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 + assert exc_info.value.args[0] == ( + 'foo is not supported by this version of the hashlib module' + ) + with pytest.raises(otp2289.OTPGeneratorError) as exc_info: + otp2289.OTPGenerator( + '1234567', # ty: ignore[invalid-argument-type] + 'TeSt', + otp2289.OTP_ALGO_MD5, + ) + assert exc_info.type is otp2289.OTPGeneratorError + assert exc_info.value.args[0] == 'Password must be a byte-string' + with pytest.raises(otp2289.OTPGeneratorError) as exc_info: + otp2289.OTPGenerator(b'1234567', 'TeSt', otp2289.OTP_ALGO_MD5) + assert exc_info.type is otp2289.OTPGeneratorError + assert exc_info.value.args[0] == 'Password must be longer than 10 bytes' + + +def test_md5() -> None: + """ + Tests the MD5 functionality of the OTPGenerator as described in the RFC + + Those are the tests from 'RFC-2289 Appendix C - OTP Verification Examples' + """ + # We could run this in a loop, but I guess "Readability counts." + # pass='This is a test.', seed='TeSt' + 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' + # 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' + ) + assert gen.generate_otp_words_from_challenge('otp-md5 1 TeSt') == ( + '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' + ) + assert gen.generate_otp_words_from_challenge('otp-md5 99 TeSt') == ( + '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.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' + # 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' + # 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' + + +def test_sha1() -> None: + """ + Tests the SHA-1 functionality of the OTPGenerator as described in the RFC + + Those are the tests from 'RFC-2289 Appendix C - OTP Verification Examples' + """ + # pass='This is a test.', seed='TeSt' + 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' + ) + assert gen.generate_otp_words_from_challenge('otp-sha1 1 TeSt') == ( + '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' + ) + assert gen.generate_otp_words_from_challenge('otp-sha1 99 TeSt') == ( + 'GAFF WAIT SKID GIG SKY EYED' + ) + # iterator test + hexdigests = list(gen.hexdigest_range(105)) + words = list(gen.words_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' + # 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' + # 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' diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..3c75621 --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,225 @@ +# SPDX-License-Identifier: BSD-2-Clause +# +# Copyright (c) 2020-2026, 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. +"""Tests for otp2289.__main__""" + +import os +import unittest.mock + +import pytest + +from otp2289.__main__ import main + + +def test_main_generate_otp_response(capsys: pytest.CaptureFixture) -> None: + """tests main""" + args = [ + '--generate-otp-response', + '-a', + 'sha1', + '-i', + '99', + '-s', + 'TesT', + '-p', + 'This is a test.', + ] + with pytest.raises(SystemExit) as exit_info: + main(args) + captured = capsys.readouterr() + assert captured.out == ( + f'Seed: TesT, Step: 99, Hash: sha1{os.linesep}' + f'0x87fec7768b73ccf9{os.linesep}' + ) + assert exit_info.value.code == 0 + args.extend(['-f', 'token']) + with pytest.raises(SystemExit) as exit_info: + main(args) + captured = capsys.readouterr() + assert captured.out == ( + f'Seed: TesT, Step: 99, Hash: sha1{os.linesep}' + f'GAFF WAIT SKID GIG SKY EYED{os.linesep}' + ) + assert exit_info.value.code == 0 + args.append('-q') + with pytest.raises(SystemExit) as exit_info: + main(args) + captured = capsys.readouterr() + assert captured.out == f'GAFF WAIT SKID GIG SKY EYED{os.linesep}' + assert exit_info.value.code == 0 + + +def test_main_generate_otp_response_env_passwd( + capsys: pytest.CaptureFixture, +) -> None: + """tests main by fetching password from the env. var. 'OTP2289_PASSWORD'""" + args = ['--generate-otp-response', '-a', 'sha1', '-i', '99', '-s', 'TesT'] + with unittest.mock.patch.dict( + os.environ, {'OTP2289_PASSWORD': 'This is a test.'} + ): + with pytest.raises(SystemExit) as exit_info: + main(args) + captured = capsys.readouterr() + assert captured.out == ( + f'Seed: TesT, Step: 99, Hash: sha1{os.linesep}' + f'0x87fec7768b73ccf9{os.linesep}' + ) + assert exit_info.value.code == 0 + args.extend(['-f', 'token']) + with pytest.raises(SystemExit) as exit_info: + main(args) + captured = capsys.readouterr() + assert captured.out == ( + f'Seed: TesT, Step: 99, Hash: sha1{os.linesep}' + f'GAFF WAIT SKID GIG SKY EYED{os.linesep}' + ) + assert exit_info.value.code == 0 + args.append('-q') + with pytest.raises(SystemExit) as exit_info: + main(args) + captured = capsys.readouterr() + assert captured.out == f'GAFF WAIT SKID GIG SKY EYED{os.linesep}' + assert exit_info.value.code == 0 + + +def test_main_generate_otp_range(capsys: pytest.CaptureFixture) -> None: + """tests main""" + args = [ + '--generate-otp-range', + '-i', + '2', + '-s', + 'TesT', + '-r', + '5', + '-p', + 'This is a test.', + ] + with pytest.raises(SystemExit) as exit_info: + main(args) + captured = capsys.readouterr() + assert captured.out == ( + f'Seed: TesT, Step: 2, Hash: md5, Range: 3' + f'{os.linesep}' + f'2: 0x4049f8b161669b7b{os.linesep}' + f'1: 0x7965e05436f5029f{os.linesep}' + f'0: 0x9e876134d90499dd{os.linesep}' + ) + assert exit_info.value.code == 0 + args.append('-q') + with pytest.raises(SystemExit) as exit_info: + main(args) + captured = capsys.readouterr() + assert captured.out == ( + f'2: 0x4049f8b161669b7b{os.linesep}' + f'1: 0x7965e05436f5029f{os.linesep}' + f'0: 0x9e876134d90499dd{os.linesep}' + ) + assert exit_info.value.code == 0 + args.extend(['-f', 'token']) + with pytest.raises(SystemExit) as exit_info: + main(args) + captured = capsys.readouterr() + assert captured.out == ( + f'2: THY AVON NO NECK COKE MOLL{os.linesep}' + f'1: EASE OIL FUM CURE AWRY AVIS{os.linesep}' + f'0: INCH SEA ANNE LONG AHEM TOUR{os.linesep}' + ) + assert exit_info.value.code == 0 + + +@pytest.mark.parametrize( + 'args', + [ + ['--generate-otp-range', '-i', '2', '-s', 'TesT', '-r', '5'], + ['--generate-otp-range', '-i', '2', '-s', 'TesT', '-r', '5', '-P'], + ], +) +@unittest.mock.patch('getpass.getpass') +def test_main_generate_otp_range_passwd_prompt( + getpass: unittest.mock.MagicMock, + capsys: pytest.CaptureFixture, + args: list[str], +) -> None: + """tests main by prompting for password (with or without -P)""" + args = ['--generate-otp-range', '-i', '2', '-s', 'TesT', '-r', '5'] + getpass.return_value = 'This is a test.' + with pytest.raises(SystemExit) as exit_info: + main(args) + captured = capsys.readouterr() + assert captured.out == ( + f'Seed: TesT, Step: 2, Hash: md5, Range: 3' + f'{os.linesep}' + f'2: 0x4049f8b161669b7b{os.linesep}' + f'1: 0x7965e05436f5029f{os.linesep}' + f'0: 0x9e876134d90499dd{os.linesep}' + ) + assert exit_info.value.code == 0 + args.append('-q') + with pytest.raises(SystemExit) as exit_info: + main(args) + captured = capsys.readouterr() + assert captured.out == ( + f'2: 0x4049f8b161669b7b{os.linesep}' + f'1: 0x7965e05436f5029f{os.linesep}' + f'0: 0x9e876134d90499dd{os.linesep}' + ) + assert exit_info.value.code == 0 + args.extend(['-f', 'token']) + with pytest.raises(SystemExit) as exit_info: + main(args) + captured = capsys.readouterr() + assert captured.out == ( + f'2: THY AVON NO NECK COKE MOLL{os.linesep}' + f'1: EASE OIL FUM CURE AWRY AVIS{os.linesep}' + f'0: INCH SEA ANNE LONG AHEM TOUR{os.linesep}' + ) + assert exit_info.value.code == 0 + + +def test_main_initiate(capsys: pytest.CaptureFixture) -> None: + """tests main""" + args = [ + '--initiate-new-sequence', + '-i', + '500', + '-s', + 'TesT', + '-p', + 'This is a test.', + ] + with pytest.raises(SystemExit) as exit_info: + main(args) + captured = capsys.readouterr() + assert captured.out == ( + f'Seed: TesT, Step: 500, Hash: md5{os.linesep}' + f'0x2b8d82b6ac14346c{os.linesep}' + ) + assert exit_info.value.code == 0 + args.append('-q') + with pytest.raises(SystemExit) as exit_info: + main(args) + captured = capsys.readouterr() + assert captured.out == f'0x2b8d82b6ac14346c{os.linesep}' + assert exit_info.value.code == 0 diff --git a/tests/test_server.py b/tests/test_server.py new file mode 100644 index 0000000..3099cf7 --- /dev/null +++ b/tests/test_server.py @@ -0,0 +1,121 @@ +# SPDX-License-Identifier: BSD-2-Clause +# +# Copyright (c) 2020-2026, 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. +"""Tests for otp2289.server""" + +import json + +import pytest + +import otp2289 + + +def test_state_caller_exceptions() -> None: + """Tests the exceptions when calling the OTPState objects""" + state = otp2289.OTPState( + '0x7965e05436f5029f', 1, 'TeSt', otp2289.OTP_ALGO_MD5 + ) + with pytest.raises(otp2289.OTPInvalidResponseError) as exc_info: + state.response_validates('bla') + assert exc_info.type is otp2289.OTPInvalidResponseError + assert exc_info.value.args[0] == ( + 'The response is neither a valid token or hex' + ) + + +def test_state_constructor_exceptions() -> None: + """Tests the exceptions when initializing new OTPState objects""" + with pytest.raises(otp2289.OTPStateError) as exc_info: + otp2289.OTPState( + '0x7965e05436f5029t', + 1, + 'TeStø'.encode(), # ty: ignore[invalid-argument-type] + otp2289.OTP_ALGO_MD5, + ) + assert exc_info.type is otp2289.OTPStateError + assert exc_info.value.args[0] == 'Seed must be a string' + + with pytest.raises(otp2289.OTPStateError) as exc_info: + otp2289.OTPState( + '0x7965e05436f5029t', + '1', # ty: ignore[invalid-argument-type] + 'TeSt', + otp2289.OTP_ALGO_MD5, + ) + assert exc_info.type is otp2289.OTPStateError + assert exc_info.value.args[0] == 'Step value MUST be an int' + + +def test_state_validation_md5() -> None: + """Tests the OTPState validation functionality for MD5""" + state = otp2289.OTPState( + '0x7965e05436f5029f', 1, 'TeSt', otp2289.OTP_ALGO_MD5 + ) + assert state.validated is False + assert state.response_validates('0x9e876134d90499dd') is True + assert state.response_validates('INCH SEA ANNE LONG AHEM TOUR') is True + assert state.ot_hex == '7965e05436f5029f' + assert state.validated is True + + +def test_state_validation_sha1() -> None: + """Tests the OTPState validation functionality for SHA1""" + state = otp2289.OTPState( + '0x63d936639734385b', 1, 'TeSt', otp2289.OTP_ALGO_SHA1 + ) + assert state.validated is False + assert state.response_validates('0xbb9e6ae1979d8ff4') is True + assert state.response_validates('MILT VARY MAST OK SEES WENT') is True + assert state.ot_hex == '63d936639734385b' + assert state.validated is True + + +def test_store() -> None: + """Tests the OTPStore functionality""" + store_data = { + 'sgs': { + 'ot_hex': '0x7965e05436f5029f', + 'current_step': 1, + 'seed': 'TeSt', + 'hash_algo': 'md5', + }, + 'blackmore': { + 'ot_hex': '0x63d936639734385b', + 'current_step': 1, + 'seed': 'TeSt', + 'hash_algo': 'sha1', + }, + } + store = otp2289.OTPStore(store_data) + assert len(store) == len(store_data) + assert isinstance(json.dumps(store.to_dict()), str) # serializable? + assert store.response_validates('sgs', '0x9e876134d90499dd') is True + assert store.response_validates('sgs', '0x9e876134d90499dd') is False + sgs_state = store.get('sgs') + if sgs_state is not None: + assert sgs_state in store + store.pop_state('sgs') + assert bool(store) is True + store.pop_state('blackmore') + assert bool(store) is False diff --git a/tests/test_static.py b/tests/test_static.py new file mode 100644 index 0000000..589fc17 --- /dev/null +++ b/tests/test_static.py @@ -0,0 +1,95 @@ +# SPDX-License-Identifier: BSD-2-Clause +# +# Copyright (c) 2020-2026, 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. +"""Tests for the static methods and basic bit, byte, token functionality""" + +import os + +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') + ) + assert bytes.fromhex('7965e05436f5029f') == ( + otp2289.OTPGenerator.tokens_to_bytes('EASE OIL FUM CURE AWRY AVIS') + ) + assert bytes.fromhex('50fe1962c4965880') == ( + otp2289.OTPGenerator.tokens_to_bytes('BAIL TUFT BITS GANG CHEF THY') + ) + assert bytes.fromhex('87066dd9644bf206') == ( + otp2289.OTPGenerator.tokens_to_bytes('FULL PEW DOWN ONCE MORT ARC') + ) + assert bytes.fromhex('7cd34c1040add14b') == ( + otp2289.OTPGenerator.tokens_to_bytes('FACT HOOF AT FIST SITE KENT') + ) + assert bytes.fromhex('5aa37a81f212146c') == ( + otp2289.OTPGenerator.tokens_to_bytes('BODE HOP JAKE STOW JUT RAP') + ) + assert bytes.fromhex('f205753943de4cf9') == ( + otp2289.OTPGenerator.tokens_to_bytes('ULAN NEW ARMY FUSE SUIT EYED') + ) + assert bytes.fromhex('ddcdac956f234937') == ( + otp2289.OTPGenerator.tokens_to_bytes('SKIM CULT LOB SLAM POE HOWL') + ) + assert bytes.fromhex('b203e28fa525be47') == ( + otp2289.OTPGenerator.tokens_to_bytes('LONG IVY JULY AJAR BOND LEE') + ) + assert bytes.fromhex('bb9e6ae1979d8ff4') == ( + otp2289.OTPGenerator.tokens_to_bytes('MILT VARY MAST OK SEES WENT') + ) + assert bytes.fromhex('63d936639734385b') == ( + otp2289.OTPGenerator.tokens_to_bytes('CART OTTO HIVE ODE VAT NUT') + ) + assert bytes.fromhex('87fec7768b73ccf9') == ( + otp2289.OTPGenerator.tokens_to_bytes('GAFF WAIT SKID GIG SKY EYED') + ) + assert bytes.fromhex('ad85f658ebe383c9') == ( + otp2289.OTPGenerator.tokens_to_bytes('LEST OR HEEL SCOT ROB SUIT') + ) + assert bytes.fromhex('d07ce229b5cf119b') == ( + otp2289.OTPGenerator.tokens_to_bytes('RITE TAKE GELD COST TUNE RECK') + ) + assert bytes.fromhex('27bc71035aaf3dc6') == ( + otp2289.OTPGenerator.tokens_to_bytes('MAY STAR TIN LYON VEDA STAN') + ) + assert bytes.fromhex('d51f3e99bf8e6f0b') == ( + otp2289.OTPGenerator.tokens_to_bytes('RUST WELT KICK FELL TAIL FRAU') + ) + assert bytes.fromhex('82aeb52d943774e4') == ( + otp2289.OTPGenerator.tokens_to_bytes('FLIT DOSE ALSO MEW DRUM DEFY') + ) + assert bytes.fromhex('4f296a74fe1567ec') == ( + otp2289.OTPGenerator.tokens_to_bytes('AURA ALOE HURL WING BERG WAIT') + ) + + +def test_random_bytes() -> None: + """Implement a few tests with random bytes""" + 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) -- cgit v1.3