summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSimeon Simeonov2026-04-28 14:27:05 +0200
committerSimeon Simeonov2026-04-28 14:27:05 +0200
commit751c74e7de1c78a151fdd8b76f220d8411a2108a (patch)
tree0b13345aad6949a3325ca8f72b4eece14a0fe973
parent82ef6adec6e59f9cc9dc9fa1a13a2b43e534bada (diff)
Restructure the entire project, enforce linting and add support for type checkers
-rw-r--r--.ruff.toml47
-rw-r--r--CHANGELOG.md9
-rw-r--r--COPYING4
-rw-r--r--LICENSE4
-rw-r--r--README.md17
-rw-r--r--pyproject.toml64
-rw-r--r--setup.cfg42
-rw-r--r--setup.py5
-rw-r--r--sphinx_conf.py17
-rw-r--r--src/otp2289/__init__.py10
-rw-r--r--src/otp2289/__main__.py22
-rw-r--r--src/otp2289/generator.py165
-rw-r--r--src/otp2289/server.py88
-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
diff --git a/.ruff.toml b/.ruff.toml
index c5617c1..a9fd42e 100644
--- a/.ruff.toml
+++ b/.ruff.toml
@@ -1,59 +1,40 @@
1cache-dir = "~/.cache/ruff" 1cache-dir = "~/.cache/ruff"
2indent-width = 4 2indent-width = 4
3line-length = 79 3line-length = 79
4target-version = "py312" 4target-version = "py310"
5namespace-packages = ["tests"]
6
5 7
6[lint] 8[lint]
7select = ["ALL", "D101", "D102", "D103", "D104"] 9select = ["ALL"]
8ignore = [ 10ignore = [
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.
59fixable = ["ALL"] 40fixable = ["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]
95inline-quotes = "single" 77inline-quotes = "single"
96
97[lint.isort]
98split-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)
diff --git a/COPYING b/COPYING
index 43ef739..aba69ce 100644
--- a/COPYING
+++ b/COPYING
@@ -1,6 +1,6 @@
1SPDX-License-Identifier: BSD-2-Clause-FreeBSD 1SPDX-License-Identifier: BSD-2-Clause
2 2
3Copyright (c) 2020-2023, Simeon Simeonov 3Copyright (c) 2020-2026, Simeon Simeonov
4All rights reserved. 4All rights reserved.
5 5
6Redistribution and use in source and binary forms, with or without 6Redistribution and use in source and binary forms, with or without
diff --git a/LICENSE b/LICENSE
index 43ef739..aba69ce 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,6 +1,6 @@
1SPDX-License-Identifier: BSD-2-Clause-FreeBSD 1SPDX-License-Identifier: BSD-2-Clause
2 2
3Copyright (c) 2020-2023, Simeon Simeonov 3Copyright (c) 2020-2026, Simeon Simeonov
4All rights reserved. 4All rights reserved.
5 5
6Redistribution and use in source and binary forms, with or without 6Redistribution and use in source and binary forms, with or without
diff --git a/README.md b/README.md
index 7ef9344..38fa9a7 100644
--- a/README.md
+++ b/README.md
@@ -8,11 +8,12 @@ It requires no additional libraries.
8## General 8## General
9 9
10The main reason for writing this library was the need to login into my 10The main reason for writing this library was the need to login into my
11FreeBSD servers using [opiepasswd(1)](https://www.freebsd.org/cgi/man.cgi?query=opiepasswd&sektion=1&manpath=FreeBSD) 11FreeBSD servers using [opiepasswd](https://en.wikipedia.org/wiki/OPIE_Authentication_System).
12as [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
14I decided to license the library under the 15I 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
18I hope that somebody will find it useful. 19I 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.
205Simeon Simeonov - sgs @ LiberaChat 206Simeon 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
210Copyright (c) 2020-2023 Simeon Simeonov 211Copyright (c) 2020-2026 Simeon Simeonov
211All rights reserved. 212All 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.
214SPDX-License-Identifier: BSD-2-Clause-FreeBSD 215SPDX-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]
2requires = [ 2requires = [
3 "setuptools>=51", 3 "setuptools >= 77.0.3",
4 "wheel" 4 "wheel"
5] 5]
6 6
7build-backend = "setuptools.build_meta" 7build-backend = "setuptools.build_meta"
8 8
9 9
10[project]
11name = "pyotp2289"
12description = "A pure Python implementation of 'A One-Time Password System'"
13dynamic = ["version"]
14license = "BSD-2-Clause"
15authors = [{name = "Simeon Simeonov"}]
16readme = "README.md"
17requires-python = ">= 3.10"
18
19classifiers = [
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]
36dev = [
37 "pytest>=7",
38 "ruff>=0.15.0",
39 "ty>=0.0.31",
40]
41
42
43[project.scripts]
44otp2289 = "otp2289.__main__:main"
45
46
47[project.urls]
48Homepage = "https://codeberg.org/sgs/pyotp2289"
49Repository = "https://codeberg.org/sgs/pyotp2289"
50Issues = "https://codeberg.org/sgs/pyotp2289/issues"
51Changelog = "https://codeberg.org/sgs/pyotp2289/src/branch/master/CHANGELOG.md"
52
53
10[tool.pytest.ini_options] 54[tool.pytest.ini_options]
11minversion = "7.0" 55minversion = "7.0"
12testpaths = [ 56cache_dir = "~/.cache/pytest_cache"
13 "test"
14]
15pythonpath = [ 57pythonpath = [
16 "src" 58 "src"
59]
60testpaths = [
61 "tests"
17] 62]
63
64
65[tool.setuptools.dynamic]
66version = {attr = "otp2289.__version__"}
67
68
69[tool.ty.environment]
70python-version = "3.10"
71root = ["./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]
2name = pyotp2289
3version = attr: otp2289.__version__
4author = attr: otp2289.__author__
5author_email = sgs@pichove.org
6description = A pure Python implementation of "A One-Time Password System"
7long_description = file: README.md
8long_description_content_type = text/markdown
9url = https://github.com/blackm0re/pyotp2289
10
11classifiers =
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
26project_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]
32package_dir =
33 = src
34packages = find:
35python_requires = >=3.7
36
37[options.packages.find]
38where = src
39
40[options.entry_points]
41console_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
3from setuptools import setup
4
5setup()
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
9project = 'pyotp2289' 10project = 'pyotp2289'
10copyright = '2020-2023, Simeon Simeonov' 11copyright = '2020-2026, Simeon Simeonov' # noqa: A001
11author = 'Simeon Simeonov' 12author = 'Simeon Simeonov'
12 13
13version = '1.2.1' 14version = '2.0.0'
14release = '1.2.1' 15release = '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
19extensions = [ 20extensions = ['sphinx.ext.autodoc', 'sphinx.ext.viewcode', 'sphinx.ext.todo']
20 'sphinx.ext.autodoc',
21 'sphinx.ext.viewcode',
22 'sphinx.ext.todo',
23]
24 21
25templates_path = ['_templates'] 22templates_path = ['_templates']
26exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 23exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
@@ -32,8 +29,12 @@ language = 'en'
32 29
33html_theme = 'nature' 30html_theme = 'nature'
34html_static_path = ['_static'] 31html_static_path = ['_static']
32html_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
39todo_include_todos = True 37todo_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)
34from .server import ( 35from .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
47def int_or_str(value): 48def 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
36import errno 36import errno
37import getpass 37import getpass
38import os 38import os
39import pathlib
39import secrets 40import secrets
40import string 41import string
41import sys 42import sys
@@ -43,9 +44,11 @@ import sys
43import otp2289 44import otp2289
44 45
45 46
46def eprint(*arg, **kwargs): 47def 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
51def generate_otp_response(args: argparse.Namespace) -> str: 54def 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
216def main(args=None): 219def 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
27import binascii
28import hashlib 27import hashlib
29import string 28import string
29import typing
30from collections.abc import Iterator
30 31
31OTP_ALGO_MD5 = 1 32OTP_ALGO_MD5: typing.Final[int] = 1
32OTP_ALGO_SHA1 = 2 33OTP_ALGO_SHA1: typing.Final[int] = 2
34
35# useful constants
36OTP2289_BITSTREAM_SIZE: typing.Final[int] = 64
37OTP2289_MAX_SEED_LENGTH: typing.Final[int] = 16
38OTP2289_MIN_PASSWORD_LENGTH: typing.Final[int] = 10
39OTP2289_SHA1_DIGEST_SIZE: typing.Final[int] = 20
40OTP2289_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 #
35RFC1760_TOKENS = [ 43RFC1760_TOKENS = [
@@ -2094,12 +2102,79 @@ class OTPChallengeError(Exception):
2094 """OTPChallengeError class""" 2102 """OTPChallengeError class"""
2095 2103
2096 2104
2105class 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
2097class OTPGenerator: 2169class 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
27import binascii 27from __future__ import annotations
28
28import hashlib 29import hashlib
30import typing
31
32if typing.TYPE_CHECKING:
33 from collections.abc import Iterator
29 34
30from .generator import OTP_ALGO_MD5, OTPGenerator, OTPGeneratorError 35from .generator import OTP_ALGO_MD5, OTPGenerator, OTPGeneratorError
31 36
37OTP2289_HEX_DIGEST_SIZE: typing.Final[int] = 16
38
32 39
33class OTPStateError(Exception): 40class 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
26import pytest 27import pytest
27 28
28import otp2289 29import otp2289
29 30
30 31
31def test_caller_exceptions(): 32def 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
60def test_constructor_exceptions(): 61def 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
136def test_md5(): 125def 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
210def test_sha1(): 191def 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
26import os 27import os
27import unittest.mock 28import unittest.mock
28 29
@@ -31,7 +32,7 @@ import pytest
31from otp2289.__main__ import main 32from otp2289.__main__ import main
32 33
33 34
34def test_main_generate_otp_response(capsys): 35def 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
75def test_main_generate_otp_response_env_passwd(capsys): 73def 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
117def test_main_generate_otp_range(capsys): 106def 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')
191def test_main_generate_otp_range_passwd_prompt(capsys, args): 160def 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
238def test_main_initiate(capsys): 201def 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
26import json 27import json
27 28
28import pytest 29import pytest
@@ -30,13 +31,10 @@ import pytest
30import otp2289 31import otp2289
31 32
32 33
33def test_state_caller_exceptions(): 34def 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
49def test_state_constructor_exceptions(): 47def 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
71def test_state_validation_md5(): 70def 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
86def test_state_validation_sha1(): 82def 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
101def test_store(): 94def 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"""
27import binascii 26
28import os 27import os
29 28
30import otp2289 29import otp2289
31 30
32 31
33def test_bytes_and_tokens(): 32def 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
91def test_random_bytes(): 90def 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)