diff options
| author | Simeon Simeonov | 2026-04-28 14:27:05 +0200 |
|---|---|---|
| committer | Simeon Simeonov | 2026-04-28 14:27:05 +0200 |
| commit | 751c74e7de1c78a151fdd8b76f220d8411a2108a (patch) | |
| tree | 0b13345aad6949a3325ca8f72b4eece14a0fe973 | |
| parent | 82ef6adec6e59f9cc9dc9fa1a13a2b43e534bada (diff) | |
Restructure the entire project, enforce linting and add support for type checkers
| -rw-r--r-- | .ruff.toml | 47 | ||||
| -rw-r--r-- | CHANGELOG.md | 9 | ||||
| -rw-r--r-- | COPYING | 4 | ||||
| -rw-r--r-- | LICENSE | 4 | ||||
| -rw-r--r-- | README.md | 17 | ||||
| -rw-r--r-- | pyproject.toml | 64 | ||||
| -rw-r--r-- | setup.cfg | 42 | ||||
| -rw-r--r-- | setup.py | 5 | ||||
| -rw-r--r-- | sphinx_conf.py | 17 | ||||
| -rw-r--r-- | src/otp2289/__init__.py | 10 | ||||
| -rw-r--r-- | src/otp2289/__main__.py | 22 | ||||
| -rw-r--r-- | src/otp2289/generator.py | 165 | ||||
| -rw-r--r-- | src/otp2289/server.py | 88 | ||||
| -rw-r--r-- | tests/test_generator.py (renamed from test/test_generator.py) | 94 | ||||
| -rw-r--r-- | tests/test_main.py (renamed from test/test_main.py) | 79 | ||||
| -rw-r--r-- | tests/test_server.py (renamed from test/test_server.py) | 40 | ||||
| -rw-r--r-- | tests/test_static.py (renamed from test/test_static.py) | 49 |
17 files changed, 382 insertions, 374 deletions
| @@ -1,59 +1,40 @@ | |||
| 1 | cache-dir = "~/.cache/ruff" | 1 | cache-dir = "~/.cache/ruff" |
| 2 | indent-width = 4 | 2 | indent-width = 4 |
| 3 | line-length = 79 | 3 | line-length = 79 |
| 4 | target-version = "py312" | 4 | target-version = "py310" |
| 5 | namespace-packages = ["tests"] | ||
| 6 | |||
| 5 | 7 | ||
| 6 | [lint] | 8 | [lint] |
| 7 | select = ["ALL", "D101", "D102", "D103", "D104"] | 9 | select = ["ALL"] |
| 8 | ignore = [ | 10 | ignore = [ |
| 9 | "ANN", | ||
| 10 | "BLE001", | 11 | "BLE001", |
| 11 | "COM812", | 12 | "COM812", |
| 12 | "D", | 13 | "D2", |
| 14 | "D4", | ||
| 13 | "EM101", # Exception must not use a string literal, assign to variable first | 15 | "EM101", # Exception must not use a string literal, assign to variable first |
| 14 | "EM102", # Exception must not use an f-string literal, assign to variable first | 16 | "EM102", # Exception must not use an f-string literal, assign to variable first |
| 15 | "ERA001", | ||
| 16 | "FBT001", | ||
| 17 | "FBT002", | ||
| 18 | "INP001", | 17 | "INP001", |
| 19 | "ISC001", | ||
| 20 | "N802", | ||
| 21 | "N806", | ||
| 22 | "PLR2004", | ||
| 23 | "PTH111", | ||
| 24 | "RUF012", | ||
| 25 | "RUF013", | ||
| 26 | "S101", | 18 | "S101", |
| 27 | "S324", | 19 | "S324", |
| 28 | "T201", | 20 | "T201", |
| 29 | "TRY003", | 21 | "TRY003", |
| 30 | "TRY300", | ||
| 31 | "UP020" | ||
| 32 | ] | 22 | ] |
| 33 | 23 | ||
| 24 | # ARG005 - Unused lambda argument: `args` | ||
| 25 | # BLE001 - Do not catch blind exception: `Exception` | ||
| 34 | # D101 - Missing docstring in public class | 26 | # D101 - Missing docstring in public class |
| 35 | # D102 - Missing docstring in public method | 27 | # D102 - Missing docstring in public method |
| 36 | # D200 - One-line docstring should fit on one line | 28 | # E721 - Use `is` and `is not` for type comparisons, or `isinstance()` for isinstance checks |
| 37 | # D203 - 1 blank line required before class docstring | 29 | # ERA001 - Found commented-out code |
| 38 | # D205 - 1 blank line required between summary line and description | ||
| 39 | # D403 - First word of the first line should be capitalized: `str` -> `Str` | ||
| 40 | # FBT001 - Boolean-typed positional argument in function definition | ||
| 41 | # FBT002 - Boolean default positional argument in function definition | ||
| 42 | # INP001 - File `beinc_weechat.py` is part of an implicit namespace package. Add an `__init__.py` | 30 | # INP001 - File `beinc_weechat.py` is part of an implicit namespace package. Add an `__init__.py` |
| 43 | # N802 - Function name `do_GET` should be lowercase | 31 | # PT008 - Use `return_value=` instead of patching with `lambda` |
| 44 | # N806 - Variable `POST_data` in function should be lowercase | ||
| 45 | # PLR2004 - Magic value used in comparison, consider replacing `200` with a constant variable | 32 | # PLR2004 - Magic value used in comparison, consider replacing `200` with a constant variable |
| 46 | # PTH111 - `os.path.expanduser()` should be replaced by `Path.expanduser()` | ||
| 47 | # PTH113 - `os.path.isfile()` should be replaced by `Path.is_file()` | ||
| 48 | # PTH123 - `open()` should be replaced by `Path.open()` | ||
| 49 | # RUF012 - Mutable class attributes should be annotated with `typing.ClassVar` | ||
| 50 | # RUF013 - PEP 484 prohibits implicit `Optional` | ||
| 51 | # S101 - Use of `assert` detected | 33 | # S101 - Use of `assert` detected |
| 52 | # S324 - Probable use of insecure hash functions in `hashlib`: `md5` | 34 | # S324 - Probable use of insecure hash functions in `hashlib`: `md5` |
| 53 | # T201 - `print` found | 35 | # T201 - `print` found |
| 54 | # TRY003 - Avoid specifying long messages outside the exception class | 36 | # TRY003 - Avoid specifying long messages outside the exception class |
| 55 | # TRY300 - Consider moving this statement to an `else` block | 37 | # TRY300 - Consider moving this statement to an `else` block |
| 56 | # UP020 - Use builtin `open` | ||
| 57 | 38 | ||
| 58 | # Allow fix for all enabled rules (when `--fix`) is provided. | 39 | # Allow fix for all enabled rules (when `--fix`) is provided. |
| 59 | fixable = ["ALL"] | 40 | fixable = ["ALL"] |
| @@ -61,7 +42,8 @@ unfixable = [] | |||
| 61 | 42 | ||
| 62 | # custom settings | 43 | # custom settings |
| 63 | [lint.per-file-ignores] | 44 | [lint.per-file-ignores] |
| 64 | "src/otp2289/__main__.py" = ["PTH113", "PTH123"] # "Readability counts" | 45 | "src/otp2289/generator.py" = ["ERA001"] # commented example code from RFC 2289 |
| 46 | # "tests/test_main.py" = ["ARG005", "E721", "PT008"] | ||
| 65 | 47 | ||
| 66 | 48 | ||
| 67 | [format] | 49 | [format] |
| @@ -93,6 +75,3 @@ docstring-code-line-length = "dynamic" | |||
| 93 | 75 | ||
| 94 | [lint.flake8-quotes] | 76 | [lint.flake8-quotes] |
| 95 | inline-quotes = "single" | 77 | inline-quotes = "single" |
| 96 | |||
| 97 | [lint.isort] | ||
| 98 | 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 @@ | |||
| 1 | # Changelog | 1 | # Changelog |
| 2 | 2 | ||
| 3 | ## [2.0.0](https://github.com/blackm0re/pyotp2289/tree/2.0.0) (2025-01-03) | ||
| 4 | |||
| 5 | [Full Changelog](https://github.com/blackm0re/pyotp2289/compare/1.2.1...2.0.0) | ||
| 6 | |||
| 7 | **Changes:** | ||
| 8 | |||
| 9 | - Rename *Exception to *Error and do some additional linting | ||
| 10 | |||
| 11 | |||
| 3 | ## [1.2.1](https://github.com/blackm0re/pyotp2289/tree/1.2.1) (2023-01-08) | 12 | ## [1.2.1](https://github.com/blackm0re/pyotp2289/tree/1.2.1) (2023-01-08) |
| 4 | 13 | ||
| 5 | [Full Changelog](https://github.com/blackm0re/pyotp2289/compare/1.2.0...1.2.1) | 14 | [Full Changelog](https://github.com/blackm0re/pyotp2289/compare/1.2.0...1.2.1) |
| @@ -1,6 +1,6 @@ | |||
| 1 | SPDX-License-Identifier: BSD-2-Clause-FreeBSD | 1 | SPDX-License-Identifier: BSD-2-Clause |
| 2 | 2 | ||
| 3 | Copyright (c) 2020-2023, Simeon Simeonov | 3 | Copyright (c) 2020-2026, Simeon Simeonov |
| 4 | All rights reserved. | 4 | All rights reserved. |
| 5 | 5 | ||
| 6 | Redistribution and use in source and binary forms, with or without | 6 | Redistribution and use in source and binary forms, with or without |
| @@ -1,6 +1,6 @@ | |||
| 1 | SPDX-License-Identifier: BSD-2-Clause-FreeBSD | 1 | SPDX-License-Identifier: BSD-2-Clause |
| 2 | 2 | ||
| 3 | Copyright (c) 2020-2023, Simeon Simeonov | 3 | Copyright (c) 2020-2026, Simeon Simeonov |
| 4 | All rights reserved. | 4 | All rights reserved. |
| 5 | 5 | ||
| 6 | Redistribution and use in source and binary forms, with or without | 6 | Redistribution and use in source and binary forms, with or without |
| @@ -8,11 +8,12 @@ It requires no additional libraries. | |||
| 8 | ## General | 8 | ## General |
| 9 | 9 | ||
| 10 | The main reason for writing this library was the need to login into my | 10 | The main reason for writing this library was the need to login into my |
| 11 | FreeBSD servers using [opiepasswd(1)](https://www.freebsd.org/cgi/man.cgi?query=opiepasswd&sektion=1&manpath=FreeBSD) | 11 | FreeBSD servers using [opiepasswd](https://en.wikipedia.org/wiki/OPIE_Authentication_System). |
| 12 | as [described in the FreeBSD Handbook](https://docs.freebsd.org/en/books/handbook/security/#one-time-passwords). | 12 | |
| 13 | *opiepasswd* has since been removed from FreeBSD since version 14. | ||
| 13 | 14 | ||
| 14 | I decided to license the library under the | 15 | I decided to license the library under the |
| 15 | [Simplified BSD License / 2-clause BSD license](https://github.com/blackm0re/pyotp2289/blob/master/LICENSE) and not under the | 16 | [Simplified BSD License / 2-clause BSD license](https://codeberg.org/sgs/pyotp2289/src/branch/master/LICENSE) and not under the |
| 16 | (L)GPL-3 as I usually do. | 17 | (L)GPL-3 as I usually do. |
| 17 | 18 | ||
| 18 | I hope that somebody will find it useful. | 19 | I hope that somebody will find it useful. |
| @@ -197,7 +198,7 @@ starting from (and including) 498. | |||
| 197 | 198 | ||
| 198 | ## Support and contributing | 199 | ## Support and contributing |
| 199 | 200 | ||
| 200 | *pyotp2289* is hosted on GitHub: https://github.com/blackm0re/pyotp2289 | 201 | *pyotp2289* is hosted on Codeberg: https://codeberg.org/sgs/pyotp2289 |
| 201 | 202 | ||
| 202 | 203 | ||
| 203 | ## Author | 204 | ## Author |
| @@ -205,10 +206,10 @@ starting from (and including) 498. | |||
| 205 | Simeon Simeonov - sgs @ LiberaChat | 206 | Simeon Simeonov - sgs @ LiberaChat |
| 206 | 207 | ||
| 207 | 208 | ||
| 208 | ## [License](https://github.com/blackm0re/pyotp2289/blob/master/LICENSE) | 209 | ## [License](https://codeberg.org/sgs/pyotp2289/src/branch/master/LICENSE) |
| 209 | 210 | ||
| 210 | Copyright (c) 2020-2023 Simeon Simeonov | 211 | Copyright (c) 2020-2026 Simeon Simeonov |
| 211 | All rights reserved. | 212 | All rights reserved. |
| 212 | 213 | ||
| 213 | [Licensed](https://github.com/blackm0re/pyotp2289/blob/master/LICENSE) under the BSD 2-clause. | 214 | [Licensed](https://codeberg.org/sgs/pyotp2289/src/branch/master/LICENSE) under the BSD 2-clause. |
| 214 | SPDX-License-Identifier: BSD-2-Clause-FreeBSD | 215 | 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 @@ | |||
| 1 | [build-system] | 1 | [build-system] |
| 2 | requires = [ | 2 | requires = [ |
| 3 | "setuptools>=51", | 3 | "setuptools >= 77.0.3", |
| 4 | "wheel" | 4 | "wheel" |
| 5 | ] | 5 | ] |
| 6 | 6 | ||
| 7 | build-backend = "setuptools.build_meta" | 7 | build-backend = "setuptools.build_meta" |
| 8 | 8 | ||
| 9 | 9 | ||
| 10 | [project] | ||
| 11 | name = "pyotp2289" | ||
| 12 | description = "A pure Python implementation of 'A One-Time Password System'" | ||
| 13 | dynamic = ["version"] | ||
| 14 | license = "BSD-2-Clause" | ||
| 15 | authors = [{name = "Simeon Simeonov"}] | ||
| 16 | readme = "README.md" | ||
| 17 | requires-python = ">= 3.10" | ||
| 18 | |||
| 19 | classifiers = [ | ||
| 20 | "Development Status :: 5 - Production/Stable", | ||
| 21 | "Intended Audience :: Developers", | ||
| 22 | "Intended Audience :: System Administrators", | ||
| 23 | "Programming Language :: Python :: 3", | ||
| 24 | "Programming Language :: Python :: 3.10", | ||
| 25 | "Programming Language :: Python :: 3.11", | ||
| 26 | "Programming Language :: Python :: 3.12", | ||
| 27 | "Programming Language :: Python :: 3.13", | ||
| 28 | "Programming Language :: Python :: 3.14", | ||
| 29 | "Programming Language :: Python :: 3.15", | ||
| 30 | "Operating System :: OS Independent", | ||
| 31 | "Topic :: Security :: Cryptography", | ||
| 32 | ] | ||
| 33 | |||
| 34 | |||
| 35 | [dependency-groups] | ||
| 36 | dev = [ | ||
| 37 | "pytest>=7", | ||
| 38 | "ruff>=0.15.0", | ||
| 39 | "ty>=0.0.31", | ||
| 40 | ] | ||
| 41 | |||
| 42 | |||
| 43 | [project.scripts] | ||
| 44 | otp2289 = "otp2289.__main__:main" | ||
| 45 | |||
| 46 | |||
| 47 | [project.urls] | ||
| 48 | Homepage = "https://codeberg.org/sgs/pyotp2289" | ||
| 49 | Repository = "https://codeberg.org/sgs/pyotp2289" | ||
| 50 | Issues = "https://codeberg.org/sgs/pyotp2289/issues" | ||
| 51 | Changelog = "https://codeberg.org/sgs/pyotp2289/src/branch/master/CHANGELOG.md" | ||
| 52 | |||
| 53 | |||
| 10 | [tool.pytest.ini_options] | 54 | [tool.pytest.ini_options] |
| 11 | minversion = "7.0" | 55 | minversion = "7.0" |
| 12 | testpaths = [ | 56 | cache_dir = "~/.cache/pytest_cache" |
| 13 | "test" | ||
| 14 | ] | ||
| 15 | pythonpath = [ | 57 | pythonpath = [ |
| 16 | "src" | 58 | "src" |
| 59 | ] | ||
| 60 | testpaths = [ | ||
| 61 | "tests" | ||
| 17 | ] | 62 | ] |
| 63 | |||
| 64 | |||
| 65 | [tool.setuptools.dynamic] | ||
| 66 | version = {attr = "otp2289.__version__"} | ||
| 67 | |||
| 68 | |||
| 69 | [tool.ty.environment] | ||
| 70 | python-version = "3.10" | ||
| 71 | 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 @@ | |||
| 1 | [metadata] | ||
| 2 | name = pyotp2289 | ||
| 3 | version = attr: otp2289.__version__ | ||
| 4 | author = attr: otp2289.__author__ | ||
| 5 | author_email = sgs@pichove.org | ||
| 6 | description = A pure Python implementation of "A One-Time Password System" | ||
| 7 | long_description = file: README.md | ||
| 8 | long_description_content_type = text/markdown | ||
| 9 | url = https://github.com/blackm0re/pyotp2289 | ||
| 10 | |||
| 11 | classifiers = | ||
| 12 | Development Status :: 5 - Production/Stable | ||
| 13 | Intended Audience :: Developers | ||
| 14 | License :: OSI Approved :: BSD License | ||
| 15 | Programming Language :: Python :: 3 | ||
| 16 | Programming Language :: Python :: 3.7 | ||
| 17 | Programming Language :: Python :: 3.8 | ||
| 18 | Programming Language :: Python :: 3.9 | ||
| 19 | Programming Language :: Python :: 3.10 | ||
| 20 | Programming Language :: Python :: 3.11 | ||
| 21 | Programming Language :: Python :: 3.12 | ||
| 22 | Programming Language :: Python :: Implementation | ||
| 23 | Operating System :: OS Independent | ||
| 24 | Topic :: Security :: Cryptography | ||
| 25 | |||
| 26 | project_urls = | ||
| 27 | Bug Tracker = https://github.com/blackm0re/pyotp2289/issues | ||
| 28 | Source = https://github.com/blackm0re/pyotp2289 | ||
| 29 | API Documentation = https://gnulover.simeonov.no/docs/api/pyotp2289/latest/ | ||
| 30 | |||
| 31 | [options] | ||
| 32 | package_dir = | ||
| 33 | = src | ||
| 34 | packages = find: | ||
| 35 | python_requires = >=3.7 | ||
| 36 | |||
| 37 | [options.packages.find] | ||
| 38 | where = src | ||
| 39 | |||
| 40 | [options.entry_points] | ||
| 41 | console_scripts = | ||
| 42 | 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 @@ | |||
| 1 | # Legacy setup for some build / install systems | ||
| 2 | |||
| 3 | from setuptools import setup | ||
| 4 | |||
| 5 | 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 @@ | |||
| 1 | """pyotp2289 sphinx configuration""" | ||
| 1 | # Configuration file for the Sphinx documentation builder. | 2 | # Configuration file for the Sphinx documentation builder. |
| 2 | # | 3 | # |
| 3 | # For the full list of built-in configuration values, see the documentation: | 4 | # For the full list of built-in configuration values, see the documentation: |
| @@ -7,20 +8,16 @@ | |||
| 7 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information | 8 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information |
| 8 | 9 | ||
| 9 | project = 'pyotp2289' | 10 | project = 'pyotp2289' |
| 10 | copyright = '2020-2023, Simeon Simeonov' | 11 | copyright = '2020-2026, Simeon Simeonov' # noqa: A001 |
| 11 | author = 'Simeon Simeonov' | 12 | author = 'Simeon Simeonov' |
| 12 | 13 | ||
| 13 | version = '1.2.1' | 14 | version = '2.0.0' |
| 14 | release = '1.2.1' | 15 | release = '2.0.0' |
| 15 | 16 | ||
| 16 | # -- General configuration --------------------------------------------------- | 17 | # -- General configuration --------------------------------------------------- |
| 17 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration | 18 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration |
| 18 | 19 | ||
| 19 | extensions = [ | 20 | extensions = ['sphinx.ext.autodoc', 'sphinx.ext.viewcode', 'sphinx.ext.todo'] |
| 20 | 'sphinx.ext.autodoc', | ||
| 21 | 'sphinx.ext.viewcode', | ||
| 22 | 'sphinx.ext.todo', | ||
| 23 | ] | ||
| 24 | 21 | ||
| 25 | templates_path = ['_templates'] | 22 | templates_path = ['_templates'] |
| 26 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] | 23 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] |
| @@ -32,8 +29,12 @@ language = 'en' | |||
| 32 | 29 | ||
| 33 | html_theme = 'nature' | 30 | html_theme = 'nature' |
| 34 | html_static_path = ['_static'] | 31 | html_static_path = ['_static'] |
| 32 | html_sidebars = {'**': ['globaltoc.html', 'searchbox.html']} | ||
| 35 | 33 | ||
| 36 | # -- Options for todo extension ---------------------------------------------- | 34 | # -- Options for todo extension ---------------------------------------------- |
| 37 | # https://www.sphinx-doc.org/en/master/usage/extensions/todo.html#configuration | 35 | # https://www.sphinx-doc.org/en/master/usage/extensions/todo.html#configuration |
| 38 | 36 | ||
| 39 | todo_include_todos = True | 37 | todo_include_todos = True |
| 38 | |||
| 39 | # Generated by running: sphinx-apidoc with: | ||
| 40 | # -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 @@ | |||
| 1 | # SPDX-License-Identifier: BSD-2-Clause-FreeBSD | 1 | # SPDX-License-Identifier: BSD-2-Clause |
| 2 | # | 2 | # |
| 3 | # Copyright (c) 2020-2025 Simeon Simeonov | 3 | # Copyright (c) 2020-2026 Simeon Simeonov |
| 4 | # All rights reserved. | 4 | # All rights reserved. |
| 5 | # | 5 | # |
| 6 | # Redistribution and use in source and binary forms, with or without | 6 | # Redistribution and use in source and binary forms, with or without |
| @@ -30,6 +30,7 @@ from .generator import ( | |||
| 30 | OTPChallengeError, | 30 | OTPChallengeError, |
| 31 | OTPGenerator, | 31 | OTPGenerator, |
| 32 | OTPGeneratorError, | 32 | OTPGeneratorError, |
| 33 | OTPResponse, | ||
| 33 | ) | 34 | ) |
| 34 | from .server import ( | 35 | from .server import ( |
| 35 | OTPInvalidResponseError, | 36 | OTPInvalidResponseError, |
| @@ -40,11 +41,11 @@ from .server import ( | |||
| 40 | ) | 41 | ) |
| 41 | 42 | ||
| 42 | __author__ = 'Simeon Simeonov' | 43 | __author__ = 'Simeon Simeonov' |
| 43 | __version__ = '1.2.2' | 44 | __version__ = '2.0.0a' |
| 44 | __license__ = 'BSD 2-Clause' | 45 | __license__ = 'BSD 2-Clause' |
| 45 | 46 | ||
| 46 | 47 | ||
| 47 | def int_or_str(value): | 48 | def int_or_str(value: int | str) -> int | str: |
| 48 | """Returns int value of value when possible""" | 49 | """Returns int value of value when possible""" |
| 49 | try: | 50 | try: |
| 50 | return int(value) | 51 | return int(value) |
| @@ -61,6 +62,7 @@ __all__ = [ | |||
| 61 | 'OTPGenerator', | 62 | 'OTPGenerator', |
| 62 | 'OTPGeneratorError', | 63 | 'OTPGeneratorError', |
| 63 | 'OTPInvalidResponseError', | 64 | 'OTPInvalidResponseError', |
| 65 | 'OTPResponse', | ||
| 64 | 'OTPState', | 66 | 'OTPState', |
| 65 | 'OTPStateError', | 67 | 'OTPStateError', |
| 66 | 'OTPStore', | 68 | '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 @@ | |||
| 1 | # SPDX-License-Identifier: BSD-2-Clause-FreeBSD | 1 | # SPDX-License-Identifier: BSD-2-Clause |
| 2 | # | 2 | # |
| 3 | # Copyright (c) 2020-2025 Simeon Simeonov | 3 | # Copyright (c) 2020-2026 Simeon Simeonov |
| 4 | # All rights reserved. | 4 | # All rights reserved. |
| 5 | # | 5 | # |
| 6 | # Redistribution and use in source and binary forms, with or without | 6 | # Redistribution and use in source and binary forms, with or without |
| @@ -36,6 +36,7 @@ import argparse | |||
| 36 | import errno | 36 | import errno |
| 37 | import getpass | 37 | import getpass |
| 38 | import os | 38 | import os |
| 39 | import pathlib | ||
| 39 | import secrets | 40 | import secrets |
| 40 | import string | 41 | import string |
| 41 | import sys | 42 | import sys |
| @@ -43,9 +44,11 @@ import sys | |||
| 43 | import otp2289 | 44 | import otp2289 |
| 44 | 45 | ||
| 45 | 46 | ||
| 46 | def eprint(*arg, **kwargs): | 47 | def eprint( |
| 48 | *value: object, sep: str | None = ' ', end: str | None = '\n' | ||
| 49 | ) -> None: | ||
| 47 | """stdderr print wrapper""" | 50 | """stdderr print wrapper""" |
| 48 | print(*arg, file=sys.stderr, flush=True, **kwargs) | 51 | print(*value, sep=sep, end=end, file=sys.stderr, flush=True) |
| 49 | 52 | ||
| 50 | 53 | ||
| 51 | def generate_otp_response(args: argparse.Namespace) -> str: | 54 | def generate_otp_response(args: argparse.Namespace) -> str: |
| @@ -162,8 +165,8 @@ def get_password(args: argparse.Namespace) -> str: | |||
| 162 | eprint('The passwords do not match') | 165 | eprint('The passwords do not match') |
| 163 | return password | 166 | return password |
| 164 | 167 | ||
| 165 | if os.path.isfile(args.password): | 168 | if pathlib.Path(args.password).is_file(): |
| 166 | with open(args.password, encoding='utf-8') as fp: | 169 | with pathlib.Path(args.password).open(encoding='utf-8') as fp: |
| 167 | return fp.readline().strip() | 170 | return fp.readline().strip() |
| 168 | 171 | ||
| 169 | return args.password | 172 | return args.password |
| @@ -213,7 +216,7 @@ def initiate_new_sequence(args: argparse.Namespace) -> str: | |||
| 213 | return header + generator.generate_otp_hexdigest(args.step) | 216 | return header + generator.generate_otp_hexdigest(args.step) |
| 214 | 217 | ||
| 215 | 218 | ||
| 216 | def main(args=None): | 219 | def main(inargs: list[str] | None = None) -> None: |
| 217 | """the main entry point""" | 220 | """the main entry point""" |
| 218 | parser = argparse.ArgumentParser( | 221 | parser = argparse.ArgumentParser( |
| 219 | prog=__package__, | 222 | prog=__package__, |
| @@ -344,8 +347,10 @@ def main(args=None): | |||
| 344 | version=f'%(prog)s {otp2289.__version__}', | 347 | version=f'%(prog)s {otp2289.__version__}', |
| 345 | help='display program-version and exit', | 348 | help='display program-version and exit', |
| 346 | ) | 349 | ) |
| 347 | args = parser.parse_args(args) | 350 | |
| 351 | args = parser.parse_args(inargs) | ||
| 348 | # handle the password before everything else | 352 | # handle the password before everything else |
| 353 | |||
| 349 | try: | 354 | try: |
| 350 | args.password = get_password(args) | 355 | args.password = get_password(args) |
| 351 | except KeyboardInterrupt: | 356 | except KeyboardInterrupt: |
| @@ -354,6 +359,7 @@ def main(args=None): | |||
| 354 | except Exception as exp: | 359 | except Exception as exp: |
| 355 | eprint(f'Unable to fetch password: {exp}') | 360 | eprint(f'Unable to fetch password: {exp}') |
| 356 | sys.exit(1) | 361 | sys.exit(1) |
| 362 | |||
| 357 | try: | 363 | try: |
| 358 | if args.initiate_new_sequence: | 364 | if args.initiate_new_sequence: |
| 359 | print(initiate_new_sequence(args)) | 365 | 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 @@ | |||
| 1 | # SPDX-License-Identifier: BSD-2-Clause-FreeBSD | 1 | # SPDX-License-Identifier: BSD-2-Clause |
| 2 | # | 2 | # |
| 3 | # Copyright (c) 2020-2025 Simeon Simeonov | 3 | # Copyright (c) 2020-2026 Simeon Simeonov |
| 4 | # All rights reserved. | 4 | # All rights reserved. |
| 5 | # | 5 | # |
| 6 | # Redistribution and use in source and binary forms, with or without | 6 | # Redistribution and use in source and binary forms, with or without |
| @@ -24,12 +24,20 @@ | |||
| 24 | # THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | 24 | # THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. |
| 25 | """A pure Python implementation of the RFC-2289 OTP generator""" | 25 | """A pure Python implementation of the RFC-2289 OTP generator""" |
| 26 | 26 | ||
| 27 | import binascii | ||
| 28 | import hashlib | 27 | import hashlib |
| 29 | import string | 28 | import string |
| 29 | import typing | ||
| 30 | from collections.abc import Iterator | ||
| 30 | 31 | ||
| 31 | OTP_ALGO_MD5 = 1 | 32 | OTP_ALGO_MD5: typing.Final[int] = 1 |
| 32 | OTP_ALGO_SHA1 = 2 | 33 | OTP_ALGO_SHA1: typing.Final[int] = 2 |
| 34 | |||
| 35 | # useful constants | ||
| 36 | OTP2289_BITSTREAM_SIZE: typing.Final[int] = 64 | ||
| 37 | OTP2289_MAX_SEED_LENGTH: typing.Final[int] = 16 | ||
| 38 | OTP2289_MIN_PASSWORD_LENGTH: typing.Final[int] = 10 | ||
| 39 | OTP2289_SHA1_DIGEST_SIZE: typing.Final[int] = 20 | ||
| 40 | OTP2289_TOKENS_COUNT: typing.Final[int] = 6 | ||
| 33 | 41 | ||
| 34 | # the tokens are defined in https://tools.ietf.org/html/rfc2289 # | 42 | # the tokens are defined in https://tools.ietf.org/html/rfc2289 # |
| 35 | RFC1760_TOKENS = [ | 43 | RFC1760_TOKENS = [ |
| @@ -2094,12 +2102,79 @@ class OTPChallengeError(Exception): | |||
| 2094 | """OTPChallengeError class""" | 2102 | """OTPChallengeError class""" |
| 2095 | 2103 | ||
| 2096 | 2104 | ||
| 2105 | class OTPResponse: | ||
| 2106 | """Encapsulates the functionality for a single OTP response""" | ||
| 2107 | |||
| 2108 | def __init__(self, response_bytes: bytes) -> None: | ||
| 2109 | """ | ||
| 2110 | Constructs a single OTP response | ||
| 2111 | |||
| 2112 | :param response_bytes: The response state | ||
| 2113 | :type response_bytes: bytes | ||
| 2114 | """ | ||
| 2115 | self._response_bytes = response_bytes | ||
| 2116 | self._hexdigest = '0x' + response_bytes.hex() | ||
| 2117 | self._words = self.bytes_to_tokens(response_bytes) | ||
| 2118 | |||
| 2119 | def __bytes__(self) -> bytes: | ||
| 2120 | """bytes representation of the object""" | ||
| 2121 | return self._response_bytes | ||
| 2122 | |||
| 2123 | def __hash__(self) -> int: | ||
| 2124 | """Uses the hash value of _response_bytes""" | ||
| 2125 | return hash(self._response_bytes) | ||
| 2126 | |||
| 2127 | @property | ||
| 2128 | def hexdigest(self) -> str: | ||
| 2129 | """Hexdigest representation of the OTP response""" | ||
| 2130 | return self._hexdigest | ||
| 2131 | |||
| 2132 | @property | ||
| 2133 | def response_bytes(self) -> bytes: | ||
| 2134 | """response_bytes read-only property""" | ||
| 2135 | return self._response_bytes | ||
| 2136 | |||
| 2137 | @property | ||
| 2138 | def words(self) -> str: | ||
| 2139 | """Tokens representation of the OTP response""" | ||
| 2140 | return self._words | ||
| 2141 | |||
| 2142 | @staticmethod | ||
| 2143 | def bytes_to_tokens(hash_bytes: bytes) -> str: | ||
| 2144 | """ | ||
| 2145 | Returns a 6 words token from bytes as specified by RFC-2289. | ||
| 2146 | |||
| 2147 | :param hash_bytes: The input bytes | ||
| 2148 | :type hash_bytes: bytes | ||
| 2149 | |||
| 2150 | :return: 6 words tokens | ||
| 2151 | :rtype: str | ||
| 2152 | """ | ||
| 2153 | bit_stream = ''.join([f'{byte:0>8b}' for byte in hash_bytes]) | ||
| 2154 | bit_pair_sum = OTPGenerator.bit_pair_sum(bit_stream) | ||
| 2155 | tokens = [] | ||
| 2156 | tokens.append(RFC1760_TOKENS[int(bit_stream[:11], 2)]) | ||
| 2157 | tokens.append(RFC1760_TOKENS[int(bit_stream[11:22], 2)]) | ||
| 2158 | tokens.append(RFC1760_TOKENS[int(bit_stream[22:33], 2)]) | ||
| 2159 | tokens.append(RFC1760_TOKENS[int(bit_stream[33:44], 2)]) | ||
| 2160 | tokens.append(RFC1760_TOKENS[int(bit_stream[44:55], 2)]) | ||
| 2161 | tokens.append( | ||
| 2162 | RFC1760_TOKENS[ | ||
| 2163 | int(bit_stream[55:64] + f'{bit_pair_sum:0>8b}'[-2:], 2) | ||
| 2164 | ] | ||
| 2165 | ) | ||
| 2166 | return ' '.join(tokens) | ||
| 2167 | |||
| 2168 | |||
| 2097 | class OTPGenerator: | 2169 | class OTPGenerator: |
| 2098 | """OTPGenerator class""" | 2170 | """OTPGenerator class""" |
| 2099 | 2171 | ||
| 2100 | def __init__( | 2172 | def __init__( |
| 2101 | self, password: bytes, seed: str = '', hash_algo=OTP_ALGO_MD5 | 2173 | self, |
| 2102 | ): | 2174 | password: bytes, |
| 2175 | seed: str = '', | ||
| 2176 | hash_algo: int | str = OTP_ALGO_MD5, | ||
| 2177 | ) -> None: | ||
| 2103 | """ | 2178 | """ |
| 2104 | Constructs an OTPGenerator object with a given password and seed. | 2179 | Constructs an OTPGenerator object with a given password and seed. |
| 2105 | 2180 | ||
| @@ -2121,11 +2196,14 @@ class OTPGenerator: | |||
| 2121 | self._hash_algo = self.validate_hash_algo(hash_algo) | 2196 | self._hash_algo = self.validate_hash_algo(hash_algo) |
| 2122 | if not isinstance(password, bytes): | 2197 | if not isinstance(password, bytes): |
| 2123 | raise OTPGeneratorError('Password must be a byte-string') | 2198 | raise OTPGeneratorError('Password must be a byte-string') |
| 2124 | if len(password) < 10: | 2199 | if len(password) < OTP2289_MIN_PASSWORD_LENGTH: |
| 2125 | raise OTPGeneratorError('Password must be longer than 10 bytes') | 2200 | raise OTPGeneratorError( |
| 2201 | f'Password must be longer than {OTP2289_MIN_PASSWORD_LENGTH} ' | ||
| 2202 | 'bytes' | ||
| 2203 | ) | ||
| 2126 | self._password = password | 2204 | self._password = password |
| 2127 | 2205 | ||
| 2128 | def __repr__(self): | 2206 | def __repr__(self) -> str: |
| 2129 | """repr implementation""" | 2207 | """repr implementation""" |
| 2130 | return ( | 2208 | return ( |
| 2131 | f'{self.__class__} at {id(self)} (seed={self._seed}, ' | 2209 | f'{self.__class__} at {id(self)} (seed={self._seed}, ' |
| @@ -2145,41 +2223,17 @@ class OTPGenerator: | |||
| 2145 | """ | 2223 | """ |
| 2146 | if not isinstance(bit_stream, str): | 2224 | if not isinstance(bit_stream, str): |
| 2147 | raise OTPGeneratorError('bit_stream must be of type str') | 2225 | raise OTPGeneratorError('bit_stream must be of type str') |
| 2148 | if len(bit_stream) != 64: | 2226 | if len(bit_stream) != OTP2289_BITSTREAM_SIZE: |
| 2149 | raise OTPGeneratorError('bit_stream must be of size 64') | 2227 | raise OTPGeneratorError( |
| 2228 | f'bit_stream must be of size {OTP2289_BITSTREAM_SIZE}' | ||
| 2229 | ) | ||
| 2150 | value = 0 | 2230 | value = 0 |
| 2151 | for pair in zip(bit_stream[::2], bit_stream[1::2], strict=True): | 2231 | for pair in zip(bit_stream[::2], bit_stream[1::2], strict=True): |
| 2152 | value += int(''.join(pair), 2) | 2232 | value += int(''.join(pair), 2) |
| 2153 | return value | 2233 | return value |
| 2154 | 2234 | ||
| 2155 | @staticmethod | 2235 | @staticmethod |
| 2156 | def bytes_to_tokens(hash_bytes: bytes) -> str: | 2236 | def get_tokens_from_challenge(challenge: str) -> tuple[str, str, int]: |
| 2157 | """ | ||
| 2158 | Returns a 6 words token from bytes as specified by RFC-2289. | ||
| 2159 | |||
| 2160 | :param hash_bytes: The input bytes | ||
| 2161 | :type hash_bytes: bytes | ||
| 2162 | |||
| 2163 | :return: 6 words tokens | ||
| 2164 | :rtype: str | ||
| 2165 | """ | ||
| 2166 | bit_stream = ''.join([f'{byte:0>8b}' for byte in hash_bytes]) | ||
| 2167 | bit_pair_sum = OTPGenerator.bit_pair_sum(bit_stream) | ||
| 2168 | tokens = [] | ||
| 2169 | tokens.append(RFC1760_TOKENS[int(bit_stream[:11], 2)]) | ||
| 2170 | tokens.append(RFC1760_TOKENS[int(bit_stream[11:22], 2)]) | ||
| 2171 | tokens.append(RFC1760_TOKENS[int(bit_stream[22:33], 2)]) | ||
| 2172 | tokens.append(RFC1760_TOKENS[int(bit_stream[33:44], 2)]) | ||
| 2173 | tokens.append(RFC1760_TOKENS[int(bit_stream[44:55], 2)]) | ||
| 2174 | tokens.append( | ||
| 2175 | RFC1760_TOKENS[ | ||
| 2176 | int(bit_stream[55:64] + f'{bit_pair_sum:0>8b}'[-2:], 2) | ||
| 2177 | ] | ||
| 2178 | ) | ||
| 2179 | return ' '.join(tokens) | ||
| 2180 | |||
| 2181 | @staticmethod | ||
| 2182 | def get_tokens_from_challenge(challenge: str) -> tuple: | ||
| 2183 | """ | 2237 | """ |
| 2184 | Returns tokens (seed, hash_algo and step) from a challenge string. | 2238 | Returns tokens (seed, hash_algo and step) from a challenge string. |
| 2185 | 2239 | ||
| @@ -2218,9 +2272,10 @@ class OTPGenerator: | |||
| 2218 | """ | 2272 | """ |
| 2219 | if not isinstance(sha1_digest, bytes): | 2273 | if not isinstance(sha1_digest, bytes): |
| 2220 | raise OTPGeneratorError('sha1_digest must be of type bytes') | 2274 | raise OTPGeneratorError('sha1_digest must be of type bytes') |
| 2221 | if len(sha1_digest) != 20: | 2275 | if len(sha1_digest) != OTP2289_SHA1_DIGEST_SIZE: |
| 2222 | raise OTPGeneratorError( | 2276 | raise OTPGeneratorError( |
| 2223 | 'sha1_digest must be 160 bits (20 bytes) long' | 2277 | f'sha1_digest must be {OTP2289_SHA1_DIGEST_SIZE * 2} bits ' |
| 2278 | f'({OTP2289_SHA1_DIGEST_SIZE} bytes) long' | ||
| 2224 | ) | 2279 | ) |
| 2225 | digested = list(5 * b'i') # 5 bytes (40 bits) | 2280 | digested = list(5 * b'i') # 5 bytes (40 bits) |
| 2226 | result = list(8 * b'x') # 8 bytes (64 bits) | 2281 | result = list(8 * b'x') # 8 bytes (64 bits) |
| @@ -2295,8 +2350,11 @@ class OTPGenerator: | |||
| 2295 | if not isinstance(tokens_str, str): | 2350 | if not isinstance(tokens_str, str): |
| 2296 | raise OTPGeneratorError('tokens must be a str') | 2351 | raise OTPGeneratorError('tokens must be a str') |
| 2297 | tokens = tokens_str.split() | 2352 | tokens = tokens_str.split() |
| 2298 | if len(tokens) != 6: | 2353 | if len(tokens) != OTP2289_TOKENS_COUNT: |
| 2299 | raise OTPGeneratorError('Tokens-string does not contain 6 tokens') | 2354 | raise OTPGeneratorError( |
| 2355 | f'Tokens-string does not contain {OTP2289_SHA1_DIGEST_SIZE} ' | ||
| 2356 | 'tokens' | ||
| 2357 | ) | ||
| 2300 | token_ints = [] | 2358 | token_ints = [] |
| 2301 | try: | 2359 | try: |
| 2302 | token_ints = [ | 2360 | token_ints = [ |
| @@ -2325,7 +2383,7 @@ class OTPGenerator: | |||
| 2325 | return int(bit_stream[:64], 2).to_bytes(8, 'big') | 2383 | return int(bit_stream[:64], 2).to_bytes(8, 'big') |
| 2326 | 2384 | ||
| 2327 | @staticmethod | 2385 | @staticmethod |
| 2328 | def validate_hash_algo(hash_algo) -> str: | 2386 | def validate_hash_algo(hash_algo: int | str) -> str: |
| 2329 | """ | 2387 | """ |
| 2330 | Validates the provided hash-algorithm. | 2388 | Validates the provided hash-algorithm. |
| 2331 | 2389 | ||
| @@ -2342,7 +2400,7 @@ class OTPGenerator: | |||
| 2342 | raise OTPGeneratorError( | 2400 | raise OTPGeneratorError( |
| 2343 | 'hash_algo is not among the known algorithms' | 2401 | 'hash_algo is not among the known algorithms' |
| 2344 | ) | 2402 | ) |
| 2345 | hash_algo = _ALGO_DICT.get(hash_algo) | 2403 | hash_algo = _ALGO_DICT[hash_algo] |
| 2346 | if not isinstance(hash_algo, str): | 2404 | if not isinstance(hash_algo, str): |
| 2347 | raise OTPGeneratorError('hash_algo must be an int or a str') | 2405 | raise OTPGeneratorError('hash_algo must be an int or a str') |
| 2348 | if hash_algo not in hashlib.algorithms_available: | 2406 | if hash_algo not in hashlib.algorithms_available: |
| @@ -2367,9 +2425,10 @@ class OTPGenerator: | |||
| 2367 | """ | 2425 | """ |
| 2368 | if not isinstance(seed, str): | 2426 | if not isinstance(seed, str): |
| 2369 | raise OTPGeneratorError('Seed must be a string') | 2427 | raise OTPGeneratorError('Seed must be a string') |
| 2370 | if not seed or len(seed) > 16: | 2428 | if not seed or len(seed) > OTP2289_MAX_SEED_LENGTH: |
| 2371 | raise OTPGeneratorError( | 2429 | raise OTPGeneratorError( |
| 2372 | 'The seed MUST be of 1 to 16 characters in length' | 2430 | f'The seed MUST be of 1 to {OTP2289_MAX_SEED_LENGTH} ' |
| 2431 | 'characters in length' | ||
| 2373 | ) | 2432 | ) |
| 2374 | for char in seed: | 2433 | for char in seed: |
| 2375 | if char not in string.ascii_letters + string.digits: | 2434 | if char not in string.ascii_letters + string.digits: |
| @@ -2407,7 +2466,8 @@ class OTPGenerator: | |||
| 2407 | :return: Hexdigest for the given step | 2466 | :return: Hexdigest for the given step |
| 2408 | :rtype: str | 2467 | :rtype: str |
| 2409 | """ | 2468 | """ |
| 2410 | return '0x' + binascii.hexlify(self._generate_otp_bytes(step)).decode() | 2469 | response = OTPResponse(self._generate_otp_bytes(step)) |
| 2470 | return response.hexdigest | ||
| 2411 | 2471 | ||
| 2412 | def generate_otp_hexdigest_from_challenge(self, challenge: str) -> str: | 2472 | def generate_otp_hexdigest_from_challenge(self, challenge: str) -> str: |
| 2413 | """ | 2473 | """ |
| @@ -2440,7 +2500,8 @@ class OTPGenerator: | |||
| 2440 | :return: Six words (separated by single space) token for the given step | 2500 | :return: Six words (separated by single space) token for the given step |
| 2441 | :rtype: str | 2501 | :rtype: str |
| 2442 | """ | 2502 | """ |
| 2443 | return self.bytes_to_tokens(self._generate_otp_bytes(step)) | 2503 | response = OTPResponse(self._generate_otp_bytes(step)) |
| 2504 | return response.words | ||
| 2444 | 2505 | ||
| 2445 | def generate_otp_words_from_challenge(self, challenge: str) -> str: | 2506 | def generate_otp_words_from_challenge(self, challenge: str) -> str: |
| 2446 | """ | 2507 | """ |
| @@ -2463,7 +2524,9 @@ class OTPGenerator: | |||
| 2463 | self._hash_algo = self.validate_hash_algo(hash_algo) | 2524 | self._hash_algo = self.validate_hash_algo(hash_algo) |
| 2464 | return self.generate_otp_words(step) | 2525 | return self.generate_otp_words(step) |
| 2465 | 2526 | ||
| 2466 | def hexdigest_range(self, start: int = 499, stop: int = 0): | 2527 | def hexdigest_range( |
| 2528 | self, start: int = 499, stop: int = 0 | ||
| 2529 | ) -> Iterator[str]: | ||
| 2467 | """ | 2530 | """ |
| 2468 | Returns an iterator that providing hexdigests corresponding to steps | 2531 | Returns an iterator that providing hexdigests corresponding to steps |
| 2469 | from `start` to and including `stop`. | 2532 | from `start` to and including `stop`. |
| @@ -2484,7 +2547,7 @@ class OTPGenerator: | |||
| 2484 | for step in range(start, stop - 1, -1): | 2547 | for step in range(start, stop - 1, -1): |
| 2485 | yield self.generate_otp_hexdigest(step) | 2548 | yield self.generate_otp_hexdigest(step) |
| 2486 | 2549 | ||
| 2487 | def words_range(self, start: int = 499, stop: int = 0): | 2550 | def words_range(self, start: int = 499, stop: int = 0) -> Iterator[str]: |
| 2488 | """ | 2551 | """ |
| 2489 | Returns an iterator that providing the words corresponding to steps | 2552 | Returns an iterator that providing the words corresponding to steps |
| 2490 | from `start` to and including `stop`. | 2553 | 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 @@ | |||
| 1 | # SPDX-License-Identifier: BSD-2-Clause-FreeBSD | 1 | # SPDX-License-Identifier: BSD-2-Clause |
| 2 | # | 2 | # |
| 3 | # Copyright (c) 2020-2025 Simeon Simeonov | 3 | # Copyright (c) 2020-2026 Simeon Simeonov |
| 4 | # All rights reserved. | 4 | # All rights reserved. |
| 5 | # | 5 | # |
| 6 | # Redistribution and use in source and binary forms, with or without | 6 | # Redistribution and use in source and binary forms, with or without |
| @@ -24,11 +24,18 @@ | |||
| 24 | # THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | 24 | # THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. |
| 25 | """A pure Python implementation of the RFC-2289 OTP server""" | 25 | """A pure Python implementation of the RFC-2289 OTP server""" |
| 26 | 26 | ||
| 27 | import binascii | 27 | from __future__ import annotations |
| 28 | |||
| 28 | import hashlib | 29 | import hashlib |
| 30 | import typing | ||
| 31 | |||
| 32 | if typing.TYPE_CHECKING: | ||
| 33 | from collections.abc import Iterator | ||
| 29 | 34 | ||
| 30 | from .generator import OTP_ALGO_MD5, OTPGenerator, OTPGeneratorError | 35 | from .generator import OTP_ALGO_MD5, OTPGenerator, OTPGeneratorError |
| 31 | 36 | ||
| 37 | OTP2289_HEX_DIGEST_SIZE: typing.Final[int] = 16 | ||
| 38 | |||
| 32 | 39 | ||
| 33 | class OTPStateError(Exception): | 40 | class OTPStateError(Exception): |
| 34 | """OTPStateError class""" | 41 | """OTPStateError class""" |
| @@ -52,8 +59,12 @@ class OTPState: | |||
| 52 | """ | 59 | """ |
| 53 | 60 | ||
| 54 | def __init__( | 61 | def __init__( |
| 55 | self, ot_hex: str, current_step: int, seed: str, hash_algo=OTP_ALGO_MD5 | 62 | self, |
| 56 | ): | 63 | ot_hex: str | None, |
| 64 | current_step: int, | ||
| 65 | seed: str, | ||
| 66 | hash_algo: int | str = OTP_ALGO_MD5, | ||
| 67 | ) -> None: | ||
| 57 | """ | 68 | """ |
| 58 | Constructs an OTPState object with the given arguments. | 69 | Constructs an OTPState object with the given arguments. |
| 59 | 70 | ||
| @@ -79,13 +90,14 @@ class OTPState: | |||
| 79 | self._hash_algo = OTPGenerator.validate_hash_algo(hash_algo) | 90 | self._hash_algo = OTPGenerator.validate_hash_algo(hash_algo) |
| 80 | self._step = OTPGenerator.validate_step(current_step) | 91 | self._step = OTPGenerator.validate_step(current_step) |
| 81 | except OTPGeneratorError as exp: | 92 | except OTPGeneratorError as exp: |
| 82 | raise OTPStateError(exp.args[0]) from None | 93 | raise OTPStateError(exp.args[0]) from exp |
| 94 | |||
| 83 | self._current_digest = None | 95 | self._current_digest = None |
| 84 | if ot_hex is not None: | 96 | if ot_hex is not None: |
| 85 | self._current_digest = self.validate_hex(ot_hex) | 97 | self._current_digest = self.validate_hex(ot_hex) |
| 86 | self._new_digest_hex = None # set upon a successful validation | 98 | self._new_digest_hex = None # set upon a successful validation |
| 87 | 99 | ||
| 88 | def __repr__(self): | 100 | def __repr__(self) -> str: |
| 89 | """repr implementation""" | 101 | """repr implementation""" |
| 90 | return ( | 102 | return ( |
| 91 | f'{self.__class__} at {id(self)} ' | 103 | f'{self.__class__} at {id(self)} ' |
| @@ -102,7 +114,7 @@ class OTPState: | |||
| 102 | return f'otp-{self._hash_algo} {self._step} {self._seed} ' | 114 | return f'otp-{self._hash_algo} {self._step} {self._seed} ' |
| 103 | 115 | ||
| 104 | @property | 116 | @property |
| 105 | def current_digest(self) -> bytes: | 117 | def current_digest(self) -> bytes | None: |
| 106 | """current_digest-property""" | 118 | """current_digest-property""" |
| 107 | return self._current_digest | 119 | return self._current_digest |
| 108 | 120 | ||
| @@ -116,7 +128,7 @@ class OTPState: | |||
| 116 | """ot_hex-property""" | 128 | """ot_hex-property""" |
| 117 | if self._current_digest is None: | 129 | if self._current_digest is None: |
| 118 | return '' | 130 | return '' |
| 119 | return binascii.hexlify(self._current_digest).decode() | 131 | return self._current_digest.hex() |
| 120 | 132 | ||
| 121 | @property | 133 | @property |
| 122 | def seed(self) -> str: | 134 | def seed(self) -> str: |
| @@ -134,7 +146,7 @@ class OTPState: | |||
| 134 | return bool(self._new_digest_hex) | 146 | return bool(self._new_digest_hex) |
| 135 | 147 | ||
| 136 | @classmethod | 148 | @classmethod |
| 137 | def from_dict(cls, dict_obj: dict): | 149 | def from_dict(cls, dict_obj: dict) -> OTPState: |
| 138 | """ | 150 | """ |
| 139 | Returns an OTPState object from the dict-object | 151 | Returns an OTPState object from the dict-object |
| 140 | 152 | ||
| @@ -196,17 +208,17 @@ class OTPState: | |||
| 196 | if ot_hex.startswith('0x'): | 208 | if ot_hex.startswith('0x'): |
| 197 | ot_hex = ot_hex[2:] | 209 | ot_hex = ot_hex[2:] |
| 198 | ot_hex = ot_hex.strip().lower() | 210 | ot_hex = ot_hex.strip().lower() |
| 199 | if len(ot_hex) != 16: | 211 | if len(ot_hex) != OTP2289_HEX_DIGEST_SIZE: |
| 200 | raise OTPStateError( | 212 | raise OTPStateError( |
| 201 | 'The length of the hex should be 16 ' | 213 | f'The length of the hex should be {OTP2289_HEX_DIGEST_SIZE} ' |
| 202 | '(representing 64 bits digest)' | 214 | '(representing 64 bits digest)' |
| 203 | ) | 215 | ) |
| 204 | try: | 216 | try: |
| 205 | return binascii.unhexlify(ot_hex) | 217 | return bytes.fromhex(ot_hex) |
| 206 | except binascii.Error: | 218 | except ValueError: |
| 207 | raise OTPStateError('Invalid OT-hex') from None | 219 | raise OTPStateError('Invalid OT-hex') from None |
| 208 | 220 | ||
| 209 | def get_next_state(self): | 221 | def get_next_state(self) -> OTPState | None: |
| 210 | """ | 222 | """ |
| 211 | Returns the next state for a validated OTPState. | 223 | Returns the next state for a validated OTPState. |
| 212 | 224 | ||
| @@ -223,7 +235,7 @@ class OTPState: | |||
| 223 | ) | 235 | ) |
| 224 | 236 | ||
| 225 | def response_validates( | 237 | def response_validates( |
| 226 | self, response: str, store_valid_response: str = True | 238 | self, response: str, *, store_valid_response: bool = True |
| 227 | ) -> bool: | 239 | ) -> bool: |
| 228 | """ | 240 | """ |
| 229 | Validates the incoming response as specified by RFC-2289. | 241 | Validates the incoming response as specified by RFC-2289. |
| @@ -251,9 +263,7 @@ class OTPState: | |||
| 251 | == self._current_digest | 263 | == self._current_digest |
| 252 | ): | 264 | ): |
| 253 | if store_valid_response: | 265 | if store_valid_response: |
| 254 | self._new_digest_hex = binascii.hexlify( | 266 | self._new_digest_hex = response_bytes.hex() |
| 255 | response_bytes | ||
| 256 | ).decode() | ||
| 257 | return True | 267 | return True |
| 258 | return False | 268 | return False |
| 259 | if self._hash_algo == 'sha1': | 269 | if self._hash_algo == 'sha1': |
| @@ -266,9 +276,7 @@ class OTPState: | |||
| 266 | == self._current_digest | 276 | == self._current_digest |
| 267 | ): | 277 | ): |
| 268 | if store_valid_response: | 278 | if store_valid_response: |
| 269 | self._new_digest_hex = binascii.hexlify( | 279 | self._new_digest_hex = response_bytes.hex() |
| 270 | response_bytes | ||
| 271 | ).decode() | ||
| 272 | return True | 280 | return True |
| 273 | return False | 281 | return False |
| 274 | # this should not happen since the hash_algo is validated by the caller | 282 | # this should not happen since the hash_algo is validated by the caller |
| @@ -283,9 +291,11 @@ class OTPState: | |||
| 283 | :return: The dict representation of the object | 291 | :return: The dict representation of the object |
| 284 | :rtype: dict | 292 | :rtype: dict |
| 285 | """ | 293 | """ |
| 286 | ot_hex = self._current_digest | 294 | ot_hex = ( |
| 287 | if ot_hex is not None: | 295 | self._current_digest.hex() |
| 288 | ot_hex = binascii.hexlify(self._current_digest).decode() | 296 | if self._current_digest is not None |
| 297 | else None | ||
| 298 | ) | ||
| 289 | return { | 299 | return { |
| 290 | 'ot_hex': ot_hex, | 300 | 'ot_hex': ot_hex, |
| 291 | 'current_step': self._step, | 301 | 'current_step': self._step, |
| @@ -304,27 +314,27 @@ class OTPStore: | |||
| 304 | The class could serve as a base class when implementing store backends. | 314 | The class could serve as a base class when implementing store backends. |
| 305 | """ | 315 | """ |
| 306 | 316 | ||
| 307 | def __init__(self, data=None): | 317 | def __init__(self, data: dict | None = None) -> None: |
| 308 | """ | 318 | """ |
| 309 | Constructs an OTPStore object from data | 319 | Constructs an OTPStore object from data |
| 310 | 320 | ||
| 311 | :param data: The data object, defaults to None | 321 | :param data: The data dict, defaults to None |
| 312 | :type data: object or None | 322 | :type data: dict or None |
| 313 | """ | 323 | """ |
| 314 | self._data = {} # {key1: {state1-data...}, key2: {state2-data...}} | 324 | self._data = {} # {key1: {state1-data...}, key2: {state2-data...}} |
| 315 | self._states = {} # OTPState: (domain, key) - dict | 325 | self._states = {} # OTPState: (domain, key) - dict |
| 316 | if data is not None: | 326 | if data is not None: |
| 317 | self._add_data(data) | 327 | self._add_data(data) |
| 318 | 328 | ||
| 319 | def __contains__(self, state): | 329 | def __contains__(self, state: OTPState) -> bool: |
| 320 | """membership test""" | 330 | """membership test""" |
| 321 | return state in self._states | 331 | return state in self._states |
| 322 | 332 | ||
| 323 | def __iter__(self): | 333 | def __iter__(self) -> Iterator: |
| 324 | """iterator for OTPStore""" | 334 | """iterator for OTPStore""" |
| 325 | return iter(self._data) | 335 | return iter(self._data) |
| 326 | 336 | ||
| 327 | def __len__(self): | 337 | def __len__(self) -> int: |
| 328 | """len() implementation""" | 338 | """len() implementation""" |
| 329 | return len(self._data) | 339 | return len(self._data) |
| 330 | 340 | ||
| @@ -348,7 +358,7 @@ class OTPStore: | |||
| 348 | """ | 358 | """ |
| 349 | return self._states | 359 | return self._states |
| 350 | 360 | ||
| 351 | def add_state(self, key: str, state: OTPState): | 361 | def add_state(self, key: str, state: OTPState) -> None: |
| 352 | """ | 362 | """ |
| 353 | Adds an OTPState object with a given key. | 363 | Adds an OTPState object with a given key. |
| 354 | 364 | ||
| @@ -367,11 +377,13 @@ class OTPStore: | |||
| 367 | self._data[key] = state | 377 | self._data[key] = state |
| 368 | self._states[state] = key | 378 | self._states[state] = key |
| 369 | 379 | ||
| 370 | def get(self, key, default=None): | 380 | def get( |
| 381 | self, key: str, default: OTPState | None = None | ||
| 382 | ) -> OTPState | None: | ||
| 371 | """A wrapper for dict.get""" | 383 | """A wrapper for dict.get""" |
| 372 | return self._data.get(key, default) | 384 | return self._data.get(key, default) |
| 373 | 385 | ||
| 374 | def items(self): | 386 | def items(self) -> typing.ItemsView: |
| 375 | """A wrapper for dict.items""" | 387 | """A wrapper for dict.items""" |
| 376 | return self._data.items() | 388 | return self._data.items() |
| 377 | 389 | ||
| @@ -396,7 +408,7 @@ class OTPStore: | |||
| 396 | return state | 408 | return state |
| 397 | 409 | ||
| 398 | def response_validates( | 410 | def response_validates( |
| 399 | self, key: str, response: str, store_valid_response: bool = True | 411 | self, key: str, response: str, *, store_valid_response: bool = True |
| 400 | ) -> bool: | 412 | ) -> bool: |
| 401 | """ | 413 | """ |
| 402 | A method that wraps around OTPState.response_validates and | 414 | A method that wraps around OTPState.response_validates and |
| @@ -424,7 +436,9 @@ class OTPStore: | |||
| 424 | :rtype: bool | 436 | :rtype: bool |
| 425 | """ | 437 | """ |
| 426 | state = self._data[key] | 438 | state = self._data[key] |
| 427 | rvalue = state.response_validates(response, store_valid_response) | 439 | rvalue = state.response_validates( |
| 440 | response, store_valid_response=store_valid_response | ||
| 441 | ) | ||
| 428 | if rvalue and store_valid_response: | 442 | if rvalue and store_valid_response: |
| 429 | next_state = state.get_next_state() | 443 | next_state = state.get_next_state() |
| 430 | self._data[key] = next_state | 444 | self._data[key] = next_state |
| @@ -443,7 +457,7 @@ class OTPStore: | |||
| 443 | """ | 457 | """ |
| 444 | return {key: state.to_dict() for key, state in self._data.items()} | 458 | return {key: state.to_dict() for key, state in self._data.items()} |
| 445 | 459 | ||
| 446 | def _add_data(self, dict_obj: dict) -> dict: | 460 | def _add_data(self, dict_obj: dict) -> None: |
| 447 | """ | 461 | """ |
| 448 | Adds data from a dict object (dict_obj). | 462 | Adds data from a dict object (dict_obj). |
| 449 | 463 | ||
diff --git a/test/test_generator.py b/tests/test_generator.py index 4329ae4..08947e5 100644 --- a/test/test_generator.py +++ b/tests/test_generator.py | |||
| @@ -1,6 +1,6 @@ | |||
| 1 | # SPDX-License-Identifier: BSD-2-Clause-FreeBSD | 1 | # SPDX-License-Identifier: BSD-2-Clause |
| 2 | # | 2 | # |
| 3 | # Copyright (c) 2020-2025, Simeon Simeonov | 3 | # Copyright (c) 2020-2026, Simeon Simeonov |
| 4 | # All rights reserved. | 4 | # All rights reserved. |
| 5 | # | 5 | # |
| 6 | # Redistribution and use in source and binary forms, with or without | 6 | # Redistribution and use in source and binary forms, with or without |
| @@ -23,20 +23,19 @@ | |||
| 23 | # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF | 23 | # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF |
| 24 | # THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | 24 | # THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. |
| 25 | """Tests for otp2289.generator""" | 25 | """Tests for otp2289.generator""" |
| 26 | |||
| 26 | import pytest | 27 | import pytest |
| 27 | 28 | ||
| 28 | import otp2289 | 29 | import otp2289 |
| 29 | 30 | ||
| 30 | 31 | ||
| 31 | def test_caller_exceptions(): | 32 | def test_caller_exceptions() -> None: |
| 32 | """Tests the exceptions when calling an initialized object""" | 33 | """Tests the exceptions when calling an initialized object""" |
| 33 | gen = otp2289.OTPGenerator( | 34 | gen = otp2289.OTPGenerator( |
| 34 | 'This is a test.'.encode(), | 35 | b'This is a test.', 'TeSt', otp2289.OTP_ALGO_MD5 |
| 35 | 'TeSt', | ||
| 36 | otp2289.OTP_ALGO_MD5, | ||
| 37 | ) | 36 | ) |
| 38 | with pytest.raises(otp2289.OTPGeneratorError) as exc_info: | 37 | with pytest.raises(otp2289.OTPGeneratorError) as exc_info: |
| 39 | gen.generate_otp_words('3') | 38 | gen.generate_otp_words('3') # ty: ignore[invalid-argument-type] |
| 40 | assert exc_info.type is otp2289.OTPGeneratorError | 39 | assert exc_info.type is otp2289.OTPGeneratorError |
| 41 | assert exc_info.value.args[0] == 'Step value MUST be an int' | 40 | assert exc_info.value.args[0] == 'Step value MUST be an int' |
| 42 | with pytest.raises(otp2289.OTPGeneratorError) as exc_info: | 41 | with pytest.raises(otp2289.OTPGeneratorError) as exc_info: |
| @@ -44,7 +43,9 @@ def test_caller_exceptions(): | |||
| 44 | assert exc_info.type is otp2289.OTPGeneratorError | 43 | assert exc_info.type is otp2289.OTPGeneratorError |
| 45 | assert exc_info.value.args[0] == 'Step value MUST be >= 0' | 44 | assert exc_info.value.args[0] == 'Step value MUST be >= 0' |
| 46 | with pytest.raises(otp2289.OTPChallengeError) as exc_info: | 45 | with pytest.raises(otp2289.OTPChallengeError) as exc_info: |
| 47 | gen.generate_otp_hexdigest_from_challenge(b'md5 fbd TeSt') | 46 | gen.generate_otp_hexdigest_from_challenge( |
| 47 | b'md5 fbd TeSt' # ty: ignore[invalid-argument-type] | ||
| 48 | ) | ||
| 48 | assert exc_info.type is otp2289.OTPChallengeError | 49 | assert exc_info.type is otp2289.OTPChallengeError |
| 49 | assert exc_info.value.args[0] == 'Challenge must be str' | 50 | assert exc_info.value.args[0] == 'Challenge must be str' |
| 50 | with pytest.raises(otp2289.OTPChallengeError) as exc_info: | 51 | with pytest.raises(otp2289.OTPChallengeError) as exc_info: |
| @@ -57,24 +58,22 @@ def test_caller_exceptions(): | |||
| 57 | assert exc_info.value.args[0] == 'Invalid challenge' | 58 | assert exc_info.value.args[0] == 'Invalid challenge' |
| 58 | 59 | ||
| 59 | 60 | ||
| 60 | def test_constructor_exceptions(): | 61 | def test_constructor_exceptions() -> None: |
| 61 | """ | 62 | """ |
| 62 | Tests the exceptions when initializing a new object (in the constructor) | 63 | Tests the exceptions when initializing a new object (in the constructor) |
| 63 | """ | 64 | """ |
| 64 | # test the otp2289.OTPGenerator __init__ and validators | 65 | # test the otp2289.OTPGenerator __init__ and validators |
| 65 | with pytest.raises(otp2289.OTPGeneratorError) as exc_info: | 66 | with pytest.raises(otp2289.OTPGeneratorError) as exc_info: |
| 66 | otp2289.OTPGenerator( | 67 | otp2289.OTPGenerator( |
| 67 | 'This is a test.'.encode(), | 68 | b'This is a test.', |
| 68 | 'TeStø'.encode(), | 69 | 'TeStø'.encode(), # ty: ignore[invalid-argument-type] |
| 69 | otp2289.OTP_ALGO_MD5, | 70 | otp2289.OTP_ALGO_MD5, |
| 70 | ) | 71 | ) |
| 71 | assert exc_info.type is otp2289.OTPGeneratorError | 72 | assert exc_info.type is otp2289.OTPGeneratorError |
| 72 | assert exc_info.value.args[0] == 'Seed must be a string' | 73 | assert exc_info.value.args[0] == 'Seed must be a string' |
| 73 | with pytest.raises(otp2289.OTPGeneratorError) as exc_info: | 74 | with pytest.raises(otp2289.OTPGeneratorError) as exc_info: |
| 74 | otp2289.OTPGenerator( | 75 | otp2289.OTPGenerator( |
| 75 | 'This is a test.'.encode(), | 76 | b'This is a test.', 'TeStøtEsTteSTteStTest', otp2289.OTP_ALGO_SHA1 |
| 76 | 'TeStøtEsTteSTteStTest', | ||
| 77 | otp2289.OTP_ALGO_SHA1, | ||
| 78 | ) | 77 | ) |
| 79 | assert exc_info.type is otp2289.OTPGeneratorError | 78 | assert exc_info.type is otp2289.OTPGeneratorError |
| 80 | assert exc_info.value.args[0] == ( | 79 | assert exc_info.value.args[0] == ( |
| @@ -82,58 +81,48 @@ def test_constructor_exceptions(): | |||
| 82 | ) | 81 | ) |
| 83 | with pytest.raises(otp2289.OTPGeneratorError) as exc_info: | 82 | with pytest.raises(otp2289.OTPGeneratorError) as exc_info: |
| 84 | otp2289.OTPGenerator( | 83 | otp2289.OTPGenerator( |
| 85 | 'This is a test.'.encode(), | 84 | b'This is a test.', 'TeStø', otp2289.OTP_ALGO_SHA1 |
| 86 | 'TeStø', | ||
| 87 | otp2289.OTP_ALGO_SHA1, | ||
| 88 | ) | 85 | ) |
| 89 | assert exc_info.type is otp2289.OTPGeneratorError | 86 | assert exc_info.type is otp2289.OTPGeneratorError |
| 90 | assert exc_info.value.args[0] == ( | 87 | assert exc_info.value.args[0] == ( |
| 91 | 'The seed MUST consist of purely alphanumeric characters' | 88 | 'The seed MUST consist of purely alphanumeric characters' |
| 92 | ) | 89 | ) |
| 93 | with pytest.raises(otp2289.OTPGeneratorError) as exc_info: | 90 | with pytest.raises(otp2289.OTPGeneratorError) as exc_info: |
| 94 | otp2289.OTPGenerator( | 91 | otp2289.OTPGenerator(b'This is a test.', 'TeSt', 9) |
| 95 | 'This is a test.'.encode(), | ||
| 96 | 'TeSt', | ||
| 97 | 9, | ||
| 98 | ) | ||
| 99 | assert exc_info.type is otp2289.OTPGeneratorError | 92 | assert exc_info.type is otp2289.OTPGeneratorError |
| 100 | assert exc_info.value.args[0] == ( | 93 | assert exc_info.value.args[0] == ( |
| 101 | 'hash_algo is not among the known algorithms' | 94 | 'hash_algo is not among the known algorithms' |
| 102 | ) | 95 | ) |
| 103 | with pytest.raises(otp2289.OTPGeneratorError) as exc_info: | 96 | with pytest.raises(otp2289.OTPGeneratorError) as exc_info: |
| 104 | otp2289.OTPGenerator( | 97 | otp2289.OTPGenerator( |
| 105 | 'This is a test.'.encode(), | 98 | b'This is a test.', |
| 106 | 'TeSt', | 99 | 'TeSt', |
| 107 | b'md5', | 100 | b'md5', # ty: ignore[invalid-argument-type] |
| 108 | ) | 101 | ) |
| 109 | assert exc_info.type is otp2289.OTPGeneratorError | 102 | assert exc_info.type is otp2289.OTPGeneratorError |
| 110 | assert exc_info.value.args[0] == 'hash_algo must be an int or a str' | 103 | assert exc_info.value.args[0] == 'hash_algo must be an int or a str' |
| 111 | # test the package structure as well | 104 | # test the package structure as well |
| 112 | with pytest.raises(otp2289.generator.OTPGeneratorError) as exc_info: | 105 | with pytest.raises(otp2289.generator.OTPGeneratorError) as exc_info: |
| 113 | otp2289.generator.OTPGenerator( | 106 | otp2289.generator.OTPGenerator(b'This is a test.', 'TeSt', 'foo') |
| 114 | 'This is a test.'.encode(), | ||
| 115 | 'TeSt', | ||
| 116 | 'foo', | ||
| 117 | ) | ||
| 118 | assert exc_info.type is otp2289.generator.OTPGeneratorError | 107 | assert exc_info.type is otp2289.generator.OTPGeneratorError |
| 119 | assert exc_info.value.args[0] == ( | 108 | assert exc_info.value.args[0] == ( |
| 120 | 'foo is not supported by this version of the hashlib module' | 109 | 'foo is not supported by this version of the hashlib module' |
| 121 | ) | 110 | ) |
| 122 | with pytest.raises(otp2289.OTPGeneratorError) as exc_info: | 111 | with pytest.raises(otp2289.OTPGeneratorError) as exc_info: |
| 123 | otp2289.OTPGenerator('1234567', 'TeSt', otp2289.OTP_ALGO_MD5) | ||
| 124 | assert exc_info.type is otp2289.OTPGeneratorError | ||
| 125 | assert exc_info.value.args[0] == 'Password must be a byte-string' | ||
| 126 | with pytest.raises(otp2289.OTPGeneratorError) as exc_info: | ||
| 127 | otp2289.OTPGenerator( | 112 | otp2289.OTPGenerator( |
| 128 | '1234567'.encode(), | 113 | '1234567', # ty: ignore[invalid-argument-type] |
| 129 | 'TeSt', | 114 | 'TeSt', |
| 130 | otp2289.OTP_ALGO_MD5, | 115 | otp2289.OTP_ALGO_MD5, |
| 131 | ) | 116 | ) |
| 132 | assert exc_info.type is otp2289.OTPGeneratorError | 117 | assert exc_info.type is otp2289.OTPGeneratorError |
| 118 | assert exc_info.value.args[0] == 'Password must be a byte-string' | ||
| 119 | with pytest.raises(otp2289.OTPGeneratorError) as exc_info: | ||
| 120 | otp2289.OTPGenerator(b'1234567', 'TeSt', otp2289.OTP_ALGO_MD5) | ||
| 121 | assert exc_info.type is otp2289.OTPGeneratorError | ||
| 133 | assert exc_info.value.args[0] == 'Password must be longer than 10 bytes' | 122 | assert exc_info.value.args[0] == 'Password must be longer than 10 bytes' |
| 134 | 123 | ||
| 135 | 124 | ||
| 136 | def test_md5(): | 125 | def test_md5() -> None: |
| 137 | """ | 126 | """ |
| 138 | Tests the MD5 functionality of the OTPGenerator as described in the RFC | 127 | Tests the MD5 functionality of the OTPGenerator as described in the RFC |
| 139 | 128 | ||
| @@ -142,9 +131,7 @@ def test_md5(): | |||
| 142 | # We could run this in a loop, but I guess "Readability counts." | 131 | # We could run this in a loop, but I guess "Readability counts." |
| 143 | # pass='This is a test.', seed='TeSt' | 132 | # pass='This is a test.', seed='TeSt' |
| 144 | gen = otp2289.OTPGenerator( | 133 | gen = otp2289.OTPGenerator( |
| 145 | 'This is a test.'.encode(), | 134 | b'This is a test.', 'TeSt', otp2289.OTP_ALGO_MD5 |
| 146 | 'TeSt', | ||
| 147 | otp2289.OTP_ALGO_MD5, | ||
| 148 | ) | 135 | ) |
| 149 | res_words = gen.generate_otp_words(0) | 136 | res_words = gen.generate_otp_words(0) |
| 150 | res_hex = gen.generate_otp_hexdigest(0) | 137 | res_hex = gen.generate_otp_hexdigest(0) |
| @@ -182,11 +169,7 @@ def test_md5(): | |||
| 182 | assert words[1] == 'EASE OIL FUM CURE AWRY AVIS' | 169 | assert words[1] == 'EASE OIL FUM CURE AWRY AVIS' |
| 183 | assert words[99] == 'BAIL TUFT BITS GANG CHEF THY' | 170 | assert words[99] == 'BAIL TUFT BITS GANG CHEF THY' |
| 184 | # pass='AbCdEfGhIjK', seed='alpha1' | 171 | # pass='AbCdEfGhIjK', seed='alpha1' |
| 185 | gen = otp2289.OTPGenerator( | 172 | gen = otp2289.OTPGenerator(b'AbCdEfGhIjK', 'alpha1', otp2289.OTP_ALGO_MD5) |
| 186 | 'AbCdEfGhIjK'.encode(), | ||
| 187 | 'alpha1', | ||
| 188 | otp2289.OTP_ALGO_MD5, | ||
| 189 | ) | ||
| 190 | assert gen.generate_otp_hexdigest(0) == '0x87066dd9644bf206' | 173 | assert gen.generate_otp_hexdigest(0) == '0x87066dd9644bf206' |
| 191 | assert gen.generate_otp_words(0) == 'FULL PEW DOWN ONCE MORT ARC' | 174 | assert gen.generate_otp_words(0) == 'FULL PEW DOWN ONCE MORT ARC' |
| 192 | assert gen.generate_otp_hexdigest(1) == '0x7cd34c1040add14b' | 175 | assert gen.generate_otp_hexdigest(1) == '0x7cd34c1040add14b' |
| @@ -195,9 +178,7 @@ def test_md5(): | |||
| 195 | assert gen.generate_otp_words(99) == 'BODE HOP JAKE STOW JUT RAP' | 178 | assert gen.generate_otp_words(99) == 'BODE HOP JAKE STOW JUT RAP' |
| 196 | # pass="OTP's are good", seed='correct' | 179 | # pass="OTP's are good", seed='correct' |
| 197 | gen = otp2289.OTPGenerator( | 180 | gen = otp2289.OTPGenerator( |
| 198 | "OTP's are good".encode(), | 181 | b"OTP's are good", 'correct', otp2289.OTP_ALGO_MD5 |
| 199 | 'correct', | ||
| 200 | otp2289.OTP_ALGO_MD5, | ||
| 201 | ) | 182 | ) |
| 202 | assert gen.generate_otp_hexdigest(0) == '0xf205753943de4cf9' | 183 | assert gen.generate_otp_hexdigest(0) == '0xf205753943de4cf9' |
| 203 | assert gen.generate_otp_words(0) == 'ULAN NEW ARMY FUSE SUIT EYED' | 184 | assert gen.generate_otp_words(0) == 'ULAN NEW ARMY FUSE SUIT EYED' |
| @@ -207,7 +188,7 @@ def test_md5(): | |||
| 207 | assert gen.generate_otp_words(99) == 'LONG IVY JULY AJAR BOND LEE' | 188 | assert gen.generate_otp_words(99) == 'LONG IVY JULY AJAR BOND LEE' |
| 208 | 189 | ||
| 209 | 190 | ||
| 210 | def test_sha1(): | 191 | def test_sha1() -> None: |
| 211 | """ | 192 | """ |
| 212 | Tests the SHA-1 functionality of the OTPGenerator as described in the RFC | 193 | Tests the SHA-1 functionality of the OTPGenerator as described in the RFC |
| 213 | 194 | ||
| @@ -215,13 +196,10 @@ def test_sha1(): | |||
| 215 | """ | 196 | """ |
| 216 | # pass='This is a test.', seed='TeSt' | 197 | # pass='This is a test.', seed='TeSt' |
| 217 | gen = otp2289.OTPGenerator( | 198 | gen = otp2289.OTPGenerator( |
| 218 | 'This is a test.'.encode(), | 199 | b'This is a test.', 'TeSt', otp2289.OTP_ALGO_SHA1 |
| 219 | 'TeSt', | ||
| 220 | otp2289.OTP_ALGO_SHA1, | ||
| 221 | ) | 200 | ) |
| 222 | # step=0 | 201 | res_hex = gen.generate_otp_hexdigest(step=0) |
| 223 | res_hex = gen.generate_otp_hexdigest(0) | 202 | res_words = gen.generate_otp_words(step=0) |
| 224 | res_words = gen.generate_otp_words(0) | ||
| 225 | assert isinstance(res_words, str) | 203 | assert isinstance(res_words, str) |
| 226 | assert isinstance(res_hex, str) | 204 | assert isinstance(res_hex, str) |
| 227 | assert res_hex == '0xbb9e6ae1979d8ff4' | 205 | assert res_hex == '0xbb9e6ae1979d8ff4' |
| @@ -254,11 +232,7 @@ def test_sha1(): | |||
| 254 | assert words[1] == 'CART OTTO HIVE ODE VAT NUT' | 232 | assert words[1] == 'CART OTTO HIVE ODE VAT NUT' |
| 255 | assert words[99] == 'GAFF WAIT SKID GIG SKY EYED' | 233 | assert words[99] == 'GAFF WAIT SKID GIG SKY EYED' |
| 256 | # pass='AbCdEfGhIjK', seed='alpha1' | 234 | # pass='AbCdEfGhIjK', seed='alpha1' |
| 257 | gen = otp2289.OTPGenerator( | 235 | gen = otp2289.OTPGenerator(b'AbCdEfGhIjK', 'alpha1', otp2289.OTP_ALGO_SHA1) |
| 258 | 'AbCdEfGhIjK'.encode(), | ||
| 259 | 'alpha1', | ||
| 260 | otp2289.OTP_ALGO_SHA1, | ||
| 261 | ) | ||
| 262 | assert gen.generate_otp_hexdigest(0) == '0xad85f658ebe383c9' | 236 | assert gen.generate_otp_hexdigest(0) == '0xad85f658ebe383c9' |
| 263 | assert gen.generate_otp_words(0) == 'LEST OR HEEL SCOT ROB SUIT' | 237 | assert gen.generate_otp_words(0) == 'LEST OR HEEL SCOT ROB SUIT' |
| 264 | assert gen.generate_otp_hexdigest(1) == '0xd07ce229b5cf119b' | 238 | assert gen.generate_otp_hexdigest(1) == '0xd07ce229b5cf119b' |
| @@ -267,9 +241,7 @@ def test_sha1(): | |||
| 267 | assert gen.generate_otp_words(99) == 'MAY STAR TIN LYON VEDA STAN' | 241 | assert gen.generate_otp_words(99) == 'MAY STAR TIN LYON VEDA STAN' |
| 268 | # pass="OTP's are good", seed='correct' | 242 | # pass="OTP's are good", seed='correct' |
| 269 | gen = otp2289.OTPGenerator( | 243 | gen = otp2289.OTPGenerator( |
| 270 | "OTP's are good".encode(), | 244 | b"OTP's are good", 'correct', otp2289.OTP_ALGO_SHA1 |
| 271 | 'correct', | ||
| 272 | otp2289.OTP_ALGO_SHA1, | ||
| 273 | ) | 245 | ) |
| 274 | assert gen.generate_otp_hexdigest(0) == '0xd51f3e99bf8e6f0b' | 246 | assert gen.generate_otp_hexdigest(0) == '0xd51f3e99bf8e6f0b' |
| 275 | assert gen.generate_otp_words(0) == 'RUST WELT KICK FELL TAIL FRAU' | 247 | assert gen.generate_otp_words(0) == 'RUST WELT KICK FELL TAIL FRAU' |
diff --git a/test/test_main.py b/tests/test_main.py index 593bf80..3c75621 100644 --- a/test/test_main.py +++ b/tests/test_main.py | |||
| @@ -1,6 +1,6 @@ | |||
| 1 | # SPDX-License-Identifier: BSD-2-Clause-FreeBSD | 1 | # SPDX-License-Identifier: BSD-2-Clause |
| 2 | # | 2 | # |
| 3 | # Copyright (c) 2020-2025, Simeon Simeonov | 3 | # Copyright (c) 2020-2026, Simeon Simeonov |
| 4 | # All rights reserved. | 4 | # All rights reserved. |
| 5 | # | 5 | # |
| 6 | # Redistribution and use in source and binary forms, with or without | 6 | # Redistribution and use in source and binary forms, with or without |
| @@ -23,6 +23,7 @@ | |||
| 23 | # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF | 23 | # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF |
| 24 | # THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | 24 | # THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. |
| 25 | """Tests for otp2289.__main__""" | 25 | """Tests for otp2289.__main__""" |
| 26 | |||
| 26 | import os | 27 | import os |
| 27 | import unittest.mock | 28 | import unittest.mock |
| 28 | 29 | ||
| @@ -31,7 +32,7 @@ import pytest | |||
| 31 | from otp2289.__main__ import main | 32 | from otp2289.__main__ import main |
| 32 | 33 | ||
| 33 | 34 | ||
| 34 | def test_main_generate_otp_response(capsys): | 35 | def test_main_generate_otp_response(capsys: pytest.CaptureFixture) -> None: |
| 35 | """tests main""" | 36 | """tests main""" |
| 36 | args = [ | 37 | args = [ |
| 37 | '--generate-otp-response', | 38 | '--generate-otp-response', |
| @@ -51,7 +52,6 @@ def test_main_generate_otp_response(capsys): | |||
| 51 | f'Seed: TesT, Step: 99, Hash: sha1{os.linesep}' | 52 | f'Seed: TesT, Step: 99, Hash: sha1{os.linesep}' |
| 52 | f'0x87fec7768b73ccf9{os.linesep}' | 53 | f'0x87fec7768b73ccf9{os.linesep}' |
| 53 | ) | 54 | ) |
| 54 | assert exit_info.type == SystemExit | ||
| 55 | assert exit_info.value.code == 0 | 55 | assert exit_info.value.code == 0 |
| 56 | args.extend(['-f', 'token']) | 56 | args.extend(['-f', 'token']) |
| 57 | with pytest.raises(SystemExit) as exit_info: | 57 | with pytest.raises(SystemExit) as exit_info: |
| @@ -61,28 +61,20 @@ def test_main_generate_otp_response(capsys): | |||
| 61 | f'Seed: TesT, Step: 99, Hash: sha1{os.linesep}' | 61 | f'Seed: TesT, Step: 99, Hash: sha1{os.linesep}' |
| 62 | f'GAFF WAIT SKID GIG SKY EYED{os.linesep}' | 62 | f'GAFF WAIT SKID GIG SKY EYED{os.linesep}' |
| 63 | ) | 63 | ) |
| 64 | assert exit_info.type == SystemExit | ||
| 65 | assert exit_info.value.code == 0 | 64 | assert exit_info.value.code == 0 |
| 66 | args.append('-q') | 65 | args.append('-q') |
| 67 | with pytest.raises(SystemExit) as exit_info: | 66 | with pytest.raises(SystemExit) as exit_info: |
| 68 | main(args) | 67 | main(args) |
| 69 | captured = capsys.readouterr() | 68 | captured = capsys.readouterr() |
| 70 | assert captured.out == f'GAFF WAIT SKID GIG SKY EYED{os.linesep}' | 69 | assert captured.out == f'GAFF WAIT SKID GIG SKY EYED{os.linesep}' |
| 71 | assert exit_info.type == SystemExit | ||
| 72 | assert exit_info.value.code == 0 | 70 | assert exit_info.value.code == 0 |
| 73 | 71 | ||
| 74 | 72 | ||
| 75 | def test_main_generate_otp_response_env_passwd(capsys): | 73 | def test_main_generate_otp_response_env_passwd( |
| 74 | capsys: pytest.CaptureFixture, | ||
| 75 | ) -> None: | ||
| 76 | """tests main by fetching password from the env. var. 'OTP2289_PASSWORD'""" | 76 | """tests main by fetching password from the env. var. 'OTP2289_PASSWORD'""" |
| 77 | args = [ | 77 | args = ['--generate-otp-response', '-a', 'sha1', '-i', '99', '-s', 'TesT'] |
| 78 | '--generate-otp-response', | ||
| 79 | '-a', | ||
| 80 | 'sha1', | ||
| 81 | '-i', | ||
| 82 | '99', | ||
| 83 | '-s', | ||
| 84 | 'TesT', | ||
| 85 | ] | ||
| 86 | with unittest.mock.patch.dict( | 78 | with unittest.mock.patch.dict( |
| 87 | os.environ, {'OTP2289_PASSWORD': 'This is a test.'} | 79 | os.environ, {'OTP2289_PASSWORD': 'This is a test.'} |
| 88 | ): | 80 | ): |
| @@ -93,7 +85,6 @@ def test_main_generate_otp_response_env_passwd(capsys): | |||
| 93 | f'Seed: TesT, Step: 99, Hash: sha1{os.linesep}' | 85 | f'Seed: TesT, Step: 99, Hash: sha1{os.linesep}' |
| 94 | f'0x87fec7768b73ccf9{os.linesep}' | 86 | f'0x87fec7768b73ccf9{os.linesep}' |
| 95 | ) | 87 | ) |
| 96 | assert exit_info.type == SystemExit | ||
| 97 | assert exit_info.value.code == 0 | 88 | assert exit_info.value.code == 0 |
| 98 | args.extend(['-f', 'token']) | 89 | args.extend(['-f', 'token']) |
| 99 | with pytest.raises(SystemExit) as exit_info: | 90 | with pytest.raises(SystemExit) as exit_info: |
| @@ -103,18 +94,16 @@ def test_main_generate_otp_response_env_passwd(capsys): | |||
| 103 | f'Seed: TesT, Step: 99, Hash: sha1{os.linesep}' | 94 | f'Seed: TesT, Step: 99, Hash: sha1{os.linesep}' |
| 104 | f'GAFF WAIT SKID GIG SKY EYED{os.linesep}' | 95 | f'GAFF WAIT SKID GIG SKY EYED{os.linesep}' |
| 105 | ) | 96 | ) |
| 106 | assert exit_info.type == SystemExit | ||
| 107 | assert exit_info.value.code == 0 | 97 | assert exit_info.value.code == 0 |
| 108 | args.append('-q') | 98 | args.append('-q') |
| 109 | with pytest.raises(SystemExit) as exit_info: | 99 | with pytest.raises(SystemExit) as exit_info: |
| 110 | main(args) | 100 | main(args) |
| 111 | captured = capsys.readouterr() | 101 | captured = capsys.readouterr() |
| 112 | assert captured.out == f'GAFF WAIT SKID GIG SKY EYED{os.linesep}' | 102 | assert captured.out == f'GAFF WAIT SKID GIG SKY EYED{os.linesep}' |
| 113 | assert exit_info.type == SystemExit | ||
| 114 | assert exit_info.value.code == 0 | 103 | assert exit_info.value.code == 0 |
| 115 | 104 | ||
| 116 | 105 | ||
| 117 | def test_main_generate_otp_range(capsys): | 106 | def test_main_generate_otp_range(capsys: pytest.CaptureFixture) -> None: |
| 118 | """tests main""" | 107 | """tests main""" |
| 119 | args = [ | 108 | args = [ |
| 120 | '--generate-otp-range', | 109 | '--generate-otp-range', |
| @@ -137,7 +126,6 @@ def test_main_generate_otp_range(capsys): | |||
| 137 | f'1: 0x7965e05436f5029f{os.linesep}' | 126 | f'1: 0x7965e05436f5029f{os.linesep}' |
| 138 | f'0: 0x9e876134d90499dd{os.linesep}' | 127 | f'0: 0x9e876134d90499dd{os.linesep}' |
| 139 | ) | 128 | ) |
| 140 | assert exit_info.type == SystemExit | ||
| 141 | assert exit_info.value.code == 0 | 129 | assert exit_info.value.code == 0 |
| 142 | args.append('-q') | 130 | args.append('-q') |
| 143 | with pytest.raises(SystemExit) as exit_info: | 131 | with pytest.raises(SystemExit) as exit_info: |
| @@ -148,7 +136,6 @@ def test_main_generate_otp_range(capsys): | |||
| 148 | f'1: 0x7965e05436f5029f{os.linesep}' | 136 | f'1: 0x7965e05436f5029f{os.linesep}' |
| 149 | f'0: 0x9e876134d90499dd{os.linesep}' | 137 | f'0: 0x9e876134d90499dd{os.linesep}' |
| 150 | ) | 138 | ) |
| 151 | assert exit_info.type == SystemExit | ||
| 152 | assert exit_info.value.code == 0 | 139 | assert exit_info.value.code == 0 |
| 153 | args.extend(['-f', 'token']) | 140 | args.extend(['-f', 'token']) |
| 154 | with pytest.raises(SystemExit) as exit_info: | 141 | with pytest.raises(SystemExit) as exit_info: |
| @@ -159,46 +146,25 @@ def test_main_generate_otp_range(capsys): | |||
| 159 | f'1: EASE OIL FUM CURE AWRY AVIS{os.linesep}' | 146 | f'1: EASE OIL FUM CURE AWRY AVIS{os.linesep}' |
| 160 | f'0: INCH SEA ANNE LONG AHEM TOUR{os.linesep}' | 147 | f'0: INCH SEA ANNE LONG AHEM TOUR{os.linesep}' |
| 161 | ) | 148 | ) |
| 162 | assert exit_info.type == SystemExit | ||
| 163 | assert exit_info.value.code == 0 | 149 | assert exit_info.value.code == 0 |
| 164 | 150 | ||
| 165 | 151 | ||
| 166 | @pytest.mark.parametrize( | 152 | @pytest.mark.parametrize( |
| 167 | 'args', | 153 | 'args', |
| 168 | [ | 154 | [ |
| 169 | [ | 155 | ['--generate-otp-range', '-i', '2', '-s', 'TesT', '-r', '5'], |
| 170 | '--generate-otp-range', | 156 | ['--generate-otp-range', '-i', '2', '-s', 'TesT', '-r', '5', '-P'], |
| 171 | '-i', | ||
| 172 | '2', | ||
| 173 | '-s', | ||
| 174 | 'TesT', | ||
| 175 | '-r', | ||
| 176 | '5', | ||
| 177 | ], | ||
| 178 | [ | ||
| 179 | '--generate-otp-range', | ||
| 180 | '-i', | ||
| 181 | '2', | ||
| 182 | '-s', | ||
| 183 | 'TesT', | ||
| 184 | '-r', | ||
| 185 | '5', | ||
| 186 | '-P', | ||
| 187 | ], | ||
| 188 | ], | 157 | ], |
| 189 | ) | 158 | ) |
| 190 | @unittest.mock.patch('getpass.getpass', lambda *args: 'This is a test.') | 159 | @unittest.mock.patch('getpass.getpass') |
| 191 | def test_main_generate_otp_range_passwd_prompt(capsys, args): | 160 | def test_main_generate_otp_range_passwd_prompt( |
| 161 | getpass: unittest.mock.MagicMock, | ||
| 162 | capsys: pytest.CaptureFixture, | ||
| 163 | args: list[str], | ||
| 164 | ) -> None: | ||
| 192 | """tests main by prompting for password (with or without -P)""" | 165 | """tests main by prompting for password (with or without -P)""" |
| 193 | args = [ | 166 | args = ['--generate-otp-range', '-i', '2', '-s', 'TesT', '-r', '5'] |
| 194 | '--generate-otp-range', | 167 | getpass.return_value = 'This is a test.' |
| 195 | '-i', | ||
| 196 | '2', | ||
| 197 | '-s', | ||
| 198 | 'TesT', | ||
| 199 | '-r', | ||
| 200 | '5', | ||
| 201 | ] | ||
| 202 | with pytest.raises(SystemExit) as exit_info: | 168 | with pytest.raises(SystemExit) as exit_info: |
| 203 | main(args) | 169 | main(args) |
| 204 | captured = capsys.readouterr() | 170 | captured = capsys.readouterr() |
| @@ -209,7 +175,6 @@ def test_main_generate_otp_range_passwd_prompt(capsys, args): | |||
| 209 | f'1: 0x7965e05436f5029f{os.linesep}' | 175 | f'1: 0x7965e05436f5029f{os.linesep}' |
| 210 | f'0: 0x9e876134d90499dd{os.linesep}' | 176 | f'0: 0x9e876134d90499dd{os.linesep}' |
| 211 | ) | 177 | ) |
| 212 | assert exit_info.type == SystemExit | ||
| 213 | assert exit_info.value.code == 0 | 178 | assert exit_info.value.code == 0 |
| 214 | args.append('-q') | 179 | args.append('-q') |
| 215 | with pytest.raises(SystemExit) as exit_info: | 180 | with pytest.raises(SystemExit) as exit_info: |
| @@ -220,7 +185,6 @@ def test_main_generate_otp_range_passwd_prompt(capsys, args): | |||
| 220 | f'1: 0x7965e05436f5029f{os.linesep}' | 185 | f'1: 0x7965e05436f5029f{os.linesep}' |
| 221 | f'0: 0x9e876134d90499dd{os.linesep}' | 186 | f'0: 0x9e876134d90499dd{os.linesep}' |
| 222 | ) | 187 | ) |
| 223 | assert exit_info.type == SystemExit | ||
| 224 | assert exit_info.value.code == 0 | 188 | assert exit_info.value.code == 0 |
| 225 | args.extend(['-f', 'token']) | 189 | args.extend(['-f', 'token']) |
| 226 | with pytest.raises(SystemExit) as exit_info: | 190 | with pytest.raises(SystemExit) as exit_info: |
| @@ -231,11 +195,10 @@ def test_main_generate_otp_range_passwd_prompt(capsys, args): | |||
| 231 | f'1: EASE OIL FUM CURE AWRY AVIS{os.linesep}' | 195 | f'1: EASE OIL FUM CURE AWRY AVIS{os.linesep}' |
| 232 | f'0: INCH SEA ANNE LONG AHEM TOUR{os.linesep}' | 196 | f'0: INCH SEA ANNE LONG AHEM TOUR{os.linesep}' |
| 233 | ) | 197 | ) |
| 234 | assert exit_info.type == SystemExit | ||
| 235 | assert exit_info.value.code == 0 | 198 | assert exit_info.value.code == 0 |
| 236 | 199 | ||
| 237 | 200 | ||
| 238 | def test_main_initiate(capsys): | 201 | def test_main_initiate(capsys: pytest.CaptureFixture) -> None: |
| 239 | """tests main""" | 202 | """tests main""" |
| 240 | args = [ | 203 | args = [ |
| 241 | '--initiate-new-sequence', | 204 | '--initiate-new-sequence', |
| @@ -253,12 +216,10 @@ def test_main_initiate(capsys): | |||
| 253 | f'Seed: TesT, Step: 500, Hash: md5{os.linesep}' | 216 | f'Seed: TesT, Step: 500, Hash: md5{os.linesep}' |
| 254 | f'0x2b8d82b6ac14346c{os.linesep}' | 217 | f'0x2b8d82b6ac14346c{os.linesep}' |
| 255 | ) | 218 | ) |
| 256 | assert exit_info.type == SystemExit | ||
| 257 | assert exit_info.value.code == 0 | 219 | assert exit_info.value.code == 0 |
| 258 | args.append('-q') | 220 | args.append('-q') |
| 259 | with pytest.raises(SystemExit) as exit_info: | 221 | with pytest.raises(SystemExit) as exit_info: |
| 260 | main(args) | 222 | main(args) |
| 261 | captured = capsys.readouterr() | 223 | captured = capsys.readouterr() |
| 262 | assert captured.out == f'0x2b8d82b6ac14346c{os.linesep}' | 224 | assert captured.out == f'0x2b8d82b6ac14346c{os.linesep}' |
| 263 | assert exit_info.type == SystemExit | ||
| 264 | assert exit_info.value.code == 0 | 225 | assert exit_info.value.code == 0 |
diff --git a/test/test_server.py b/tests/test_server.py index e81532f..3099cf7 100644 --- a/test/test_server.py +++ b/tests/test_server.py | |||
| @@ -1,6 +1,6 @@ | |||
| 1 | # SPDX-License-Identifier: BSD-2-Clause-FreeBSD | 1 | # SPDX-License-Identifier: BSD-2-Clause |
| 2 | # | 2 | # |
| 3 | # Copyright (c) 2020-2025, Simeon Simeonov | 3 | # Copyright (c) 2020-2026, Simeon Simeonov |
| 4 | # All rights reserved. | 4 | # All rights reserved. |
| 5 | # | 5 | # |
| 6 | # Redistribution and use in source and binary forms, with or without | 6 | # Redistribution and use in source and binary forms, with or without |
| @@ -23,6 +23,7 @@ | |||
| 23 | # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF | 23 | # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF |
| 24 | # THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | 24 | # THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. |
| 25 | """Tests for otp2289.server""" | 25 | """Tests for otp2289.server""" |
| 26 | |||
| 26 | import json | 27 | import json |
| 27 | 28 | ||
| 28 | import pytest | 29 | import pytest |
| @@ -30,13 +31,10 @@ import pytest | |||
| 30 | import otp2289 | 31 | import otp2289 |
| 31 | 32 | ||
| 32 | 33 | ||
| 33 | def test_state_caller_exceptions(): | 34 | def test_state_caller_exceptions() -> None: |
| 34 | """Tests the exceptions when calling the OTPState objects""" | 35 | """Tests the exceptions when calling the OTPState objects""" |
| 35 | state = otp2289.OTPState( | 36 | state = otp2289.OTPState( |
| 36 | '0x7965e05436f5029f', | 37 | '0x7965e05436f5029f', 1, 'TeSt', otp2289.OTP_ALGO_MD5 |
| 37 | 1, | ||
| 38 | 'TeSt', | ||
| 39 | otp2289.OTP_ALGO_MD5, | ||
| 40 | ) | 38 | ) |
| 41 | with pytest.raises(otp2289.OTPInvalidResponseError) as exc_info: | 39 | with pytest.raises(otp2289.OTPInvalidResponseError) as exc_info: |
| 42 | state.response_validates('bla') | 40 | state.response_validates('bla') |
| @@ -46,21 +44,22 @@ def test_state_caller_exceptions(): | |||
| 46 | ) | 44 | ) |
| 47 | 45 | ||
| 48 | 46 | ||
| 49 | def test_state_constructor_exceptions(): | 47 | def test_state_constructor_exceptions() -> None: |
| 50 | """Tests the exceptions when initializing new OTPState objects""" | 48 | """Tests the exceptions when initializing new OTPState objects""" |
| 51 | with pytest.raises(otp2289.OTPStateError) as exc_info: | 49 | with pytest.raises(otp2289.OTPStateError) as exc_info: |
| 52 | otp2289.OTPState( | 50 | otp2289.OTPState( |
| 53 | '0x7965e05436f5029t', | 51 | '0x7965e05436f5029t', |
| 54 | 1, | 52 | 1, |
| 55 | 'TeStø'.encode(), | 53 | 'TeStø'.encode(), # ty: ignore[invalid-argument-type] |
| 56 | otp2289.OTP_ALGO_MD5, | 54 | otp2289.OTP_ALGO_MD5, |
| 57 | ) | 55 | ) |
| 58 | assert exc_info.type is otp2289.OTPStateError | 56 | assert exc_info.type is otp2289.OTPStateError |
| 59 | assert exc_info.value.args[0] == 'Seed must be a string' | 57 | assert exc_info.value.args[0] == 'Seed must be a string' |
| 58 | |||
| 60 | with pytest.raises(otp2289.OTPStateError) as exc_info: | 59 | with pytest.raises(otp2289.OTPStateError) as exc_info: |
| 61 | otp2289.OTPState( | 60 | otp2289.OTPState( |
| 62 | '0x7965e05436f5029t', | 61 | '0x7965e05436f5029t', |
| 63 | '1', | 62 | '1', # ty: ignore[invalid-argument-type] |
| 64 | 'TeSt', | 63 | 'TeSt', |
| 65 | otp2289.OTP_ALGO_MD5, | 64 | otp2289.OTP_ALGO_MD5, |
| 66 | ) | 65 | ) |
| @@ -68,13 +67,10 @@ def test_state_constructor_exceptions(): | |||
| 68 | assert exc_info.value.args[0] == 'Step value MUST be an int' | 67 | assert exc_info.value.args[0] == 'Step value MUST be an int' |
| 69 | 68 | ||
| 70 | 69 | ||
| 71 | def test_state_validation_md5(): | 70 | def test_state_validation_md5() -> None: |
| 72 | """Tests the OTPState validation functionality for MD5""" | 71 | """Tests the OTPState validation functionality for MD5""" |
| 73 | state = otp2289.OTPState( | 72 | state = otp2289.OTPState( |
| 74 | '0x7965e05436f5029f', | 73 | '0x7965e05436f5029f', 1, 'TeSt', otp2289.OTP_ALGO_MD5 |
| 75 | 1, | ||
| 76 | 'TeSt', | ||
| 77 | otp2289.OTP_ALGO_MD5, | ||
| 78 | ) | 74 | ) |
| 79 | assert state.validated is False | 75 | assert state.validated is False |
| 80 | assert state.response_validates('0x9e876134d90499dd') is True | 76 | assert state.response_validates('0x9e876134d90499dd') is True |
| @@ -83,13 +79,10 @@ def test_state_validation_md5(): | |||
| 83 | assert state.validated is True | 79 | assert state.validated is True |
| 84 | 80 | ||
| 85 | 81 | ||
| 86 | def test_state_validation_sha1(): | 82 | def test_state_validation_sha1() -> None: |
| 87 | """Tests the OTPState validation functionality for SHA1""" | 83 | """Tests the OTPState validation functionality for SHA1""" |
| 88 | state = otp2289.OTPState( | 84 | state = otp2289.OTPState( |
| 89 | '0x63d936639734385b', | 85 | '0x63d936639734385b', 1, 'TeSt', otp2289.OTP_ALGO_SHA1 |
| 90 | 1, | ||
| 91 | 'TeSt', | ||
| 92 | otp2289.OTP_ALGO_SHA1, | ||
| 93 | ) | 86 | ) |
| 94 | assert state.validated is False | 87 | assert state.validated is False |
| 95 | assert state.response_validates('0xbb9e6ae1979d8ff4') is True | 88 | assert state.response_validates('0xbb9e6ae1979d8ff4') is True |
| @@ -98,7 +91,7 @@ def test_state_validation_sha1(): | |||
| 98 | assert state.validated is True | 91 | assert state.validated is True |
| 99 | 92 | ||
| 100 | 93 | ||
| 101 | def test_store(): | 94 | def test_store() -> None: |
| 102 | """Tests the OTPStore functionality""" | 95 | """Tests the OTPStore functionality""" |
| 103 | store_data = { | 96 | store_data = { |
| 104 | 'sgs': { | 97 | 'sgs': { |
| @@ -115,12 +108,13 @@ def test_store(): | |||
| 115 | }, | 108 | }, |
| 116 | } | 109 | } |
| 117 | store = otp2289.OTPStore(store_data) | 110 | store = otp2289.OTPStore(store_data) |
| 118 | assert len(store) == 2 | 111 | assert len(store) == len(store_data) |
| 119 | assert isinstance(json.dumps(store.to_dict()), str) # serializable? | 112 | assert isinstance(json.dumps(store.to_dict()), str) # serializable? |
| 120 | assert store.response_validates('sgs', '0x9e876134d90499dd') is True | 113 | assert store.response_validates('sgs', '0x9e876134d90499dd') is True |
| 121 | assert store.response_validates('sgs', '0x9e876134d90499dd') is False | 114 | assert store.response_validates('sgs', '0x9e876134d90499dd') is False |
| 122 | sgs_state = store.get('sgs') | 115 | sgs_state = store.get('sgs') |
| 123 | assert sgs_state in store | 116 | if sgs_state is not None: |
| 117 | assert sgs_state in store | ||
| 124 | store.pop_state('sgs') | 118 | store.pop_state('sgs') |
| 125 | assert bool(store) is True | 119 | assert bool(store) is True |
| 126 | store.pop_state('blackmore') | 120 | store.pop_state('blackmore') |
diff --git a/test/test_static.py b/tests/test_static.py index 7635e5e..589fc17 100644 --- a/test/test_static.py +++ b/tests/test_static.py | |||
| @@ -1,7 +1,6 @@ | |||
| 1 | # -*- coding: utf-8 -*- | 1 | # SPDX-License-Identifier: BSD-2-Clause |
| 2 | # SPDX-License-Identifier: BSD-2-Clause-FreeBSD | ||
| 3 | # | 2 | # |
| 4 | # Copyright (c) 2020-2023, Simeon Simeonov | 3 | # Copyright (c) 2020-2026, Simeon Simeonov |
| 5 | # All rights reserved. | 4 | # All rights reserved. |
| 6 | # | 5 | # |
| 7 | # Redistribution and use in source and binary forms, with or without | 6 | # Redistribution and use in source and binary forms, with or without |
| @@ -24,73 +23,73 @@ | |||
| 24 | # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF | 23 | # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF |
| 25 | # THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | 24 | # THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. |
| 26 | """Tests for the static methods and basic bit, byte, token functionality""" | 25 | """Tests for the static methods and basic bit, byte, token functionality""" |
| 27 | import binascii | 26 | |
| 28 | import os | 27 | import os |
| 29 | 28 | ||
| 30 | import otp2289 | 29 | import otp2289 |
| 31 | 30 | ||
| 32 | 31 | ||
| 33 | def test_bytes_and_tokens(): | 32 | def test_bytes_and_tokens() -> None: |
| 34 | """Tests the official hex and tokens defined in RFC2289""" | 33 | """Tests the official hex and tokens defined in RFC2289""" |
| 35 | assert binascii.unhexlify('9e876134d90499dd') == ( | 34 | assert bytes.fromhex('9e876134d90499dd') == ( |
| 36 | otp2289.OTPGenerator.tokens_to_bytes('INCH SEA ANNE LONG AHEM TOUR') | 35 | otp2289.OTPGenerator.tokens_to_bytes('INCH SEA ANNE LONG AHEM TOUR') |
| 37 | ) | 36 | ) |
| 38 | assert binascii.unhexlify('7965e05436f5029f') == ( | 37 | assert bytes.fromhex('7965e05436f5029f') == ( |
| 39 | otp2289.OTPGenerator.tokens_to_bytes('EASE OIL FUM CURE AWRY AVIS') | 38 | otp2289.OTPGenerator.tokens_to_bytes('EASE OIL FUM CURE AWRY AVIS') |
| 40 | ) | 39 | ) |
| 41 | assert binascii.unhexlify('50fe1962c4965880') == ( | 40 | assert bytes.fromhex('50fe1962c4965880') == ( |
| 42 | otp2289.OTPGenerator.tokens_to_bytes('BAIL TUFT BITS GANG CHEF THY') | 41 | otp2289.OTPGenerator.tokens_to_bytes('BAIL TUFT BITS GANG CHEF THY') |
| 43 | ) | 42 | ) |
| 44 | assert binascii.unhexlify('87066dd9644bf206') == ( | 43 | assert bytes.fromhex('87066dd9644bf206') == ( |
| 45 | otp2289.OTPGenerator.tokens_to_bytes('FULL PEW DOWN ONCE MORT ARC') | 44 | otp2289.OTPGenerator.tokens_to_bytes('FULL PEW DOWN ONCE MORT ARC') |
| 46 | ) | 45 | ) |
| 47 | assert binascii.unhexlify('7cd34c1040add14b') == ( | 46 | assert bytes.fromhex('7cd34c1040add14b') == ( |
| 48 | otp2289.OTPGenerator.tokens_to_bytes('FACT HOOF AT FIST SITE KENT') | 47 | otp2289.OTPGenerator.tokens_to_bytes('FACT HOOF AT FIST SITE KENT') |
| 49 | ) | 48 | ) |
| 50 | assert binascii.unhexlify('5aa37a81f212146c') == ( | 49 | assert bytes.fromhex('5aa37a81f212146c') == ( |
| 51 | otp2289.OTPGenerator.tokens_to_bytes('BODE HOP JAKE STOW JUT RAP') | 50 | otp2289.OTPGenerator.tokens_to_bytes('BODE HOP JAKE STOW JUT RAP') |
| 52 | ) | 51 | ) |
| 53 | assert binascii.unhexlify('f205753943de4cf9') == ( | 52 | assert bytes.fromhex('f205753943de4cf9') == ( |
| 54 | otp2289.OTPGenerator.tokens_to_bytes('ULAN NEW ARMY FUSE SUIT EYED') | 53 | otp2289.OTPGenerator.tokens_to_bytes('ULAN NEW ARMY FUSE SUIT EYED') |
| 55 | ) | 54 | ) |
| 56 | assert binascii.unhexlify('ddcdac956f234937') == ( | 55 | assert bytes.fromhex('ddcdac956f234937') == ( |
| 57 | otp2289.OTPGenerator.tokens_to_bytes('SKIM CULT LOB SLAM POE HOWL') | 56 | otp2289.OTPGenerator.tokens_to_bytes('SKIM CULT LOB SLAM POE HOWL') |
| 58 | ) | 57 | ) |
| 59 | assert binascii.unhexlify('b203e28fa525be47') == ( | 58 | assert bytes.fromhex('b203e28fa525be47') == ( |
| 60 | otp2289.OTPGenerator.tokens_to_bytes('LONG IVY JULY AJAR BOND LEE') | 59 | otp2289.OTPGenerator.tokens_to_bytes('LONG IVY JULY AJAR BOND LEE') |
| 61 | ) | 60 | ) |
| 62 | assert binascii.unhexlify('bb9e6ae1979d8ff4') == ( | 61 | assert bytes.fromhex('bb9e6ae1979d8ff4') == ( |
| 63 | otp2289.OTPGenerator.tokens_to_bytes('MILT VARY MAST OK SEES WENT') | 62 | otp2289.OTPGenerator.tokens_to_bytes('MILT VARY MAST OK SEES WENT') |
| 64 | ) | 63 | ) |
| 65 | assert binascii.unhexlify('63d936639734385b') == ( | 64 | assert bytes.fromhex('63d936639734385b') == ( |
| 66 | otp2289.OTPGenerator.tokens_to_bytes('CART OTTO HIVE ODE VAT NUT') | 65 | otp2289.OTPGenerator.tokens_to_bytes('CART OTTO HIVE ODE VAT NUT') |
| 67 | ) | 66 | ) |
| 68 | assert binascii.unhexlify('87fec7768b73ccf9') == ( | 67 | assert bytes.fromhex('87fec7768b73ccf9') == ( |
| 69 | otp2289.OTPGenerator.tokens_to_bytes('GAFF WAIT SKID GIG SKY EYED') | 68 | otp2289.OTPGenerator.tokens_to_bytes('GAFF WAIT SKID GIG SKY EYED') |
| 70 | ) | 69 | ) |
| 71 | assert binascii.unhexlify('ad85f658ebe383c9') == ( | 70 | assert bytes.fromhex('ad85f658ebe383c9') == ( |
| 72 | otp2289.OTPGenerator.tokens_to_bytes('LEST OR HEEL SCOT ROB SUIT') | 71 | otp2289.OTPGenerator.tokens_to_bytes('LEST OR HEEL SCOT ROB SUIT') |
| 73 | ) | 72 | ) |
| 74 | assert binascii.unhexlify('d07ce229b5cf119b') == ( | 73 | assert bytes.fromhex('d07ce229b5cf119b') == ( |
| 75 | otp2289.OTPGenerator.tokens_to_bytes('RITE TAKE GELD COST TUNE RECK') | 74 | otp2289.OTPGenerator.tokens_to_bytes('RITE TAKE GELD COST TUNE RECK') |
| 76 | ) | 75 | ) |
| 77 | assert binascii.unhexlify('27bc71035aaf3dc6') == ( | 76 | assert bytes.fromhex('27bc71035aaf3dc6') == ( |
| 78 | otp2289.OTPGenerator.tokens_to_bytes('MAY STAR TIN LYON VEDA STAN') | 77 | otp2289.OTPGenerator.tokens_to_bytes('MAY STAR TIN LYON VEDA STAN') |
| 79 | ) | 78 | ) |
| 80 | assert binascii.unhexlify('d51f3e99bf8e6f0b') == ( | 79 | assert bytes.fromhex('d51f3e99bf8e6f0b') == ( |
| 81 | otp2289.OTPGenerator.tokens_to_bytes('RUST WELT KICK FELL TAIL FRAU') | 80 | otp2289.OTPGenerator.tokens_to_bytes('RUST WELT KICK FELL TAIL FRAU') |
| 82 | ) | 81 | ) |
| 83 | assert binascii.unhexlify('82aeb52d943774e4') == ( | 82 | assert bytes.fromhex('82aeb52d943774e4') == ( |
| 84 | otp2289.OTPGenerator.tokens_to_bytes('FLIT DOSE ALSO MEW DRUM DEFY') | 83 | otp2289.OTPGenerator.tokens_to_bytes('FLIT DOSE ALSO MEW DRUM DEFY') |
| 85 | ) | 84 | ) |
| 86 | assert binascii.unhexlify('4f296a74fe1567ec') == ( | 85 | assert bytes.fromhex('4f296a74fe1567ec') == ( |
| 87 | otp2289.OTPGenerator.tokens_to_bytes('AURA ALOE HURL WING BERG WAIT') | 86 | otp2289.OTPGenerator.tokens_to_bytes('AURA ALOE HURL WING BERG WAIT') |
| 88 | ) | 87 | ) |
| 89 | 88 | ||
| 90 | 89 | ||
| 91 | def test_random_bytes(): | 90 | def test_random_bytes() -> None: |
| 92 | """Implement a few tests with random bytes""" | 91 | """Implement a few tests with random bytes""" |
| 93 | for _ in range(10): | 92 | for _ in range(10): |
| 94 | rnd_bytes = os.urandom(8) # 64 bits | 93 | rnd_bytes = os.urandom(8) # 64 bits |
| 95 | tokens = otp2289.OTPGenerator.bytes_to_tokens(rnd_bytes) | 94 | tokens = otp2289.OTPResponse.bytes_to_tokens(rnd_bytes) |
| 96 | assert rnd_bytes == otp2289.OTPGenerator.tokens_to_bytes(tokens) | 95 | assert rnd_bytes == otp2289.OTPGenerator.tokens_to_bytes(tokens) |
