From 33f9722c875d203fb598f2fed221c64c7d5fc4fb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 01:01:58 +0000 Subject: [PATCH] Modernise for Python 3.8+ and drop legacy dependencies - Remove the six dependency; the library now targets Python 3.8+ only - Add PEP 484 type hints to the public API - Use constant-time comparison (hmac.compare_digest) when validating tokens - Replace setup.py with a PEP 621 pyproject.toml (bump to 2.0.0) - Swap timecop for freezegun in the test suite - Replace Travis CI with a GitHub Actions matrix (Python 3.8-3.13) - Update README badge/changelog and docs version Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01AzdBWLnyXWnN89YYbPxpwb --- .github/workflows/ci.yml | 27 +++++++++++ .gitignore | 4 ++ .travis.yml | 14 ------ README.rst | 11 ++++- docs/conf.py | 2 +- onetimepass/__init__.py | 91 ++++++++++++++++++++----------------- pyproject.toml | 52 +++++++++++++++++++++ requirements/production.txt | 3 +- requirements/tests.txt | 2 +- setup.py | 56 ----------------------- tests/__init__.py | 31 +++++++------ 11 files changed, 160 insertions(+), 133 deletions(-) create mode 100644 .github/workflows/ci.yml delete mode 100644 .travis.yml create mode 100644 pyproject.toml delete mode 100644 setup.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6248fd8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,27 @@ +name: CI + +on: + push: + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13'] + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e . -r requirements/tests.txt + - name: Run unit tests + run: python -m unittest tests + - name: Run doctests + run: python -m doctest onetimepass/__init__.py -v diff --git a/.gitignore b/.gitignore index d30285e..297de91 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,10 @@ # C extensions *.so +# Virtual environments +.venv +venv + # Packages *.egg *.egg-info diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 244d37f..0000000 --- a/.travis.yml +++ /dev/null @@ -1,14 +0,0 @@ -language: python -python: - - "2.6" - - "2.7" - - "3.2" - - "3.3" - - "3.4" - - "3.5.0b3" - - "3.5-dev" - - "nightly" - - "pypy" - - "pypy3" -install: "pip install -r requirements/tests.txt" -script: "python -munittest tests" diff --git a/README.rst b/README.rst index e423a3f..0a2a815 100644 --- a/README.rst +++ b/README.rst @@ -4,8 +4,8 @@ Versions Current development release: `onetimepass-master.tar.gz`_ |otp-status-dev|_ .. |otp-status-dev| image:: - https://api.travis-ci.org/tadeck/onetimepass.png?branch=master -.. _otp-status-dev: https://travis-ci.org/tadeck/onetimepass + https://github.com/tadeck/onetimepass/actions/workflows/ci.yml/badge.svg +.. _otp-status-dev: https://github.com/tadeck/onetimepass/actions/workflows/ci.yml .. _onetimepass-master.tar.gz: https://github.com/tadeck/onetimepass/archive/master.tar.gz @@ -15,6 +15,13 @@ Changelog +---------+------------+------------------------------------------------------+ | Version | Date | Changes | +=========+============+======================================================+ +| 2.0.0 | 2026-06-21 | - dropped Python 2 support (now requires Python 3.8+),| +| | | - removed the ``six`` dependency (no runtime deps), | +| | | - switched packaging to ``pyproject.toml``, | +| | | - added type hints to the public API, | +| | | - constant-time token comparison during validation, | +| | | - moved CI from Travis to GitHub Actions, | ++---------+------------+------------------------------------------------------+ | 1.0.1 | 2015-07-31 | - fixed tests and build system, | | | | - extended test coverage with Py3.5, PyPy and PyPy3, | +---------+------------+------------------------------------------------------+ diff --git a/docs/conf.py b/docs/conf.py index f12ea6a..5ddafbe 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -48,7 +48,7 @@ # built documents. # # The short X.Y version. -version = '1.0' +version = '2.0' # The full version, including alpha/beta/rc tags. release = '{}.0'.format(version) diff --git a/onetimepass/__init__.py b/onetimepass/__init__.py index 7cbccf2..2031ea7 100755 --- a/onetimepass/__init__.py +++ b/onetimepass/__init__.py @@ -3,7 +3,7 @@ time-based. It is compatible with Google Authenticator application and applications based on it. -@version: 1.0.1 +@version: 2.0.0 @author: Tomasz Jaskowski @contact: http://github.com/tadeck @license: MIT @@ -27,21 +27,23 @@ False """ +from __future__ import annotations + import base64 import hashlib import hmac -import six import struct import time +from typing import Any, Callable __author__ = 'Tomasz Jaskowski ' -__date__ = '31 July 2015' -__version_info__ = (1, 0, 1) +__date__ = '21 June 2026' +__version_info__ = (2, 0, 0) __version__ = '%s.%s.%s' % __version_info__ __license__ = 'MIT' -def _is_possible_token(token, token_length=6): +def _is_possible_token(token: int | bytes | str, token_length: int = 6) -> bool: """Determines if given value is acceptable as a token. Used when validating tokens. @@ -64,18 +66,18 @@ def _is_possible_token(token, token_length=6): False """ if not isinstance(token, bytes): - token = six.b(str(token)) + token = str(token).encode('utf-8') return token.isdigit() and len(token) <= token_length def get_hotp( - secret, - intervals_no, - as_string=False, - casefold=True, - digest_method=hashlib.sha1, - token_length=6, -): + secret: bytes | str, + intervals_no: int, + as_string: bool = False, + casefold: bool = True, + digest_method: Callable[..., Any] = hashlib.sha1, + token_length: int = 6, +) -> int | bytes: """ Get HMAC-based one-time password on the basis of given secret and interval number. @@ -104,7 +106,7 @@ def get_hotp( >>> result == b'816065' True """ - if isinstance(secret, six.string_types): + if isinstance(secret, str): # It is unicode, convert it to bytes secret = secret.encode('utf-8') # Get rid of all the spacing: @@ -115,25 +117,25 @@ def get_hotp( raise TypeError('Incorrect secret') msg = struct.pack('>Q', intervals_no) hmac_digest = hmac.new(key, msg, digest_method).digest() - ob = hmac_digest[19] if six.PY3 else ord(hmac_digest[19]) + ob = hmac_digest[19] o = ob & 15 token_base = struct.unpack('>I', hmac_digest[o:o + 4])[0] & 0x7fffffff token = token_base % (10 ** token_length) if as_string: # TODO: should as_string=True return unicode, not bytes? - return six.b('{{:0{}d}}'.format(token_length).format(token)) + return '{:0{}d}'.format(token, token_length).encode('utf-8') else: return token def get_totp( - secret, - as_string=False, - digest_method=hashlib.sha1, - token_length=6, - interval_length=30, - clock=None, -): + secret: bytes | str, + as_string: bool = False, + digest_method: Callable[..., Any] = hashlib.sha1, + token_length: int = 6, + interval_length: int = 30, + clock: float | None = None, +) -> int | bytes: """Get time-based one-time password on the basis of given secret and time. :param secret: the base32-encoded string acting as secret key @@ -171,13 +173,13 @@ def get_totp( def valid_hotp( - token, - secret, - last=1, - trials=1000, - digest_method=hashlib.sha1, - token_length=6, -): + token: int | bytes | str, + secret: bytes | str, + last: int = 1, + trials: int = 1000, + digest_method: Callable[..., Any] = hashlib.sha1, + token_length: int = 6, +) -> int | bool: """Check if given token is valid for given secret. Return interval number that was successful, or False if not found. @@ -206,27 +208,29 @@ def valid_hotp( """ if not _is_possible_token(token, token_length=token_length): return False - for i in six.moves.xrange(last + 1, last + trials + 1): + token_str = '{:0{}d}'.format(int(token), token_length) + for i in range(last + 1, last + trials + 1): token_candidate = get_hotp( secret=secret, intervals_no=i, digest_method=digest_method, token_length=token_length, ) - if token_candidate == int(token): + candidate_str = '{:0{}d}'.format(token_candidate, token_length) + if hmac.compare_digest(candidate_str, token_str): return i return False def valid_totp( - token, - secret, - digest_method=hashlib.sha1, - token_length=6, - interval_length=30, - clock=None, - window=0, -): + token: int | bytes | str, + secret: bytes | str, + digest_method: Callable[..., Any] = hashlib.sha1, + token_length: int = 6, + interval_length: int = 30, + clock: float | None = None, + window: int = 0, +) -> bool: """Check if given token is valid time-based one-time password for given secret. @@ -264,14 +268,17 @@ def valid_totp( if _is_possible_token(token, token_length=token_length): if clock is None: clock = time.time() + token_str = '{:0{}d}'.format(int(token), token_length) for w in range(-window, window+1): - if int(token) == get_totp( + candidate = get_totp( secret, digest_method=digest_method, token_length=token_length, interval_length=interval_length, clock=int(clock)+(w*interval_length) - ): + ) + candidate_str = '{:0{}d}'.format(candidate, token_length) + if hmac.compare_digest(candidate_str, token_str): return True return False diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..ef8920a --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,52 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "onetimepass" +version = "2.0.0" +description = "Module for generating and validating HOTP and TOTP tokens" +readme = {file = "README.rst", content-type = "text/x-rst"} +requires-python = ">=3.8" +license = {text = "MIT"} +authors = [ + {name = "Tomasz Jaskowski", email = "tadeck@gmail.com"}, +] +keywords = ["otp", "hotp", "totp", "one-time password", "2fa", "google authenticator"] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "Intended Audience :: Education", + "Intended Audience :: Financial and Insurance Industry", + "Intended Audience :: Healthcare Industry", + "Intended Audience :: Information Technology", + "Intended Audience :: Legal Industry", + "Intended Audience :: Science/Research", + "Intended Audience :: System Administrators", + "Intended Audience :: Telecommunications Industry", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Internet :: WWW/HTTP :: Session", + "Topic :: Internet :: WWW/HTTP :: Site Management", + "Topic :: Security", + "Topic :: Software Development :: Libraries", + "Topic :: Software Development :: Libraries :: Python Modules", +] +dependencies = [] + +[project.optional-dependencies] +test = ["freezegun"] + +[project.urls] +Homepage = "https://github.com/tadeck/onetimepass/" +Repository = "https://github.com/tadeck/onetimepass/" + +[tool.setuptools] +packages = ["onetimepass"] diff --git a/requirements/production.txt b/requirements/production.txt index bf61b24..2dd23b9 100644 --- a/requirements/production.txt +++ b/requirements/production.txt @@ -1,2 +1 @@ -# Tested with six==1.3.0 and 1.9.0, unknown compatibility with other versions -six +# onetimepass has no runtime dependencies (Python 3.8+ standard library only). diff --git a/requirements/tests.txt b/requirements/tests.txt index 102afd9..32498f5 100644 --- a/requirements/tests.txt +++ b/requirements/tests.txt @@ -1,2 +1,2 @@ -r production.txt -timecop +freezegun diff --git a/setup.py b/setup.py deleted file mode 100644 index cb769e8..0000000 --- a/setup.py +++ /dev/null @@ -1,56 +0,0 @@ -""" -onetimepass module for HMAC-Based One-Time Passwords and Time-Based One-Time -Passwords, as implemented in Google Authenticator. - -source: https://github.com/tadeck/onetimepass -author: Tomasz Jaskowski (http://www.jaskowski.info/) -""" - -try: - from setuptools import setup -except ImportError: - from distutils.core import setup -import os - -CURRENT_DIR = os.path.dirname(__file__) - -setup( - author='Tomasz Jaskowski', - author_email='tadeck@gmail.com', - classifiers=[ - 'Development Status :: 6 - Mature', - 'Intended Audience :: Developers', - 'Intended Audience :: Education', - 'Intended Audience :: Financial and Insurance Industry', - 'Intended Audience :: Healthcare Industry', - 'Intended Audience :: Information Technology', - 'Intended Audience :: Legal Industry', - 'Intended Audience :: Science/Research', - 'Intended Audience :: System Administrators', - 'Intended Audience :: Telecommunications Industry', - 'License :: OSI Approved :: MIT License', - 'Operating System :: OS Independent', - 'Programming Language :: Python :: 2.6', - 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 3.2', - 'Programming Language :: Python :: 3.3', - 'Programming Language :: Python :: 3.4', - 'Topic :: Internet :: WWW/HTTP :: Session', - 'Topic :: Internet :: WWW/HTTP :: Site Management', - 'Topic :: Security', - 'Topic :: Software Development :: Libraries', - 'Topic :: Software Development :: Libraries :: Python Modules', - ], - description='Module for generating and validating HOTP and TOTP tokens', - download_url='https://github.com/tadeck/onetimepass/archive/v1.0.0.tar.gz', - install_requires=[ - # TODO: Assign it dynamically based on requirements.txt file content - 'six', # tested with 1.3.0 and 1.9.0 - ], - license='MIT', - long_description=open(os.path.join(CURRENT_DIR, 'README.rst')).read(), - name='onetimepass', - packages=['onetimepass'], - url='https://github.com/tadeck/onetimepass/', - version='1.0.1', -) diff --git a/tests/__init__.py b/tests/__init__.py index 58dfb2e..5983811 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,11 +1,12 @@ """ Tests for ``onetimepass`` module """ -import six import time -import timecop +from datetime import datetime from unittest import TestCase +from freezegun import freeze_time + from onetimepass import ( _is_possible_token, get_hotp, get_totp, valid_hotp, valid_totp, ) @@ -26,7 +27,7 @@ def test_is_possible_token_helper(self): # bytes self.assertTrue(_is_possible_token(b'123456')) # unicode - self.assertTrue(_is_possible_token(six.u('123456'))) + self.assertTrue(_is_possible_token('123456')) # token with invalid characters self.assertFalse(_is_possible_token(b'abcdef')) @@ -34,8 +35,8 @@ def test_is_possible_token_helper(self): self.assertFalse(_is_possible_token(b'12345678')) # similar cases as above, but for unicode - self.assertFalse(_is_possible_token(six.u('abcdef'))) - self.assertFalse(_is_possible_token(six.u('12345678'))) + self.assertFalse(_is_possible_token('abcdef')) + self.assertFalse(_is_possible_token('12345678')) def test_variable_length_in_possible_tokens(self): """ @@ -68,7 +69,7 @@ def test_hotp_generation_from_unicode_secret(self): Check if HOTP is properly generated for unicode secrets """ # Simple generation from unicode - secret = six.u('MFRGGZDFMZTWQ2LK') + secret = 'MFRGGZDFMZTWQ2LK' self.assertEqual(get_hotp(secret, 1), 765705) def test_returning_hotp_as_string(self): @@ -105,7 +106,7 @@ def chunks(original, size): :param size: requested size of chunks :type size: int """ - for i in six.moves.range(0, len(original), size): + for i in range(0, len(original), size): yield original[i:i+size] # Simple generation without spaces: @@ -138,7 +139,7 @@ def test_checking_hotp_validity_for_unicode_secret(self): Validity check should also work if secret passed to valid_hotp is unicode. """ - secret = six.u('MFRGGZDFMZTWQ2LK') + secret = 'MFRGGZDFMZTWQ2LK' self.assertTrue(valid_hotp(get_hotp(secret, 123), secret)) def test_validating_correct_hotp_after_exhaustion(self): @@ -188,7 +189,7 @@ def test_generating_current_totp_and_validating(self): created HOTP for proper interval """ secret = b'MFRGGZDFMZTWQ2LK' - with timecop.freeze(time.time()): + with freeze_time(datetime.now()): hotp = get_hotp(secret=secret, intervals_no=int(time.time())//30,) totp = get_totp(secret=secret) self.assertEqual(hotp, totp) @@ -198,7 +199,7 @@ def test_generating_current_totp_as_string(self): Check if the TOTP also works seamlessly when generated as string """ secret = b'MFRGGZDFMZTWQ2LK' - with timecop.freeze(time.time()): + with freeze_time(datetime.now()): hotp = get_hotp( secret=secret, intervals_no=int(time.time())//30, @@ -213,7 +214,7 @@ def test_generating_totp_at_specific_clock(self): which is basically the same as hotp """ secret = b'MFRGGZDFMZTWQ2LK' - with timecop.freeze(time.time()): + with freeze_time(datetime.now()): hotp = get_hotp(secret=secret, intervals_no=int(time.time())//30,) totp = get_totp(secret=secret, clock=None) self.assertEqual(hotp, totp) @@ -232,7 +233,7 @@ def test_validating_totp_with_a_window(self): validate if a totp token falls within a certain window """ secret = b'MFRGGZDFMZTWQ2LK' - with timecop.freeze(time.time()): + with freeze_time(datetime.now()): totp = get_totp(secret=secret, clock=(int(time.time()-30))) self.assertFalse(valid_totp(totp, secret)) self.assertTrue(valid_totp(totp, secret, window=1)) @@ -256,7 +257,7 @@ def test_validating_totp_for_same_secret(self): Check if validating TOTP generated for the same secret works """ secret = b'MFRGGZDFMZTWQ2LK' - with timecop.freeze(time.time()): + with freeze_time(datetime.now()): self.assertTrue(valid_totp(get_totp(secret), secret)) def test_validating_invalid_totp_for_same_secret(self): @@ -264,7 +265,7 @@ def test_validating_invalid_totp_for_same_secret(self): Test case when the same secret is used, but the token differs """ secret = b'MFRGGZDFMZTWQ2LK' - with timecop.freeze(time.time()): + with freeze_time(datetime.now()): self.assertFalse(valid_totp(get_totp(secret)+1, secret)) def test_validating_correct_hotp_as_totp(self): @@ -273,5 +274,5 @@ def test_validating_correct_hotp_as_totp(self): very big interval number (matching Unix epoch timestamp) """ secret = b'MFRGGZDFMZTWQ2LK' - with timecop.freeze(time.time()): + with freeze_time(datetime.now()): self.assertFalse(valid_totp(get_hotp(secret, 1), secret))