Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
# C extensions
*.so

# Virtual environments
.venv
venv

# Packages
*.egg
*.egg-info
Expand Down
14 changes: 0 additions & 14 deletions .travis.yml

This file was deleted.

11 changes: 9 additions & 2 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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, |
+---------+------------+------------------------------------------------------+
Expand Down
2 changes: 1 addition & 1 deletion docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
91 changes: 49 additions & 42 deletions onetimepass/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 <tadeck@gmail.com>'
__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.

Expand All @@ -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.
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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

Expand Down
52 changes: 52 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"]
3 changes: 1 addition & 2 deletions requirements/production.txt
Original file line number Diff line number Diff line change
@@ -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).
2 changes: 1 addition & 1 deletion requirements/tests.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
-r production.txt
timecop
freezegun
56 changes: 0 additions & 56 deletions setup.py

This file was deleted.

Loading
Loading