PyOTP 2.10.0__tar.gz → 2.11.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pyotp-2.11.1/.github/workflows/actions.lock +64 -0
- {pyotp-2.10.0 → pyotp-2.11.1}/.github/workflows/ci.yml +7 -7
- {pyotp-2.10.0 → pyotp-2.11.1}/Changes.rst +16 -0
- {pyotp-2.10.0 → pyotp-2.11.1}/PKG-INFO +19 -9
- {pyotp-2.10.0 → pyotp-2.11.1}/README.rst +17 -7
- {pyotp-2.10.0 → pyotp-2.11.1}/common.mk +11 -1
- {pyotp-2.10.0 → pyotp-2.11.1}/pyproject.toml +11 -0
- {pyotp-2.10.0 → pyotp-2.11.1}/src/pyotp/__init__.py +10 -9
- pyotp-2.11.1/src/pyotp/compat.py +3 -0
- pyotp-2.11.1/src/pyotp/contrib/__init__.py +1 -0
- {pyotp-2.10.0 → pyotp-2.11.1}/src/pyotp/contrib/steam.py +3 -2
- {pyotp-2.10.0 → pyotp-2.11.1}/src/pyotp/hotp.py +9 -7
- {pyotp-2.10.0 → pyotp-2.11.1}/src/pyotp/otp.py +7 -5
- pyotp-2.11.1/src/pyotp/totp.py +183 -0
- {pyotp-2.10.0 → pyotp-2.11.1}/src/pyotp/utils.py +10 -9
- {pyotp-2.10.0 → pyotp-2.11.1}/test.py +254 -10
- pyotp-2.10.0/src/pyotp/compat.py +0 -9
- pyotp-2.10.0/src/pyotp/contrib/__init__.py +0 -1
- pyotp-2.10.0/src/pyotp/totp.py +0 -118
- {pyotp-2.10.0 → pyotp-2.11.1}/.github/FUNDING.yml +0 -0
- {pyotp-2.10.0 → pyotp-2.11.1}/.github/workflows/release.yml +0 -0
- {pyotp-2.10.0 → pyotp-2.11.1}/.gitignore +0 -0
- {pyotp-2.10.0 → pyotp-2.11.1}/LICENSE +0 -0
- {pyotp-2.10.0 → pyotp-2.11.1}/MANIFEST.in +0 -0
- {pyotp-2.10.0 → pyotp-2.11.1}/Makefile +0 -0
- {pyotp-2.10.0 → pyotp-2.11.1}/SECURITY.md +0 -0
- {pyotp-2.10.0 → pyotp-2.11.1}/docs/changelog.rst +0 -0
- {pyotp-2.10.0 → pyotp-2.11.1}/docs/conf.py +0 -0
- {pyotp-2.10.0 → pyotp-2.11.1}/docs/index.rst +0 -0
- {pyotp-2.10.0 → pyotp-2.11.1}/docs/toc.html +0 -0
- {pyotp-2.10.0 → pyotp-2.11.1}/src/pyotp/py.typed +0 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# This file is machine-generated by `gh actions-lock`.
|
|
2
|
+
# Do not edit by hand; run `gh actions-lock` to update.
|
|
3
|
+
# Docs: https://gh.io/actions-lockfile
|
|
4
|
+
version: 'v0.0.3'
|
|
5
|
+
workflows:
|
|
6
|
+
'.github/workflows/ci.yml':
|
|
7
|
+
- 'actions/checkout@v7'
|
|
8
|
+
- 'actions/setup-python@v7'
|
|
9
|
+
- 'astral-sh/ruff-action@v4.1.0'
|
|
10
|
+
- 'codecov/codecov-action@v7'
|
|
11
|
+
'.github/workflows/release.yml':
|
|
12
|
+
- 'actions/checkout@v4'
|
|
13
|
+
- 'actions/setup-python@v5'
|
|
14
|
+
- 'pypa/gh-action-pypi-publish@release/v1'
|
|
15
|
+
dependencies:
|
|
16
|
+
'actions/checkout@v4':
|
|
17
|
+
ref: 'v4'
|
|
18
|
+
commit: 'sha1-11d5960a326750d5838078e36cf38b85af677262'
|
|
19
|
+
owner_id: 44036562
|
|
20
|
+
repo_id: 197814629
|
|
21
|
+
'actions/checkout@v7':
|
|
22
|
+
ref: 'v7'
|
|
23
|
+
commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1'
|
|
24
|
+
owner_id: 44036562
|
|
25
|
+
repo_id: 197814629
|
|
26
|
+
'actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd':
|
|
27
|
+
ref: 'v8.0.0'
|
|
28
|
+
commit: 'sha1-ed597411d8f924073f98dfc5c65a23a2325f34cd'
|
|
29
|
+
owner_id: 44036562
|
|
30
|
+
repo_id: 205262760
|
|
31
|
+
'actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405':
|
|
32
|
+
ref: 'v6.2.0'
|
|
33
|
+
commit: 'sha1-a309ff8b426b58ec0e2a45f0f869d46889d02405'
|
|
34
|
+
owner_id: 44036562
|
|
35
|
+
repo_id: 192625525
|
|
36
|
+
'actions/setup-python@v5':
|
|
37
|
+
ref: 'v5'
|
|
38
|
+
commit: 'sha1-a26af69be951a213d495a4c3e4e4022e16d87065'
|
|
39
|
+
owner_id: 44036562
|
|
40
|
+
repo_id: 192625525
|
|
41
|
+
'actions/setup-python@v7':
|
|
42
|
+
ref: 'v7'
|
|
43
|
+
commit: 'sha1-6d4fd22b20465ccc0f7354476588e8df7c9ca0e0'
|
|
44
|
+
owner_id: 44036562
|
|
45
|
+
repo_id: 192625525
|
|
46
|
+
'astral-sh/ruff-action@v4.1.0':
|
|
47
|
+
ref: 'v4.1.0'
|
|
48
|
+
commit: 'sha1-278981a28ce3188b1e39527901f38254bf3aac89'
|
|
49
|
+
owner_id: 115962839
|
|
50
|
+
repo_id: 865075538
|
|
51
|
+
'codecov/codecov-action@v7':
|
|
52
|
+
ref: 'v7'
|
|
53
|
+
commit: 'sha1-303a32d7a59b442fa8d48b6a1cc6825c09c847a5'
|
|
54
|
+
owner_id: 8226205
|
|
55
|
+
repo_id: 200299178
|
|
56
|
+
uses:
|
|
57
|
+
- 'actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd'
|
|
58
|
+
'pypa/gh-action-pypi-publish@release/v1':
|
|
59
|
+
ref: 'release/v1'
|
|
60
|
+
commit: 'sha1-7245906fb61fa33a13554f9d5fb856fe61758086'
|
|
61
|
+
owner_id: 647025
|
|
62
|
+
repo_id: 178055147
|
|
63
|
+
uses:
|
|
64
|
+
- 'actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405'
|
|
@@ -10,22 +10,22 @@ jobs:
|
|
|
10
10
|
strategy:
|
|
11
11
|
matrix:
|
|
12
12
|
os: [ubuntu-22.04, ubuntu-latest, macos-latest]
|
|
13
|
-
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
|
|
13
|
+
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14", "3.15"]
|
|
14
14
|
steps:
|
|
15
|
-
- uses: actions/checkout@
|
|
15
|
+
- uses: actions/checkout@v7
|
|
16
16
|
- name: Set up Python ${{ matrix.python-version }}
|
|
17
|
-
uses: actions/setup-python@
|
|
17
|
+
uses: actions/setup-python@v7
|
|
18
18
|
with:
|
|
19
19
|
python-version: ${{ matrix.python-version }}
|
|
20
20
|
- run: make install
|
|
21
21
|
- run: make lint
|
|
22
22
|
- run: make test
|
|
23
|
-
- uses: codecov/codecov-action@
|
|
23
|
+
- uses: codecov/codecov-action@v7
|
|
24
24
|
ruff:
|
|
25
25
|
runs-on: ubuntu-latest
|
|
26
26
|
steps:
|
|
27
|
-
- uses: actions/checkout@
|
|
28
|
-
- uses: astral-sh/ruff-action@
|
|
29
|
-
- uses: astral-sh/ruff-action@
|
|
27
|
+
- uses: actions/checkout@v7
|
|
28
|
+
- uses: astral-sh/ruff-action@v4.1.0
|
|
29
|
+
- uses: astral-sh/ruff-action@v4.1.0
|
|
30
30
|
with:
|
|
31
31
|
args: "format --check"
|
|
@@ -1,3 +1,19 @@
|
|
|
1
|
+
Changes for v2.11.1 (2026-11-14)
|
|
2
|
+
================================
|
|
3
|
+
|
|
4
|
+
- Fix PyPI README markup and validate distributions before release
|
|
5
|
+
(#200)
|
|
6
|
+
|
|
7
|
+
Changes for v2.11.0 (2026-11-14)
|
|
8
|
+
================================
|
|
9
|
+
|
|
10
|
+
- Fix DST rollbacks and normalize TOTP timestamp inputs (#197)
|
|
11
|
+
|
|
12
|
+
- Respect initial_count=0 in HOTP.provisioning_uri() (#190)
|
|
13
|
+
|
|
14
|
+
- Implement verify_and_get_timecode, a method for TOTP that verifies
|
|
15
|
+
and returns the matching timecode (#191)
|
|
16
|
+
|
|
1
17
|
Changes for v2.10.0 (2026-06-13)
|
|
2
18
|
================================
|
|
3
19
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: PyOTP
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.11.1
|
|
4
4
|
Summary: Python One Time Password Library
|
|
5
5
|
Project-URL: Homepage, https://github.com/pyauth/pyotp
|
|
6
6
|
Project-URL: Documentation, https://github.com/pyauth/pyotp
|
|
@@ -96,7 +96,7 @@ Usage
|
|
|
96
96
|
|
|
97
97
|
Time-based OTPs
|
|
98
98
|
~~~~~~~~~~~~~~~
|
|
99
|
-
::
|
|
99
|
+
.. code:: python
|
|
100
100
|
|
|
101
101
|
import pyotp
|
|
102
102
|
import time
|
|
@@ -111,7 +111,7 @@ Time-based OTPs
|
|
|
111
111
|
|
|
112
112
|
Counter-based OTPs
|
|
113
113
|
~~~~~~~~~~~~~~~~~~
|
|
114
|
-
::
|
|
114
|
+
.. code:: python
|
|
115
115
|
|
|
116
116
|
import pyotp
|
|
117
117
|
|
|
@@ -127,11 +127,15 @@ Counter-based OTPs
|
|
|
127
127
|
Generating a Secret Key
|
|
128
128
|
~~~~~~~~~~~~~~~~~~~~~~~
|
|
129
129
|
A helper function is provided to generate a 32-character base32 secret, compatible with Google Authenticator and other
|
|
130
|
-
OTP apps
|
|
130
|
+
OTP apps:
|
|
131
|
+
|
|
132
|
+
.. code:: python
|
|
131
133
|
|
|
132
134
|
pyotp.random_base32()
|
|
133
135
|
|
|
134
|
-
Some applications want the secret key to be formatted as a hex-encoded string
|
|
136
|
+
Some applications want the secret key to be formatted as a hex-encoded string:
|
|
137
|
+
|
|
138
|
+
.. code:: python
|
|
135
139
|
|
|
136
140
|
pyotp.random_hex() # returns a 40-character hex-encoded secret
|
|
137
141
|
|
|
@@ -139,7 +143,9 @@ Google Authenticator Compatible
|
|
|
139
143
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
140
144
|
|
|
141
145
|
PyOTP works with the Google Authenticator iPhone and Android app, as well as other OTP apps like Authy. PyOTP includes
|
|
142
|
-
the ability to generate provisioning URIs for use with the QR Code scanner built into these MFA client apps
|
|
146
|
+
the ability to generate provisioning URIs for use with the QR Code scanner built into these MFA client apps:
|
|
147
|
+
|
|
148
|
+
.. code:: python
|
|
143
149
|
|
|
144
150
|
pyotp.totp.TOTP('JBSWY3DPEHPK3PXP').provisioning_uri(name='alice@google.com', issuer_name='Secure App')
|
|
145
151
|
|
|
@@ -152,7 +158,9 @@ the ability to generate provisioning URIs for use with the QR Code scanner built
|
|
|
152
158
|
This URL can then be rendered as a QR Code (for example, using https://github.com/soldair/node-qrcode) which can then be
|
|
153
159
|
scanned and added to the users list of OTP credentials.
|
|
154
160
|
|
|
155
|
-
Parsing these URLs is also supported
|
|
161
|
+
Parsing these URLs is also supported:
|
|
162
|
+
|
|
163
|
+
.. code:: python
|
|
156
164
|
|
|
157
165
|
pyotp.parse_uri('otpauth://totp/Secure%20App:alice%40google.com?secret=JBSWY3DPEHPK3PXP&issuer=Secure%20App')
|
|
158
166
|
|
|
@@ -169,7 +177,9 @@ Scan the following barcode with your phone's OTP app (e.g. Google Authenticator)
|
|
|
169
177
|
|
|
170
178
|
.. image:: https://quickchart.io/qr?size=250&text=otpauth%3A%2F%2Ftotp%2Falice%40google.com%3Fsecret%3DJBSWY3DPEHPK3PXP
|
|
171
179
|
|
|
172
|
-
Now run the following and compare the output
|
|
180
|
+
Now run the following and compare the output:
|
|
181
|
+
|
|
182
|
+
.. code:: python
|
|
173
183
|
|
|
174
184
|
import pyotp
|
|
175
185
|
totp = pyotp.TOTP("JBSWY3DPEHPK3PXP")
|
|
@@ -57,7 +57,7 @@ Usage
|
|
|
57
57
|
|
|
58
58
|
Time-based OTPs
|
|
59
59
|
~~~~~~~~~~~~~~~
|
|
60
|
-
::
|
|
60
|
+
.. code:: python
|
|
61
61
|
|
|
62
62
|
import pyotp
|
|
63
63
|
import time
|
|
@@ -72,7 +72,7 @@ Time-based OTPs
|
|
|
72
72
|
|
|
73
73
|
Counter-based OTPs
|
|
74
74
|
~~~~~~~~~~~~~~~~~~
|
|
75
|
-
::
|
|
75
|
+
.. code:: python
|
|
76
76
|
|
|
77
77
|
import pyotp
|
|
78
78
|
|
|
@@ -88,11 +88,15 @@ Counter-based OTPs
|
|
|
88
88
|
Generating a Secret Key
|
|
89
89
|
~~~~~~~~~~~~~~~~~~~~~~~
|
|
90
90
|
A helper function is provided to generate a 32-character base32 secret, compatible with Google Authenticator and other
|
|
91
|
-
OTP apps
|
|
91
|
+
OTP apps:
|
|
92
|
+
|
|
93
|
+
.. code:: python
|
|
92
94
|
|
|
93
95
|
pyotp.random_base32()
|
|
94
96
|
|
|
95
|
-
Some applications want the secret key to be formatted as a hex-encoded string
|
|
97
|
+
Some applications want the secret key to be formatted as a hex-encoded string:
|
|
98
|
+
|
|
99
|
+
.. code:: python
|
|
96
100
|
|
|
97
101
|
pyotp.random_hex() # returns a 40-character hex-encoded secret
|
|
98
102
|
|
|
@@ -100,7 +104,9 @@ Google Authenticator Compatible
|
|
|
100
104
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
101
105
|
|
|
102
106
|
PyOTP works with the Google Authenticator iPhone and Android app, as well as other OTP apps like Authy. PyOTP includes
|
|
103
|
-
the ability to generate provisioning URIs for use with the QR Code scanner built into these MFA client apps
|
|
107
|
+
the ability to generate provisioning URIs for use with the QR Code scanner built into these MFA client apps:
|
|
108
|
+
|
|
109
|
+
.. code:: python
|
|
104
110
|
|
|
105
111
|
pyotp.totp.TOTP('JBSWY3DPEHPK3PXP').provisioning_uri(name='alice@google.com', issuer_name='Secure App')
|
|
106
112
|
|
|
@@ -113,7 +119,9 @@ the ability to generate provisioning URIs for use with the QR Code scanner built
|
|
|
113
119
|
This URL can then be rendered as a QR Code (for example, using https://github.com/soldair/node-qrcode) which can then be
|
|
114
120
|
scanned and added to the users list of OTP credentials.
|
|
115
121
|
|
|
116
|
-
Parsing these URLs is also supported
|
|
122
|
+
Parsing these URLs is also supported:
|
|
123
|
+
|
|
124
|
+
.. code:: python
|
|
117
125
|
|
|
118
126
|
pyotp.parse_uri('otpauth://totp/Secure%20App:alice%40google.com?secret=JBSWY3DPEHPK3PXP&issuer=Secure%20App')
|
|
119
127
|
|
|
@@ -130,7 +138,9 @@ Scan the following barcode with your phone's OTP app (e.g. Google Authenticator)
|
|
|
130
138
|
|
|
131
139
|
.. image:: https://quickchart.io/qr?size=250&text=otpauth%3A%2F%2Ftotp%2Falice%40google.com%3Fsecret%3DJBSWY3DPEHPK3PXP
|
|
132
140
|
|
|
133
|
-
Now run the following and compare the output
|
|
141
|
+
Now run the following and compare the output:
|
|
142
|
+
|
|
143
|
+
.. code:: python
|
|
134
144
|
|
|
135
145
|
import pyotp
|
|
136
146
|
totp = pyotp.TOTP("JBSWY3DPEHPK3PXP")
|
|
@@ -13,12 +13,15 @@ release-patch:
|
|
|
13
13
|
$(MAKE) release
|
|
14
14
|
|
|
15
15
|
release:
|
|
16
|
+
@if [[ "$$(git symbolic-ref --quiet HEAD)" != refs/heads/main ]]; then echo "Releases must be run from the main branch"; exit 1; fi
|
|
16
17
|
@if ! git diff --cached --exit-code; then echo "Commit staged files before proceeding"; exit 1; fi
|
|
18
|
+
@if ! git diff --exit-code; then echo "Commit unstaged changes before proceeding"; exit 1; fi
|
|
17
19
|
@if [[ -z $$TAG ]]; then echo "Use release-{major,minor,patch}"; exit 1; fi
|
|
18
20
|
@if ! type -P pandoc; then echo "Please install pandoc"; exit 1; fi
|
|
19
21
|
@if ! type -P sponge; then echo "Please install moreutils"; exit 1; fi
|
|
20
22
|
@if ! type -P gh; then echo "Please install gh"; exit 1; fi
|
|
21
23
|
git pull
|
|
24
|
+
$(MAKE) check-dist
|
|
22
25
|
TAG_MSG=$$(mktemp); \
|
|
23
26
|
echo "# Changes for ${TAG} ($$(date +%Y-%m-%d))" > $$TAG_MSG; \
|
|
24
27
|
git log --pretty=format:%s $$(git describe --abbrev=0)..HEAD >> $$TAG_MSG; \
|
|
@@ -32,6 +35,13 @@ release:
|
|
|
32
35
|
gh release create ${TAG} dist/*.whl --notes="$$(git tag --list ${TAG} -n99 | perl -pe 's/^\S+\s*// if $$. == 1' | sed 's/^\s\s\s\s//')"
|
|
33
36
|
$(MAKE) release-docs
|
|
34
37
|
|
|
38
|
+
check-dist:
|
|
39
|
+
@python -c 'import build, twine' 2>/dev/null || { echo "Please install build and twine: python -m pip install build twine"; exit 1; }
|
|
40
|
+
@release_dist_dir=$$(mktemp -d); \
|
|
41
|
+
trap 'rm -rf "$$release_dist_dir"' EXIT; \
|
|
42
|
+
python -m build --outdir "$$release_dist_dir"; \
|
|
43
|
+
python -m twine check --strict "$$release_dist_dir"/*
|
|
44
|
+
|
|
35
45
|
release-docs:
|
|
36
46
|
$(MAKE) docs
|
|
37
47
|
-git branch -D gh-pages
|
|
@@ -42,4 +52,4 @@ release-docs:
|
|
|
42
52
|
git push --force origin $$(git subtree split --prefix docs/html --branch gh-pages):refs/heads/gh-pages
|
|
43
53
|
git checkout -
|
|
44
54
|
|
|
45
|
-
.PHONY: release
|
|
55
|
+
.PHONY: release check-dist
|
|
@@ -53,3 +53,14 @@ line_length = 120
|
|
|
53
53
|
|
|
54
54
|
[tool.ruff]
|
|
55
55
|
line-length = 120
|
|
56
|
+
|
|
57
|
+
[tool.ruff.lint]
|
|
58
|
+
ignore = [
|
|
59
|
+
"SIM102", # Allow nested if statements.
|
|
60
|
+
"SIM117", # Allow nested with statements.
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
[tool.ruff.lint.per-file-ignores]
|
|
64
|
+
"**/__init__.py" = ["F401"]
|
|
65
|
+
# Keep accepted timestamp types explicit in public API annotations.
|
|
66
|
+
"src/pyotp/totp.py" = ["PYI041"]
|
|
@@ -1,13 +1,15 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
import hashlib
|
|
2
4
|
from re import split
|
|
3
|
-
from typing import Any,
|
|
5
|
+
from typing import Any, Sequence
|
|
4
6
|
from urllib.parse import parse_qsl, unquote, urlparse
|
|
5
7
|
|
|
6
|
-
from . import contrib
|
|
8
|
+
from . import contrib
|
|
7
9
|
from .compat import random
|
|
8
|
-
from .hotp import HOTP
|
|
9
|
-
from .otp import OTP
|
|
10
|
-
from .totp import TOTP
|
|
10
|
+
from .hotp import HOTP
|
|
11
|
+
from .otp import OTP
|
|
12
|
+
from .totp import TOTP
|
|
11
13
|
|
|
12
14
|
|
|
13
15
|
def random_base32(length: int = 32, chars: Sequence[str] = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567") -> str:
|
|
@@ -47,7 +49,7 @@ def parse_uri(uri: str) -> OTP:
|
|
|
47
49
|
digits = None
|
|
48
50
|
|
|
49
51
|
# Data we'll parse to the correct constructor
|
|
50
|
-
otp_data:
|
|
52
|
+
otp_data: dict[str, Any] = {}
|
|
51
53
|
|
|
52
54
|
# Parse the URI structure first, then unquote individual components.
|
|
53
55
|
# Unquoting the whole URI before splitting the label would turn an
|
|
@@ -97,9 +99,8 @@ def parse_uri(uri: str) -> OTP:
|
|
|
97
99
|
elif key == "counter":
|
|
98
100
|
otp_data["initial_count"] = int(value)
|
|
99
101
|
|
|
100
|
-
if encoder != "steam":
|
|
101
|
-
|
|
102
|
-
raise ValueError("Digits may only be 6, 7, or 8")
|
|
102
|
+
if encoder != "steam" and digits is not None and digits not in [6, 7, 8]:
|
|
103
|
+
raise ValueError("Digits may only be 6, 7, or 8")
|
|
103
104
|
|
|
104
105
|
if not secret:
|
|
105
106
|
raise ValueError("No secret found in URI")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .steam import Steam
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
import hashlib
|
|
2
|
-
from typing import Optional
|
|
3
4
|
|
|
4
5
|
from ..totp import TOTP
|
|
5
6
|
|
|
@@ -13,7 +14,7 @@ class Steam(TOTP):
|
|
|
13
14
|
"""
|
|
14
15
|
|
|
15
16
|
def __init__(
|
|
16
|
-
self, s: str, name:
|
|
17
|
+
self, s: str, name: str | None = None, issuer: str | None = None, interval: int = 30, digits: int = 5
|
|
17
18
|
) -> None:
|
|
18
19
|
"""
|
|
19
20
|
:param s: secret in base32 format
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
import hashlib
|
|
2
|
-
from typing import Any
|
|
4
|
+
from typing import Any
|
|
3
5
|
|
|
4
6
|
from . import utils
|
|
5
7
|
from .otp import OTP
|
|
@@ -15,8 +17,8 @@ class HOTP(OTP):
|
|
|
15
17
|
s: str,
|
|
16
18
|
digits: int = 6,
|
|
17
19
|
digest: Any = None,
|
|
18
|
-
name:
|
|
19
|
-
issuer:
|
|
20
|
+
name: str | None = None,
|
|
21
|
+
issuer: str | None = None,
|
|
20
22
|
initial_count: int = 0,
|
|
21
23
|
) -> None:
|
|
22
24
|
"""
|
|
@@ -55,9 +57,9 @@ class HOTP(OTP):
|
|
|
55
57
|
|
|
56
58
|
def provisioning_uri(
|
|
57
59
|
self,
|
|
58
|
-
name:
|
|
59
|
-
initial_count:
|
|
60
|
-
issuer_name:
|
|
60
|
+
name: str | None = None,
|
|
61
|
+
initial_count: int | None = None,
|
|
62
|
+
issuer_name: str | None = None,
|
|
61
63
|
**kwargs,
|
|
62
64
|
) -> str:
|
|
63
65
|
"""
|
|
@@ -77,7 +79,7 @@ class HOTP(OTP):
|
|
|
77
79
|
return utils.build_uri(
|
|
78
80
|
self.secret,
|
|
79
81
|
name=name if name else self.name,
|
|
80
|
-
initial_count=initial_count if initial_count else self.initial_count,
|
|
82
|
+
initial_count=initial_count if initial_count is not None else self.initial_count,
|
|
81
83
|
issuer=issuer_name if issuer_name else self.issuer,
|
|
82
84
|
algorithm=self.digest().name,
|
|
83
85
|
digits=self.digits,
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
import base64
|
|
2
4
|
import hashlib
|
|
3
5
|
import hmac
|
|
4
|
-
from typing import Any
|
|
6
|
+
from typing import Any
|
|
5
7
|
|
|
6
8
|
|
|
7
|
-
class OTP
|
|
9
|
+
class OTP:
|
|
8
10
|
"""
|
|
9
11
|
Base class for OTP handlers.
|
|
10
12
|
"""
|
|
@@ -14,8 +16,8 @@ class OTP(object):
|
|
|
14
16
|
s: str,
|
|
15
17
|
digits: int = 6,
|
|
16
18
|
digest: Any = hashlib.sha1,
|
|
17
|
-
name:
|
|
18
|
-
issuer:
|
|
19
|
+
name: str | None = None,
|
|
20
|
+
issuer: str | None = None,
|
|
19
21
|
) -> None:
|
|
20
22
|
self.digits = digits
|
|
21
23
|
if digits > 10:
|
|
@@ -69,4 +71,4 @@ class OTP(object):
|
|
|
69
71
|
# It's necessary to convert the final result from bytearray to bytes
|
|
70
72
|
# because the hmac functions in python 2.6 and 3.3 don't work with
|
|
71
73
|
# bytearray
|
|
72
|
-
return bytes(
|
|
74
|
+
return bytes(reversed(result.ljust(padding, b"\0")))
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import calendar
|
|
4
|
+
import datetime
|
|
5
|
+
import hashlib
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any, Literal
|
|
8
|
+
|
|
9
|
+
from . import utils
|
|
10
|
+
from .otp import OTP
|
|
11
|
+
|
|
12
|
+
_TIMESTAMP_LIMIT = 253402300800 # 10000-01-01 00:00:00 UTC, exclusive.
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TOTP(OTP):
|
|
16
|
+
"""
|
|
17
|
+
Handler for time-based OTP counters.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
s: str,
|
|
23
|
+
digits: int = 6,
|
|
24
|
+
digest: Any = None,
|
|
25
|
+
name: str | None = None,
|
|
26
|
+
issuer: str | None = None,
|
|
27
|
+
interval: int = 30,
|
|
28
|
+
) -> None:
|
|
29
|
+
"""
|
|
30
|
+
:param s: secret in base32 format
|
|
31
|
+
:param digits: number of integers in the OTP. Some apps expect this to be 6 digits, others support more.
|
|
32
|
+
:param digest: digest function to use in the HMAC (expected to be SHA1)
|
|
33
|
+
:param name: account name
|
|
34
|
+
:param issuer: issuer
|
|
35
|
+
:param interval: the time interval in seconds for OTP. This defaults to 30.
|
|
36
|
+
"""
|
|
37
|
+
if digest is None:
|
|
38
|
+
digest = hashlib.sha1
|
|
39
|
+
elif digest in [hashlib.md5, hashlib.shake_128]:
|
|
40
|
+
raise ValueError("selected digest function must generate digest size greater than or equals to 18 bytes")
|
|
41
|
+
|
|
42
|
+
self.interval = interval
|
|
43
|
+
super().__init__(s=s, digits=digits, digest=digest, name=name, issuer=issuer)
|
|
44
|
+
|
|
45
|
+
def at(self, for_time: int | float | datetime.datetime, counter_offset: int = 0) -> str:
|
|
46
|
+
"""
|
|
47
|
+
Accepts a Unix timestamp (int or float) or a datetime object.
|
|
48
|
+
|
|
49
|
+
Unix timestamps are measured in seconds since 1970-01-01 00:00:00 UTC.
|
|
50
|
+
Prefer Unix timestamps or timezone-aware UTC datetimes. Naive
|
|
51
|
+
datetimes are interpreted as local time, respecting ``fold``.
|
|
52
|
+
|
|
53
|
+
To get the time until the next timecode change (seconds until the current OTP expires), use this instead:
|
|
54
|
+
|
|
55
|
+
.. code:: python
|
|
56
|
+
|
|
57
|
+
totp = pyotp.TOTP(...)
|
|
58
|
+
time_remaining = totp.interval - time.time() % totp.interval
|
|
59
|
+
|
|
60
|
+
:param for_time: the time to generate an OTP for
|
|
61
|
+
:param counter_offset: the amount of ticks to add to the time counter
|
|
62
|
+
:returns: OTP value
|
|
63
|
+
:raises ValueError: if for_time is before the Unix epoch or after year 9999 in UTC
|
|
64
|
+
"""
|
|
65
|
+
return self.generate_otp(self.timecode(for_time) + counter_offset)
|
|
66
|
+
|
|
67
|
+
def now(self) -> str:
|
|
68
|
+
"""
|
|
69
|
+
Generate the current time OTP
|
|
70
|
+
|
|
71
|
+
:returns: OTP value
|
|
72
|
+
:raises ValueError: if the current time is before the Unix epoch or after year 9999 in UTC
|
|
73
|
+
"""
|
|
74
|
+
return self.generate_otp(self.timecode(time.time()))
|
|
75
|
+
|
|
76
|
+
def verify(self, otp: str, for_time: int | float | datetime.datetime | None = None, valid_window: int = 0) -> bool:
|
|
77
|
+
"""
|
|
78
|
+
Verifies the OTP passed in against the current time OTP.
|
|
79
|
+
|
|
80
|
+
Unix timestamps are measured in seconds since 1970-01-01 00:00:00 UTC.
|
|
81
|
+
Prefer Unix timestamps or timezone-aware UTC datetimes. Naive
|
|
82
|
+
datetimes are interpreted as local time, respecting ``fold``.
|
|
83
|
+
|
|
84
|
+
:param otp: the OTP to check against
|
|
85
|
+
:param for_time: Unix timestamp (int or float) or datetime to check OTP at (defaults to now)
|
|
86
|
+
:param valid_window: extends the validity to this many counter ticks before and after the current one
|
|
87
|
+
:returns: True if verification succeeded, False otherwise
|
|
88
|
+
:raises ValueError: if for_time is before the Unix epoch or after year 9999 in UTC
|
|
89
|
+
"""
|
|
90
|
+
if for_time is None:
|
|
91
|
+
for_time = time.time()
|
|
92
|
+
|
|
93
|
+
timecode = self.timecode(for_time)
|
|
94
|
+
|
|
95
|
+
if valid_window:
|
|
96
|
+
for i in range(-valid_window, valid_window + 1):
|
|
97
|
+
if utils.strings_equal(str(otp), str(self.generate_otp(timecode + i))):
|
|
98
|
+
return True
|
|
99
|
+
return False
|
|
100
|
+
|
|
101
|
+
return utils.strings_equal(str(otp), str(self.generate_otp(timecode)))
|
|
102
|
+
|
|
103
|
+
def verify_and_get_timecode(
|
|
104
|
+
self, otp: str, for_time: int | float | datetime.datetime | None = None, valid_window: int = 0
|
|
105
|
+
) -> int | Literal[False]:
|
|
106
|
+
"""
|
|
107
|
+
Verifies the OTP passed in against the current time OTP and returns the matching timecode.
|
|
108
|
+
|
|
109
|
+
This is useful when callers need to prevent TOTP reuse. An
|
|
110
|
+
application can store the timecode from the last successful
|
|
111
|
+
authentication and reject future OTPs whose matching timecode
|
|
112
|
+
is not greater than the stored one.
|
|
113
|
+
|
|
114
|
+
Unix timestamps are measured in seconds since 1970-01-01 00:00:00 UTC.
|
|
115
|
+
Prefer Unix timestamps or timezone-aware UTC datetimes. Naive
|
|
116
|
+
datetimes are interpreted as local time, respecting ``fold``.
|
|
117
|
+
|
|
118
|
+
:param otp: the OTP to check against
|
|
119
|
+
:param for_time: Unix timestamp (int or float) or datetime to check OTP at (defaults to now)
|
|
120
|
+
:param valid_window: extends the validity to this counter ticks before and after the current one
|
|
121
|
+
:returns: matching timecode if verification succeeded, False otherwise
|
|
122
|
+
:raises ValueError: if for_time is before the Unix epoch or after year 9999 in UTC
|
|
123
|
+
"""
|
|
124
|
+
if for_time is None:
|
|
125
|
+
for_time = time.time()
|
|
126
|
+
|
|
127
|
+
if valid_window < 0:
|
|
128
|
+
raise ValueError("valid_window cannot be negative")
|
|
129
|
+
|
|
130
|
+
timecode = self.timecode(for_time)
|
|
131
|
+
for i in range(-valid_window, valid_window + 1):
|
|
132
|
+
if utils.strings_equal(str(otp), str(self.generate_otp(timecode + i))):
|
|
133
|
+
return timecode + i
|
|
134
|
+
return False
|
|
135
|
+
|
|
136
|
+
def provisioning_uri(self, name: str | None = None, issuer_name: str | None = None, **kwargs) -> str:
|
|
137
|
+
"""
|
|
138
|
+
Returns the provisioning URI for the OTP. This can then be
|
|
139
|
+
encoded in a QR Code and used to provision an OTP app like
|
|
140
|
+
Google Authenticator.
|
|
141
|
+
|
|
142
|
+
See also:
|
|
143
|
+
https://github.com/google/google-authenticator/wiki/Key-Uri-Format
|
|
144
|
+
|
|
145
|
+
"""
|
|
146
|
+
return utils.build_uri(
|
|
147
|
+
self.secret,
|
|
148
|
+
name if name else self.name,
|
|
149
|
+
issuer=issuer_name if issuer_name else self.issuer,
|
|
150
|
+
algorithm=self.digest().name,
|
|
151
|
+
digits=self.digits,
|
|
152
|
+
period=self.interval,
|
|
153
|
+
**kwargs,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
def timecode(self, for_time: int | float | datetime.datetime) -> int:
|
|
157
|
+
"""
|
|
158
|
+
Converts a Unix timestamp (int or float) or a datetime object
|
|
159
|
+
to the corresponding counter value (timecode).
|
|
160
|
+
|
|
161
|
+
Unix timestamps are measured in seconds since 1970-01-01 00:00:00 UTC.
|
|
162
|
+
Prefer Unix timestamps or timezone-aware UTC datetimes. Naive
|
|
163
|
+
datetimes are interpreted as local time, using ``fold`` to
|
|
164
|
+
distinguish repeated clock times. Subsecond precision is ignored.
|
|
165
|
+
|
|
166
|
+
:raises ValueError: if for_time is before the Unix epoch or after year 9999 in UTC
|
|
167
|
+
"""
|
|
168
|
+
error_message = "for_time must be at or after the Unix epoch and before year 10000 UTC"
|
|
169
|
+
timestamp: int | float
|
|
170
|
+
if isinstance(for_time, datetime.datetime):
|
|
171
|
+
try:
|
|
172
|
+
if for_time.tzinfo:
|
|
173
|
+
timestamp = calendar.timegm(for_time.utctimetuple())
|
|
174
|
+
else:
|
|
175
|
+
# Preserve whole-second behavior without discarding fold.
|
|
176
|
+
timestamp = for_time.replace(microsecond=0).timestamp()
|
|
177
|
+
except (OverflowError, OSError, ValueError) as error:
|
|
178
|
+
raise ValueError(error_message) from error
|
|
179
|
+
else:
|
|
180
|
+
timestamp = for_time
|
|
181
|
+
if not 0 <= timestamp < _TIMESTAMP_LIMIT:
|
|
182
|
+
raise ValueError(error_message)
|
|
183
|
+
return int(int(timestamp) / self.interval)
|
|
@@ -1,17 +1,18 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
import unicodedata
|
|
2
4
|
from hmac import compare_digest
|
|
3
|
-
from typing import Dict, Optional, Union
|
|
4
5
|
from urllib.parse import quote, urlencode, urlparse
|
|
5
6
|
|
|
6
7
|
|
|
7
8
|
def build_uri(
|
|
8
9
|
secret: str,
|
|
9
10
|
name: str,
|
|
10
|
-
initial_count:
|
|
11
|
-
issuer:
|
|
12
|
-
algorithm:
|
|
13
|
-
digits:
|
|
14
|
-
period:
|
|
11
|
+
initial_count: int | None = None,
|
|
12
|
+
issuer: str | None = None,
|
|
13
|
+
algorithm: str | None = None,
|
|
14
|
+
digits: int | None = None,
|
|
15
|
+
period: int | None = None,
|
|
15
16
|
**kwargs,
|
|
16
17
|
) -> str:
|
|
17
18
|
"""
|
|
@@ -49,7 +50,7 @@ def build_uri(
|
|
|
49
50
|
otp_type = "hotp" if is_initial_count_present else "totp"
|
|
50
51
|
base_uri = "otpauth://{0}/{1}?{2}"
|
|
51
52
|
|
|
52
|
-
url_args:
|
|
53
|
+
url_args: dict[str, None | int | str] = {"secret": secret}
|
|
53
54
|
|
|
54
55
|
label = quote(name)
|
|
55
56
|
if issuer is not None:
|
|
@@ -66,11 +67,11 @@ def build_uri(
|
|
|
66
67
|
url_args["period"] = period
|
|
67
68
|
for k, v in kwargs.items():
|
|
68
69
|
if not isinstance(v, str):
|
|
69
|
-
raise
|
|
70
|
+
raise TypeError("All otpauth uri parameters must be strings")
|
|
70
71
|
if k == "image":
|
|
71
72
|
image_uri = urlparse(v)
|
|
72
73
|
if image_uri.scheme != "https" or not image_uri.netloc or not image_uri.path:
|
|
73
|
-
raise ValueError("{} is not a valid url"
|
|
74
|
+
raise ValueError(f"{image_uri} is not a valid url")
|
|
74
75
|
url_args[k] = v
|
|
75
76
|
|
|
76
77
|
uri = base_uri.format(otp_type, label, urlencode(url_args).replace("+", "%20"))
|
|
@@ -5,12 +5,15 @@ import datetime
|
|
|
5
5
|
import hashlib
|
|
6
6
|
import os
|
|
7
7
|
import sys
|
|
8
|
+
import time
|
|
8
9
|
import unittest
|
|
10
|
+
from typing import ClassVar
|
|
11
|
+
from unittest.mock import patch
|
|
9
12
|
from urllib.parse import parse_qsl, urlparse
|
|
10
13
|
from warnings import warn
|
|
11
14
|
|
|
12
15
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "src"))
|
|
13
|
-
import pyotp
|
|
16
|
+
import pyotp
|
|
14
17
|
|
|
15
18
|
|
|
16
19
|
class HOTPExampleValuesFromTheRFC(unittest.TestCase):
|
|
@@ -97,6 +100,11 @@ class HOTPExampleValuesFromTheRFC(unittest.TestCase):
|
|
|
97
100
|
)
|
|
98
101
|
self.assertEqual(hotp.provisioning_uri(), pyotp.parse_uri(hotp.provisioning_uri()).provisioning_uri())
|
|
99
102
|
|
|
103
|
+
# initial_count=0 must be respected even though 0 is falsy
|
|
104
|
+
hotp = pyotp.HOTP("wrn3pqx5uqxqvnqr", name="mark@percival", initial_count=7)
|
|
105
|
+
url = urlparse(hotp.provisioning_uri(initial_count=0))
|
|
106
|
+
self.assertEqual(dict(parse_qsl(url.query))["counter"], "0")
|
|
107
|
+
|
|
100
108
|
code = pyotp.totp.TOTP("S46SQCPPTCNPROMHWYBDCTBZXV")
|
|
101
109
|
self.assertEqual(code.provisioning_uri(), "otpauth://totp/Secret?secret=S46SQCPPTCNPROMHWYBDCTBZXV")
|
|
102
110
|
code.verify("123456")
|
|
@@ -112,7 +120,7 @@ class HOTPExampleValuesFromTheRFC(unittest.TestCase):
|
|
|
112
120
|
|
|
113
121
|
|
|
114
122
|
class TOTPExampleValuesFromTheRFC(unittest.TestCase):
|
|
115
|
-
RFC_VALUES = {
|
|
123
|
+
RFC_VALUES: ClassVar[dict] = {
|
|
116
124
|
(hashlib.sha1, b"12345678901234567890"): (
|
|
117
125
|
(59, "94287082"),
|
|
118
126
|
(1111111109, "07081804"),
|
|
@@ -175,11 +183,10 @@ class TOTPExampleValuesFromTheRFC(unittest.TestCase):
|
|
|
175
183
|
|
|
176
184
|
def test_input_before_epoch(self):
|
|
177
185
|
totp = pyotp.TOTP("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ")
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
totp.at(-30)
|
|
186
|
+
for timestamp in (-30, -29.5, -1, -0.1):
|
|
187
|
+
with self.subTest(timestamp=timestamp):
|
|
188
|
+
with self.assertRaises(ValueError):
|
|
189
|
+
totp.at(timestamp)
|
|
183
190
|
|
|
184
191
|
def test_validate_totp_with_digit_length(self):
|
|
185
192
|
totp = pyotp.TOTP("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ")
|
|
@@ -245,6 +252,15 @@ class TOTPExampleValuesFromTheRFC(unittest.TestCase):
|
|
|
245
252
|
)
|
|
246
253
|
self.assertEqual(totp.provisioning_uri(), pyotp.parse_uri(totp.provisioning_uri()).provisioning_uri())
|
|
247
254
|
|
|
255
|
+
def test_provisioning_uri_parameter_types(self):
|
|
256
|
+
for otp_type in (pyotp.TOTP, pyotp.HOTP):
|
|
257
|
+
with self.subTest(otp_type=otp_type):
|
|
258
|
+
otp = otp_type("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ")
|
|
259
|
+
with self.assertRaises(TypeError):
|
|
260
|
+
otp.provisioning_uri(image=123)
|
|
261
|
+
with self.assertRaises(ValueError):
|
|
262
|
+
otp.provisioning_uri(image="not-a-url")
|
|
263
|
+
|
|
248
264
|
def test_random_key_generation(self):
|
|
249
265
|
self.assertEqual(len(pyotp.random_base32()), 32)
|
|
250
266
|
self.assertEqual(len(pyotp.random_base32(length=34)), 34)
|
|
@@ -256,6 +272,209 @@ class TOTPExampleValuesFromTheRFC(unittest.TestCase):
|
|
|
256
272
|
pyotp.random_hex(length=39)
|
|
257
273
|
|
|
258
274
|
|
|
275
|
+
@unittest.skipUnless(hasattr(time, "tzset"), "Local timezone tests require time.tzset()")
|
|
276
|
+
class TOTPTimezones(unittest.TestCase):
|
|
277
|
+
def setUp(self):
|
|
278
|
+
self.original_tz = os.environ.get("TZ")
|
|
279
|
+
self.addCleanup(self.restore_timezone)
|
|
280
|
+
|
|
281
|
+
def restore_timezone(self):
|
|
282
|
+
if self.original_tz is None:
|
|
283
|
+
os.environ.pop("TZ", None)
|
|
284
|
+
else:
|
|
285
|
+
os.environ["TZ"] = self.original_tz
|
|
286
|
+
time.tzset()
|
|
287
|
+
|
|
288
|
+
def test_repeated_local_times(self):
|
|
289
|
+
# POSIX rules keep these tests independent of the installed timezone database.
|
|
290
|
+
transitions = (
|
|
291
|
+
("GMT0BST,M3.5.0/1,M10.5.0/2", "2026-10-25T00:30:00+00:00", "2026-10-25T01:30:00+00:00"),
|
|
292
|
+
("EST5EDT,M3.2.0/2,M11.1.0/2", "2026-11-01T05:30:00+00:00", "2026-11-01T06:30:00+00:00"),
|
|
293
|
+
("LHST-10:30LHDT-11,M10.1.0/2,M4.1.0/2", "2026-04-04T14:45:00+00:00", "2026-04-04T15:15:00+00:00"),
|
|
294
|
+
)
|
|
295
|
+
for timezone, first, second in transitions:
|
|
296
|
+
os.environ["TZ"] = timezone
|
|
297
|
+
time.tzset()
|
|
298
|
+
timestamps = [int(datetime.datetime.fromisoformat(value).timestamp()) for value in (first, second)]
|
|
299
|
+
local_times = [datetime.datetime.fromtimestamp(ts, datetime.timezone.utc).astimezone() for ts in timestamps]
|
|
300
|
+
self.assertEqual(local_times[0].replace(tzinfo=None), local_times[1].replace(tzinfo=None))
|
|
301
|
+
self.assertNotEqual(local_times[0].utcoffset(), local_times[1].utcoffset())
|
|
302
|
+
for fold, timestamp in enumerate(timestamps):
|
|
303
|
+
for otp_type in (pyotp.TOTP, pyotp.contrib.Steam):
|
|
304
|
+
for interval in (30, 60) if otp_type is pyotp.TOTP else (30,):
|
|
305
|
+
totp = otp_type("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ", interval=interval)
|
|
306
|
+
for delta in (-1, 0, 1):
|
|
307
|
+
with self.subTest(
|
|
308
|
+
timezone=timezone, fold=fold, otp=otp_type, interval=interval, delta=delta
|
|
309
|
+
):
|
|
310
|
+
ts = timestamp + delta
|
|
311
|
+
counter = ts // interval
|
|
312
|
+
expected = totp.generate_otp(counter)
|
|
313
|
+
aware = datetime.datetime.fromtimestamp(ts, datetime.timezone.utc).astimezone()
|
|
314
|
+
naive = aware.replace(tzinfo=None, fold=fold)
|
|
315
|
+
for for_time in (ts, ts + 0.5, naive, aware):
|
|
316
|
+
self.assertEqual(totp.timecode(for_time), counter)
|
|
317
|
+
self.assertEqual(totp.at(for_time), expected)
|
|
318
|
+
self.assertTrue(totp.verify(expected, for_time))
|
|
319
|
+
self.assertEqual(totp.verify_and_get_timecode(expected, for_time), counter)
|
|
320
|
+
for offset in (-1, 1):
|
|
321
|
+
adjacent = totp.generate_otp(counter + offset)
|
|
322
|
+
self.assertEqual(totp.at(for_time, offset), adjacent)
|
|
323
|
+
self.assertTrue(totp.verify(adjacent, for_time, valid_window=1))
|
|
324
|
+
self.assertEqual(
|
|
325
|
+
totp.verify_and_get_timecode(adjacent, for_time, valid_window=1),
|
|
326
|
+
counter + offset,
|
|
327
|
+
)
|
|
328
|
+
other = totp.generate_otp((timestamps[1 - fold] + delta) // interval)
|
|
329
|
+
self.assertFalse(totp.verify(other, naive, valid_window=1))
|
|
330
|
+
self.assertIs(totp.verify_and_get_timecode(other, naive, valid_window=1), False)
|
|
331
|
+
with Timecop(ts):
|
|
332
|
+
self.assertEqual(datetime.datetime.now().astimezone().timestamp(), ts)
|
|
333
|
+
self.assertEqual(totp.now(), expected)
|
|
334
|
+
self.assertTrue(totp.verify(expected))
|
|
335
|
+
self.assertEqual(totp.verify_and_get_timecode(expected), counter)
|
|
336
|
+
|
|
337
|
+
def test_naive_fractional_seconds(self):
|
|
338
|
+
os.environ["TZ"] = "UTC0"
|
|
339
|
+
time.tzset()
|
|
340
|
+
totp = pyotp.TOTP("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ")
|
|
341
|
+
for for_time, counter in TOTPTimecode.FRACTIONAL_SECONDS:
|
|
342
|
+
with self.subTest(for_time=for_time):
|
|
343
|
+
self.assertEqual(totp.timecode(for_time.replace(tzinfo=None)), counter)
|
|
344
|
+
|
|
345
|
+
def test_naive_time_range(self):
|
|
346
|
+
os.environ["TZ"] = "UTC0"
|
|
347
|
+
time.tzset()
|
|
348
|
+
totp = pyotp.TOTP("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ")
|
|
349
|
+
epoch = datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc).replace(tzinfo=None)
|
|
350
|
+
self.assertEqual(totp.timecode(epoch), 0)
|
|
351
|
+
self.assertEqual(totp.timecode(datetime.datetime.max), 8446743359)
|
|
352
|
+
for for_time in (datetime.datetime.min, epoch - datetime.timedelta(microseconds=1)):
|
|
353
|
+
with self.subTest(for_time=for_time):
|
|
354
|
+
with self.assertRaisesRegex(ValueError, "for_time must be"):
|
|
355
|
+
totp.timecode(for_time)
|
|
356
|
+
with self.assertRaises(ValueError):
|
|
357
|
+
totp.at(for_time)
|
|
358
|
+
with self.assertRaises(ValueError):
|
|
359
|
+
totp.verify("invalid", for_time=for_time)
|
|
360
|
+
with self.assertRaises(ValueError):
|
|
361
|
+
totp.verify_and_get_timecode("invalid", for_time=for_time)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
class TOTPTimecode(unittest.TestCase):
|
|
365
|
+
FRACTIONAL_SECONDS = (
|
|
366
|
+
(datetime.datetime(1970, 1, 1, 0, 0, 29, 999999, tzinfo=datetime.timezone.utc), 0),
|
|
367
|
+
(datetime.datetime(1970, 1, 1, 0, 0, 30, tzinfo=datetime.timezone.utc), 1),
|
|
368
|
+
(datetime.datetime(2600, 1, 1, 0, 0, 29, 999999, tzinfo=datetime.timezone.utc), 662696640),
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
def test_aware_fractional_seconds(self):
|
|
372
|
+
totp = pyotp.TOTP("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ")
|
|
373
|
+
for for_time, counter in self.FRACTIONAL_SECONDS:
|
|
374
|
+
for timezone in (datetime.timezone.utc, datetime.timezone(datetime.timedelta(hours=5, minutes=30))):
|
|
375
|
+
aware = for_time.astimezone(timezone)
|
|
376
|
+
with self.subTest(for_time=aware):
|
|
377
|
+
self.assertEqual(totp.timecode(aware), counter)
|
|
378
|
+
|
|
379
|
+
def test_numeric_timestamps(self):
|
|
380
|
+
totp = pyotp.TOTP("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ")
|
|
381
|
+
cases = (
|
|
382
|
+
(0, 0),
|
|
383
|
+
(29.999999, 0),
|
|
384
|
+
(30.0, 1),
|
|
385
|
+
(59.999999, 1),
|
|
386
|
+
(60, 2),
|
|
387
|
+
(253402300799, 8446743359),
|
|
388
|
+
(253402300799.5, 8446743359),
|
|
389
|
+
)
|
|
390
|
+
for timestamp, counter in cases:
|
|
391
|
+
with self.subTest(timestamp=timestamp):
|
|
392
|
+
self.assertEqual(totp.timecode(timestamp), counter)
|
|
393
|
+
code = totp.generate_otp(counter)
|
|
394
|
+
self.assertEqual(totp.at(timestamp), code)
|
|
395
|
+
self.assertTrue(totp.verify(code, for_time=timestamp))
|
|
396
|
+
matched = totp.verify_and_get_timecode(code, for_time=timestamp)
|
|
397
|
+
self.assertIsNot(matched, False)
|
|
398
|
+
self.assertEqual(matched, counter)
|
|
399
|
+
|
|
400
|
+
def test_timestamps_outside_datetime_range(self):
|
|
401
|
+
for timestamp in (-30.5, -29.5, -1, -0.1, 253402300800, 253402300800.0, 10**12):
|
|
402
|
+
self.assert_time_out_of_range(timestamp)
|
|
403
|
+
|
|
404
|
+
def test_nonfinite_timestamps(self):
|
|
405
|
+
for timestamp in (float("nan"), float("inf"), float("-inf")):
|
|
406
|
+
self.assert_time_out_of_range(timestamp)
|
|
407
|
+
|
|
408
|
+
def test_current_time_outside_range(self):
|
|
409
|
+
for otp_type in (pyotp.TOTP, pyotp.contrib.Steam):
|
|
410
|
+
totp = otp_type("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ")
|
|
411
|
+
for timestamp in (-0.1, 253402300800):
|
|
412
|
+
with self.subTest(otp_type=otp_type, timestamp=timestamp):
|
|
413
|
+
with patch("time.time", return_value=timestamp):
|
|
414
|
+
with self.assertRaisesRegex(ValueError, "for_time must be"):
|
|
415
|
+
totp.now()
|
|
416
|
+
with self.assertRaises(ValueError):
|
|
417
|
+
totp.verify("invalid")
|
|
418
|
+
with self.assertRaises(ValueError):
|
|
419
|
+
totp.verify_and_get_timecode("invalid")
|
|
420
|
+
|
|
421
|
+
def test_datetimes_outside_range(self):
|
|
422
|
+
cases = (
|
|
423
|
+
datetime.datetime.min,
|
|
424
|
+
datetime.datetime.min.replace(tzinfo=datetime.timezone.utc),
|
|
425
|
+
datetime.datetime(1969, 12, 31, 23, 59, 59, 999999, tzinfo=datetime.timezone.utc),
|
|
426
|
+
datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone(datetime.timedelta(hours=1))),
|
|
427
|
+
datetime.datetime.max.replace(tzinfo=datetime.timezone(datetime.timedelta(hours=-1))),
|
|
428
|
+
)
|
|
429
|
+
for for_time in cases:
|
|
430
|
+
self.assert_time_out_of_range(for_time)
|
|
431
|
+
|
|
432
|
+
def test_datetime_range_boundaries(self):
|
|
433
|
+
totp = pyotp.TOTP("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ")
|
|
434
|
+
cases = (
|
|
435
|
+
(datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc), 0),
|
|
436
|
+
(datetime.datetime(1969, 12, 31, 23, tzinfo=datetime.timezone(datetime.timedelta(hours=-1))), 0),
|
|
437
|
+
(datetime.datetime.max.replace(tzinfo=datetime.timezone.utc), 8446743359),
|
|
438
|
+
)
|
|
439
|
+
for for_time, counter in cases:
|
|
440
|
+
with self.subTest(for_time=for_time):
|
|
441
|
+
code = totp.generate_otp(counter)
|
|
442
|
+
self.assertEqual(totp.timecode(for_time), counter)
|
|
443
|
+
self.assertEqual(totp.at(for_time), code)
|
|
444
|
+
self.assertTrue(totp.verify(code, for_time=for_time))
|
|
445
|
+
self.assertEqual(totp.verify_and_get_timecode(code, for_time=for_time), counter)
|
|
446
|
+
|
|
447
|
+
def assert_time_out_of_range(self, for_time):
|
|
448
|
+
for otp_type in (pyotp.TOTP, pyotp.contrib.Steam):
|
|
449
|
+
totp = otp_type("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ")
|
|
450
|
+
calls = (
|
|
451
|
+
(totp.timecode, (for_time,)),
|
|
452
|
+
(totp.at, (for_time,)),
|
|
453
|
+
(totp.verify, ("invalid", for_time)),
|
|
454
|
+
(totp.verify_and_get_timecode, ("invalid", for_time)),
|
|
455
|
+
)
|
|
456
|
+
for method, args in calls:
|
|
457
|
+
with self.subTest(otp_type=otp_type, method=method.__name__, for_time=for_time):
|
|
458
|
+
with self.assertRaisesRegex(ValueError, "for_time must be"):
|
|
459
|
+
method(*args)
|
|
460
|
+
|
|
461
|
+
def test_verification_normalizes_time_once(self):
|
|
462
|
+
totp = pyotp.TOTP("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ")
|
|
463
|
+
timestamp = 200.5
|
|
464
|
+
counter = 6
|
|
465
|
+
code = totp.generate_otp(counter + 2)
|
|
466
|
+
for verify in (totp.verify, totp.verify_and_get_timecode):
|
|
467
|
+
for candidate in (code, "invalid"):
|
|
468
|
+
with self.subTest(method=verify.__name__, candidate=candidate):
|
|
469
|
+
with patch.object(totp, "timecode", wraps=totp.timecode) as normalize:
|
|
470
|
+
result = verify(candidate, for_time=timestamp, valid_window=2)
|
|
471
|
+
normalize.assert_called_once_with(timestamp)
|
|
472
|
+
if candidate == code:
|
|
473
|
+
self.assertTrue(result)
|
|
474
|
+
else:
|
|
475
|
+
self.assertIs(result, False)
|
|
476
|
+
|
|
477
|
+
|
|
259
478
|
class SteamTOTP(unittest.TestCase):
|
|
260
479
|
def test_match_examples(self):
|
|
261
480
|
steam = pyotp.contrib.Steam("BASE32SECRET3232")
|
|
@@ -335,6 +554,15 @@ class ValidWindowTest(unittest.TestCase):
|
|
|
335
554
|
self.assertFalse(totp.verify("195979", 200, 1))
|
|
336
555
|
|
|
337
556
|
|
|
557
|
+
class GetTimeCodeTest(unittest.TestCase):
|
|
558
|
+
def test_timecode(self):
|
|
559
|
+
totp = pyotp.TOTP("ABCDEFGH")
|
|
560
|
+
self.assertEqual(totp.verify_and_get_timecode("451564", 200, 1), 5)
|
|
561
|
+
self.assertEqual(totp.verify_and_get_timecode("028307", 200, 1), 6)
|
|
562
|
+
self.assertEqual(totp.verify_and_get_timecode("681610", 200, 1), 7)
|
|
563
|
+
self.assertFalse(totp.verify_and_get_timecode("195979", 200, 1))
|
|
564
|
+
|
|
565
|
+
|
|
338
566
|
class DigestFunctionTest(unittest.TestCase):
|
|
339
567
|
def test_md5(self):
|
|
340
568
|
with self.assertRaises(ValueError) as cm:
|
|
@@ -485,7 +713,7 @@ class ParseUriTest(unittest.TestCase):
|
|
|
485
713
|
self.assertEqual(otp.issuer, "Big Corp")
|
|
486
714
|
|
|
487
715
|
|
|
488
|
-
class Timecop
|
|
716
|
+
class Timecop:
|
|
489
717
|
"""
|
|
490
718
|
Half-assed clone of timecop.rb, just enough to pass our tests.
|
|
491
719
|
"""
|
|
@@ -496,15 +724,31 @@ class Timecop(object):
|
|
|
496
724
|
def __enter__(self):
|
|
497
725
|
self.real_datetime = datetime.datetime
|
|
498
726
|
datetime.datetime = self.frozen_datetime()
|
|
727
|
+
self.time_patch = patch("time.time", return_value=self.freeze_timestamp)
|
|
728
|
+
self.time_patch.start()
|
|
499
729
|
|
|
500
730
|
def __exit__(self, type, value, traceback):
|
|
501
731
|
datetime.datetime = self.real_datetime
|
|
732
|
+
self.time_patch.stop()
|
|
502
733
|
|
|
503
734
|
def frozen_datetime(self):
|
|
504
735
|
class FrozenDateTime(datetime.datetime):
|
|
505
736
|
@classmethod
|
|
506
|
-
def now(cls,
|
|
507
|
-
|
|
737
|
+
def now(cls, tz=None):
|
|
738
|
+
instant = timecop.real_datetime.fromtimestamp(timecop.freeze_timestamp, tz)
|
|
739
|
+
# Construct explicitly: older Python versions can lose fold when
|
|
740
|
+
# fromtimestamp() is called on a datetime subclass.
|
|
741
|
+
return cls(
|
|
742
|
+
instant.year,
|
|
743
|
+
instant.month,
|
|
744
|
+
instant.day,
|
|
745
|
+
instant.hour,
|
|
746
|
+
instant.minute,
|
|
747
|
+
instant.second,
|
|
748
|
+
instant.microsecond,
|
|
749
|
+
tzinfo=instant.tzinfo,
|
|
750
|
+
fold=instant.fold,
|
|
751
|
+
)
|
|
508
752
|
|
|
509
753
|
timecop = self
|
|
510
754
|
return FrozenDateTime
|
pyotp-2.10.0/src/pyotp/compat.py
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
from .steam import Steam # noqa:F401
|
pyotp-2.10.0/src/pyotp/totp.py
DELETED
|
@@ -1,118 +0,0 @@
|
|
|
1
|
-
import calendar
|
|
2
|
-
import datetime
|
|
3
|
-
import hashlib
|
|
4
|
-
import time
|
|
5
|
-
from typing import Any, Optional, Union
|
|
6
|
-
|
|
7
|
-
from . import utils
|
|
8
|
-
from .otp import OTP
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
class TOTP(OTP):
|
|
12
|
-
"""
|
|
13
|
-
Handler for time-based OTP counters.
|
|
14
|
-
"""
|
|
15
|
-
|
|
16
|
-
def __init__(
|
|
17
|
-
self,
|
|
18
|
-
s: str,
|
|
19
|
-
digits: int = 6,
|
|
20
|
-
digest: Any = None,
|
|
21
|
-
name: Optional[str] = None,
|
|
22
|
-
issuer: Optional[str] = None,
|
|
23
|
-
interval: int = 30,
|
|
24
|
-
) -> None:
|
|
25
|
-
"""
|
|
26
|
-
:param s: secret in base32 format
|
|
27
|
-
:param digits: number of integers in the OTP. Some apps expect this to be 6 digits, others support more.
|
|
28
|
-
:param digest: digest function to use in the HMAC (expected to be SHA1)
|
|
29
|
-
:param name: account name
|
|
30
|
-
:param issuer: issuer
|
|
31
|
-
:param interval: the time interval in seconds for OTP. This defaults to 30.
|
|
32
|
-
"""
|
|
33
|
-
if digest is None:
|
|
34
|
-
digest = hashlib.sha1
|
|
35
|
-
elif digest in [hashlib.md5, hashlib.shake_128]:
|
|
36
|
-
raise ValueError("selected digest function must generate digest size greater than or equals to 18 bytes")
|
|
37
|
-
|
|
38
|
-
self.interval = interval
|
|
39
|
-
super().__init__(s=s, digits=digits, digest=digest, name=name, issuer=issuer)
|
|
40
|
-
|
|
41
|
-
def at(self, for_time: Union[int, datetime.datetime], counter_offset: int = 0) -> str:
|
|
42
|
-
"""
|
|
43
|
-
Accepts either a Unix timestamp integer or a datetime object.
|
|
44
|
-
|
|
45
|
-
To get the time until the next timecode change (seconds until the current OTP expires), use this instead:
|
|
46
|
-
|
|
47
|
-
.. code:: python
|
|
48
|
-
|
|
49
|
-
totp = pyotp.TOTP(...)
|
|
50
|
-
time_remaining = totp.interval - datetime.datetime.now().timestamp() % totp.interval
|
|
51
|
-
|
|
52
|
-
:param for_time: the time to generate an OTP for
|
|
53
|
-
:param counter_offset: the amount of ticks to add to the time counter
|
|
54
|
-
:returns: OTP value
|
|
55
|
-
"""
|
|
56
|
-
if not isinstance(for_time, datetime.datetime):
|
|
57
|
-
for_time = datetime.datetime.fromtimestamp(int(for_time))
|
|
58
|
-
return self.generate_otp(self.timecode(for_time) + counter_offset)
|
|
59
|
-
|
|
60
|
-
def now(self) -> str:
|
|
61
|
-
"""
|
|
62
|
-
Generate the current time OTP
|
|
63
|
-
|
|
64
|
-
:returns: OTP value
|
|
65
|
-
"""
|
|
66
|
-
return self.generate_otp(self.timecode(datetime.datetime.now()))
|
|
67
|
-
|
|
68
|
-
def verify(self, otp: str, for_time: Optional[datetime.datetime] = None, valid_window: int = 0) -> bool:
|
|
69
|
-
"""
|
|
70
|
-
Verifies the OTP passed in against the current time OTP.
|
|
71
|
-
|
|
72
|
-
:param otp: the OTP to check against
|
|
73
|
-
:param for_time: Time to check OTP at (defaults to now)
|
|
74
|
-
:param valid_window: extends the validity to this many counter ticks before and after the current one
|
|
75
|
-
:returns: True if verification succeeded, False otherwise
|
|
76
|
-
"""
|
|
77
|
-
if for_time is None:
|
|
78
|
-
for_time = datetime.datetime.now()
|
|
79
|
-
|
|
80
|
-
if valid_window:
|
|
81
|
-
for i in range(-valid_window, valid_window + 1):
|
|
82
|
-
if utils.strings_equal(str(otp), str(self.at(for_time, i))):
|
|
83
|
-
return True
|
|
84
|
-
return False
|
|
85
|
-
|
|
86
|
-
return utils.strings_equal(str(otp), str(self.at(for_time)))
|
|
87
|
-
|
|
88
|
-
def provisioning_uri(self, name: Optional[str] = None, issuer_name: Optional[str] = None, **kwargs) -> str:
|
|
89
|
-
"""
|
|
90
|
-
Returns the provisioning URI for the OTP. This can then be
|
|
91
|
-
encoded in a QR Code and used to provision an OTP app like
|
|
92
|
-
Google Authenticator.
|
|
93
|
-
|
|
94
|
-
See also:
|
|
95
|
-
https://github.com/google/google-authenticator/wiki/Key-Uri-Format
|
|
96
|
-
|
|
97
|
-
"""
|
|
98
|
-
return utils.build_uri(
|
|
99
|
-
self.secret,
|
|
100
|
-
name if name else self.name,
|
|
101
|
-
issuer=issuer_name if issuer_name else self.issuer,
|
|
102
|
-
algorithm=self.digest().name,
|
|
103
|
-
digits=self.digits,
|
|
104
|
-
period=self.interval,
|
|
105
|
-
**kwargs,
|
|
106
|
-
)
|
|
107
|
-
|
|
108
|
-
def timecode(self, for_time: datetime.datetime) -> int:
|
|
109
|
-
"""
|
|
110
|
-
Accepts either a timezone naive (`for_time.tzinfo is None`) or
|
|
111
|
-
a timezone aware datetime as argument and returns the
|
|
112
|
-
corresponding counter value (timecode).
|
|
113
|
-
|
|
114
|
-
"""
|
|
115
|
-
if for_time.tzinfo:
|
|
116
|
-
return int(calendar.timegm(for_time.utctimetuple()) / self.interval)
|
|
117
|
-
else:
|
|
118
|
-
return int(time.mktime(for_time.timetuple()) / self.interval)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|