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.
Files changed (31) hide show
  1. pyotp-2.11.1/.github/workflows/actions.lock +64 -0
  2. {pyotp-2.10.0 → pyotp-2.11.1}/.github/workflows/ci.yml +7 -7
  3. {pyotp-2.10.0 → pyotp-2.11.1}/Changes.rst +16 -0
  4. {pyotp-2.10.0 → pyotp-2.11.1}/PKG-INFO +19 -9
  5. {pyotp-2.10.0 → pyotp-2.11.1}/README.rst +17 -7
  6. {pyotp-2.10.0 → pyotp-2.11.1}/common.mk +11 -1
  7. {pyotp-2.10.0 → pyotp-2.11.1}/pyproject.toml +11 -0
  8. {pyotp-2.10.0 → pyotp-2.11.1}/src/pyotp/__init__.py +10 -9
  9. pyotp-2.11.1/src/pyotp/compat.py +3 -0
  10. pyotp-2.11.1/src/pyotp/contrib/__init__.py +1 -0
  11. {pyotp-2.10.0 → pyotp-2.11.1}/src/pyotp/contrib/steam.py +3 -2
  12. {pyotp-2.10.0 → pyotp-2.11.1}/src/pyotp/hotp.py +9 -7
  13. {pyotp-2.10.0 → pyotp-2.11.1}/src/pyotp/otp.py +7 -5
  14. pyotp-2.11.1/src/pyotp/totp.py +183 -0
  15. {pyotp-2.10.0 → pyotp-2.11.1}/src/pyotp/utils.py +10 -9
  16. {pyotp-2.10.0 → pyotp-2.11.1}/test.py +254 -10
  17. pyotp-2.10.0/src/pyotp/compat.py +0 -9
  18. pyotp-2.10.0/src/pyotp/contrib/__init__.py +0 -1
  19. pyotp-2.10.0/src/pyotp/totp.py +0 -118
  20. {pyotp-2.10.0 → pyotp-2.11.1}/.github/FUNDING.yml +0 -0
  21. {pyotp-2.10.0 → pyotp-2.11.1}/.github/workflows/release.yml +0 -0
  22. {pyotp-2.10.0 → pyotp-2.11.1}/.gitignore +0 -0
  23. {pyotp-2.10.0 → pyotp-2.11.1}/LICENSE +0 -0
  24. {pyotp-2.10.0 → pyotp-2.11.1}/MANIFEST.in +0 -0
  25. {pyotp-2.10.0 → pyotp-2.11.1}/Makefile +0 -0
  26. {pyotp-2.10.0 → pyotp-2.11.1}/SECURITY.md +0 -0
  27. {pyotp-2.10.0 → pyotp-2.11.1}/docs/changelog.rst +0 -0
  28. {pyotp-2.10.0 → pyotp-2.11.1}/docs/conf.py +0 -0
  29. {pyotp-2.10.0 → pyotp-2.11.1}/docs/index.rst +0 -0
  30. {pyotp-2.10.0 → pyotp-2.11.1}/docs/toc.html +0 -0
  31. {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@v4
15
+ - uses: actions/checkout@v7
16
16
  - name: Set up Python ${{ matrix.python-version }}
17
- uses: actions/setup-python@v5
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@v5
23
+ - uses: codecov/codecov-action@v7
24
24
  ruff:
25
25
  runs-on: ubuntu-latest
26
26
  steps:
27
- - uses: actions/checkout@v4
28
- - uses: astral-sh/ruff-action@v3
29
- - uses: astral-sh/ruff-action@v3
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.4
1
+ Metadata-Version: 2.5
2
2
  Name: PyOTP
3
- Version: 2.10.0
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, Dict, Sequence
5
+ from typing import Any, Sequence
4
6
  from urllib.parse import parse_qsl, unquote, urlparse
5
7
 
6
- from . import contrib # noqa:F401
8
+ from . import contrib
7
9
  from .compat import random
8
- from .hotp import HOTP as HOTP
9
- from .otp import OTP as OTP
10
- from .totp import TOTP as 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: Dict[str, Any] = {}
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
- if digits is not None and digits not in [6, 7, 8]:
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,3 @@
1
+ from secrets import SystemRandom
2
+
3
+ random = SystemRandom()
@@ -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: Optional[str] = None, issuer: Optional[str] = None, interval: int = 30, digits: int = 5
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, Optional
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: Optional[str] = None,
19
- issuer: Optional[str] = None,
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: Optional[str] = None,
59
- initial_count: Optional[int] = None,
60
- issuer_name: Optional[str] = None,
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, Optional
6
+ from typing import Any
5
7
 
6
8
 
7
- class OTP(object):
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: Optional[str] = None,
18
- issuer: Optional[str] = None,
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(bytearray(reversed(result)).rjust(padding, b"\0"))
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: Optional[int] = None,
11
- issuer: Optional[str] = None,
12
- algorithm: Optional[str] = None,
13
- digits: Optional[int] = None,
14
- period: Optional[int] = None,
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: Dict[str, Union[None, int, str]] = {"secret": secret}
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 ValueError("All otpauth uri parameters must be strings")
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".format(image_uri))
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 # noqa
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
- # -1 and -29.5 round down to 0 (epoch)
179
- self.assertEqual(totp.at(-1), "755224")
180
- self.assertEqual(totp.at(-29.5), "755224")
181
- with self.assertRaises(ValueError):
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(object):
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, **kwargs):
507
- return cls.fromtimestamp(timecop.freeze_timestamp)
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
@@ -1,9 +0,0 @@
1
- import sys
2
-
3
- # Use secrets module if available (Python version >= 3.6) per PEP 506
4
- if sys.version_info >= (3, 6):
5
- from secrets import SystemRandom
6
- else:
7
- from random import SystemRandom
8
-
9
- random = SystemRandom()
@@ -1 +0,0 @@
1
- from .steam import Steam # noqa:F401
@@ -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