PyOTP 2.3.0__tar.gz → 2.4.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.3.0 → pyotp-2.4.1}/MANIFEST.in +1 -1
- {pyotp-2.3.0/src/pyotp.egg-info → pyotp-2.4.1}/PKG-INFO +27 -14
- {pyotp-2.3.0 → pyotp-2.4.1}/README.rst +26 -12
- {pyotp-2.3.0 → pyotp-2.4.1}/setup.cfg +1 -1
- {pyotp-2.3.0 → pyotp-2.4.1}/setup.py +2 -2
- pyotp-2.4.1/src/pyotp/__init__.py +102 -0
- pyotp-2.4.1/src/pyotp/compat.py +7 -0
- {pyotp-2.3.0 → pyotp-2.4.1}/src/pyotp/hotp.py +21 -17
- {pyotp-2.3.0 → pyotp-2.4.1}/src/pyotp/otp.py +17 -10
- pyotp-2.4.1/src/pyotp/py.typed +0 -0
- {pyotp-2.3.0 → pyotp-2.4.1}/src/pyotp/totp.py +27 -26
- {pyotp-2.3.0 → pyotp-2.4.1}/src/pyotp/utils.py +20 -48
- {pyotp-2.3.0 → pyotp-2.4.1/src/pyotp.egg-info}/PKG-INFO +27 -14
- {pyotp-2.3.0 → pyotp-2.4.1}/src/pyotp.egg-info/SOURCES.txt +1 -0
- {pyotp-2.3.0 → pyotp-2.4.1}/test.py +175 -42
- pyotp-2.3.0/src/pyotp/__init__.py +0 -23
- pyotp-2.3.0/src/pyotp/compat.py +0 -10
- {pyotp-2.3.0 → pyotp-2.4.1}/LICENSE +0 -0
- {pyotp-2.3.0 → pyotp-2.4.1}/requirements.txt +0 -0
- {pyotp-2.3.0 → pyotp-2.4.1}/src/pyotp.egg-info/dependency_links.txt +0 -0
- {pyotp-2.3.0 → pyotp-2.4.1}/src/pyotp.egg-info/not-zip-safe +0 -0
- {pyotp-2.3.0 → pyotp-2.4.1}/src/pyotp.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 1.1
|
|
2
2
|
Name: pyotp
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.4.1
|
|
4
4
|
Summary: Python One Time Password Library
|
|
5
5
|
Home-page: https://github.com/pyotp/pyotp
|
|
6
6
|
Author: PyOTP contributors
|
|
@@ -84,11 +84,15 @@ Description: PyOTP - The Python One-Time Password Library
|
|
|
84
84
|
hotp.verify('316439', 1401) # => True
|
|
85
85
|
hotp.verify('316439', 1402) # => False
|
|
86
86
|
|
|
87
|
-
Generating a
|
|
88
|
-
|
|
89
|
-
::
|
|
87
|
+
Generating a Secret Key
|
|
88
|
+
~~~~~~~~~~~~~~~~~~~~~~~
|
|
89
|
+
A helper function is provided to generate a 16 character base32 secret, compatible with Google Authenticator and other OTP apps::
|
|
90
|
+
|
|
91
|
+
pyotp.random_base32()
|
|
90
92
|
|
|
91
|
-
|
|
93
|
+
Some applications want the secret key to be formatted as a hex-encoded string::
|
|
94
|
+
|
|
95
|
+
pyotp.random_hex() # returns a 32-character hex-encoded secret
|
|
92
96
|
|
|
93
97
|
Google Authenticator Compatible
|
|
94
98
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
@@ -96,17 +100,27 @@ Description: PyOTP - The Python One-Time Password Library
|
|
|
96
100
|
PyOTP works with the Google Authenticator iPhone and Android app, as well as other OTP apps like Authy. PyOTP includes the
|
|
97
101
|
ability to generate provisioning URIs for use with the QR Code scanner built into these MFA client apps::
|
|
98
102
|
|
|
99
|
-
pyotp.totp.TOTP('JBSWY3DPEHPK3PXP').provisioning_uri(
|
|
103
|
+
pyotp.totp.TOTP('JBSWY3DPEHPK3PXP').provisioning_uri(name='alice@google.com', issuer_name='Secure App')
|
|
100
104
|
|
|
101
105
|
>>> 'otpauth://totp/Secure%20App:alice%40google.com?secret=JBSWY3DPEHPK3PXP&issuer=Secure%20App'
|
|
102
106
|
|
|
103
|
-
pyotp.hotp.HOTP('JBSWY3DPEHPK3PXP').provisioning_uri("alice@google.com",
|
|
107
|
+
pyotp.hotp.HOTP('JBSWY3DPEHPK3PXP').provisioning_uri(name="alice@google.com", issuer_name="Secure App", initial_count=0)
|
|
104
108
|
|
|
105
109
|
>>> 'otpauth://hotp/Secure%20App:alice%40google.com?secret=JBSWY3DPEHPK3PXP&issuer=Secure%20App&counter=0'
|
|
106
110
|
|
|
107
111
|
This URL can then be rendered as a QR Code (for example, using https://github.com/neocotic/qrious) which can then be scanned
|
|
108
112
|
and added to the users list of OTP credentials.
|
|
109
113
|
|
|
114
|
+
Parsing these URLs is also supported::
|
|
115
|
+
|
|
116
|
+
pyotp.parse_uri('otpauth://totp/Secure%20App:alice%40google.com?secret=JBSWY3DPEHPK3PXP&issuer=Secure%20App')
|
|
117
|
+
|
|
118
|
+
>>> <pyotp.totp.TOTP object at 0xFFFFFFFF>
|
|
119
|
+
|
|
120
|
+
pyotp.parse_uri('otpauth://hotp/Secure%20App:alice%40google.com?secret=JBSWY3DPEHPK3PXP&issuer=Secure%20App&counter=0'
|
|
121
|
+
|
|
122
|
+
>>> <pyotp.totp.HOTP object at 0xFFFFFFFF>
|
|
123
|
+
|
|
110
124
|
Working example
|
|
111
125
|
~~~~~~~~~~~~~~~
|
|
112
126
|
|
|
@@ -123,10 +137,10 @@ Description: PyOTP - The Python One-Time Password Library
|
|
|
123
137
|
Links
|
|
124
138
|
~~~~~
|
|
125
139
|
|
|
126
|
-
* `Project home page (GitHub) <https://github.com/
|
|
140
|
+
* `Project home page (GitHub) <https://github.com/pyauth/pyotp>`_
|
|
127
141
|
* `Documentation (Read the Docs) <https://pyotp.readthedocs.io/en/latest/>`_
|
|
128
142
|
* `Package distribution (PyPI) <https://pypi.python.org/pypi/pyotp>`_
|
|
129
|
-
* `Change log <https://github.com/
|
|
143
|
+
* `Change log <https://github.com/pyauth/pyotp/blob/master/Changes.rst>`_
|
|
130
144
|
* `RFC 4226: HOTP: An HMAC-Based One-Time Password <https://tools.ietf.org/html/rfc4226>`_
|
|
131
145
|
* `RFC 6238: TOTP: Time-Based One-Time Password Algorithm <https://tools.ietf.org/html/rfc6238>`_
|
|
132
146
|
* `ROTP <https://github.com/mdp/rotp>`_ - Original Ruby OTP library by `Mark Percival <https://github.com/mdp>`_
|
|
@@ -139,10 +153,10 @@ Description: PyOTP - The Python One-Time Password Library
|
|
|
139
153
|
* `WebAuthn <https://www.w3.org/TR/webauthn/>`_
|
|
140
154
|
* `PyWARP <https://github.com/pyauth/pywarp>`_
|
|
141
155
|
|
|
142
|
-
.. image:: https://
|
|
143
|
-
:target: https://
|
|
144
|
-
.. image:: https://img.shields.io/codecov/c/github/
|
|
145
|
-
:target: https://codecov.io/github/
|
|
156
|
+
.. image:: https://github.com/pyauth/pyotp/workflows/Python%20package/badge.svg
|
|
157
|
+
:target: https://github.com/pyauth/pyotp/actions
|
|
158
|
+
.. image:: https://img.shields.io/codecov/c/github/pyauth/pyotp/master.svg
|
|
159
|
+
:target: https://codecov.io/github/pyauth/pyotp?branch=master
|
|
146
160
|
.. image:: https://img.shields.io/pypi/v/pyotp.svg
|
|
147
161
|
:target: https://pypi.python.org/pypi/pyotp
|
|
148
162
|
.. image:: https://img.shields.io/pypi/l/pyotp.svg
|
|
@@ -157,7 +171,6 @@ Classifier: License :: OSI Approved :: MIT License
|
|
|
157
171
|
Classifier: Operating System :: MacOS :: MacOS X
|
|
158
172
|
Classifier: Operating System :: POSIX
|
|
159
173
|
Classifier: Programming Language :: Python
|
|
160
|
-
Classifier: Programming Language :: Python :: 2.7
|
|
161
174
|
Classifier: Programming Language :: Python :: 3.4
|
|
162
175
|
Classifier: Programming Language :: Python :: 3.5
|
|
163
176
|
Classifier: Programming Language :: Python :: 3.6
|
|
@@ -76,11 +76,15 @@ Counter-based OTPs
|
|
|
76
76
|
hotp.verify('316439', 1401) # => True
|
|
77
77
|
hotp.verify('316439', 1402) # => False
|
|
78
78
|
|
|
79
|
-
Generating a
|
|
80
|
-
|
|
81
|
-
::
|
|
79
|
+
Generating a Secret Key
|
|
80
|
+
~~~~~~~~~~~~~~~~~~~~~~~
|
|
81
|
+
A helper function is provided to generate a 16 character base32 secret, compatible with Google Authenticator and other OTP apps::
|
|
82
|
+
|
|
83
|
+
pyotp.random_base32()
|
|
82
84
|
|
|
83
|
-
|
|
85
|
+
Some applications want the secret key to be formatted as a hex-encoded string::
|
|
86
|
+
|
|
87
|
+
pyotp.random_hex() # returns a 32-character hex-encoded secret
|
|
84
88
|
|
|
85
89
|
Google Authenticator Compatible
|
|
86
90
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
@@ -88,17 +92,27 @@ Google Authenticator Compatible
|
|
|
88
92
|
PyOTP works with the Google Authenticator iPhone and Android app, as well as other OTP apps like Authy. PyOTP includes the
|
|
89
93
|
ability to generate provisioning URIs for use with the QR Code scanner built into these MFA client apps::
|
|
90
94
|
|
|
91
|
-
pyotp.totp.TOTP('JBSWY3DPEHPK3PXP').provisioning_uri(
|
|
95
|
+
pyotp.totp.TOTP('JBSWY3DPEHPK3PXP').provisioning_uri(name='alice@google.com', issuer_name='Secure App')
|
|
92
96
|
|
|
93
97
|
>>> 'otpauth://totp/Secure%20App:alice%40google.com?secret=JBSWY3DPEHPK3PXP&issuer=Secure%20App'
|
|
94
98
|
|
|
95
|
-
pyotp.hotp.HOTP('JBSWY3DPEHPK3PXP').provisioning_uri("alice@google.com",
|
|
99
|
+
pyotp.hotp.HOTP('JBSWY3DPEHPK3PXP').provisioning_uri(name="alice@google.com", issuer_name="Secure App", initial_count=0)
|
|
96
100
|
|
|
97
101
|
>>> 'otpauth://hotp/Secure%20App:alice%40google.com?secret=JBSWY3DPEHPK3PXP&issuer=Secure%20App&counter=0'
|
|
98
102
|
|
|
99
103
|
This URL can then be rendered as a QR Code (for example, using https://github.com/neocotic/qrious) which can then be scanned
|
|
100
104
|
and added to the users list of OTP credentials.
|
|
101
105
|
|
|
106
|
+
Parsing these URLs is also supported::
|
|
107
|
+
|
|
108
|
+
pyotp.parse_uri('otpauth://totp/Secure%20App:alice%40google.com?secret=JBSWY3DPEHPK3PXP&issuer=Secure%20App')
|
|
109
|
+
|
|
110
|
+
>>> <pyotp.totp.TOTP object at 0xFFFFFFFF>
|
|
111
|
+
|
|
112
|
+
pyotp.parse_uri('otpauth://hotp/Secure%20App:alice%40google.com?secret=JBSWY3DPEHPK3PXP&issuer=Secure%20App&counter=0'
|
|
113
|
+
|
|
114
|
+
>>> <pyotp.totp.HOTP object at 0xFFFFFFFF>
|
|
115
|
+
|
|
102
116
|
Working example
|
|
103
117
|
~~~~~~~~~~~~~~~
|
|
104
118
|
|
|
@@ -115,10 +129,10 @@ Now run the following and compare the output::
|
|
|
115
129
|
Links
|
|
116
130
|
~~~~~
|
|
117
131
|
|
|
118
|
-
* `Project home page (GitHub) <https://github.com/
|
|
132
|
+
* `Project home page (GitHub) <https://github.com/pyauth/pyotp>`_
|
|
119
133
|
* `Documentation (Read the Docs) <https://pyotp.readthedocs.io/en/latest/>`_
|
|
120
134
|
* `Package distribution (PyPI) <https://pypi.python.org/pypi/pyotp>`_
|
|
121
|
-
* `Change log <https://github.com/
|
|
135
|
+
* `Change log <https://github.com/pyauth/pyotp/blob/master/Changes.rst>`_
|
|
122
136
|
* `RFC 4226: HOTP: An HMAC-Based One-Time Password <https://tools.ietf.org/html/rfc4226>`_
|
|
123
137
|
* `RFC 6238: TOTP: Time-Based One-Time Password Algorithm <https://tools.ietf.org/html/rfc6238>`_
|
|
124
138
|
* `ROTP <https://github.com/mdp/rotp>`_ - Original Ruby OTP library by `Mark Percival <https://github.com/mdp>`_
|
|
@@ -131,10 +145,10 @@ For new applications:
|
|
|
131
145
|
* `WebAuthn <https://www.w3.org/TR/webauthn/>`_
|
|
132
146
|
* `PyWARP <https://github.com/pyauth/pywarp>`_
|
|
133
147
|
|
|
134
|
-
.. image:: https://
|
|
135
|
-
:target: https://
|
|
136
|
-
.. image:: https://img.shields.io/codecov/c/github/
|
|
137
|
-
:target: https://codecov.io/github/
|
|
148
|
+
.. image:: https://github.com/pyauth/pyotp/workflows/Python%20package/badge.svg
|
|
149
|
+
:target: https://github.com/pyauth/pyotp/actions
|
|
150
|
+
.. image:: https://img.shields.io/codecov/c/github/pyauth/pyotp/master.svg
|
|
151
|
+
:target: https://codecov.io/github/pyauth/pyotp?branch=master
|
|
138
152
|
.. image:: https://img.shields.io/pypi/v/pyotp.svg
|
|
139
153
|
:target: https://pypi.python.org/pypi/pyotp
|
|
140
154
|
.. image:: https://img.shields.io/pypi/l/pyotp.svg
|
|
@@ -7,7 +7,7 @@ install_requires = [line.rstrip() for line in open(os.path.join(os.path.dirname(
|
|
|
7
7
|
|
|
8
8
|
setup(
|
|
9
9
|
name="pyotp",
|
|
10
|
-
version="2.
|
|
10
|
+
version="2.4.1",
|
|
11
11
|
url="https://github.com/pyotp/pyotp",
|
|
12
12
|
license="MIT License",
|
|
13
13
|
author="PyOTP contributors",
|
|
@@ -17,6 +17,7 @@ setup(
|
|
|
17
17
|
install_requires=install_requires,
|
|
18
18
|
packages=["pyotp"],
|
|
19
19
|
package_dir={"": "src"},
|
|
20
|
+
package_data={"pyotp": ["py.typed"]},
|
|
20
21
|
platforms=["MacOS X", "Posix"],
|
|
21
22
|
zip_safe=False,
|
|
22
23
|
test_suite="test",
|
|
@@ -26,7 +27,6 @@ setup(
|
|
|
26
27
|
"Operating System :: MacOS :: MacOS X",
|
|
27
28
|
"Operating System :: POSIX",
|
|
28
29
|
"Programming Language :: Python",
|
|
29
|
-
"Programming Language :: Python :: 2.7",
|
|
30
30
|
"Programming Language :: Python :: 3.4",
|
|
31
31
|
"Programming Language :: Python :: 3.5",
|
|
32
32
|
"Programming Language :: Python :: 3.6",
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
from typing import Any, Dict, Sequence
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
from re import split
|
|
5
|
+
from urllib.parse import unquote, urlparse, parse_qsl
|
|
6
|
+
|
|
7
|
+
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
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def random_base32(
|
|
14
|
+
length: int = 16,
|
|
15
|
+
chars: Sequence[str] = list('ABCDEFGHIJKLMNOPQRSTUVWXYZ234567')) -> str:
|
|
16
|
+
if length < 16:
|
|
17
|
+
raise Exception("Secrets should be at least 128 bits")
|
|
18
|
+
|
|
19
|
+
return ''.join(
|
|
20
|
+
random.choice(chars)
|
|
21
|
+
for _ in range(length)
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def random_hex(
|
|
26
|
+
length: int = 32,
|
|
27
|
+
chars: Sequence[str] = list('ABCDEF0123456789')) -> str:
|
|
28
|
+
if length < 32:
|
|
29
|
+
raise Exception("Secrets should be at least 128 bits")
|
|
30
|
+
return random_base32(length=length, chars=chars)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def parse_uri(uri: str) -> OTP:
|
|
34
|
+
"""
|
|
35
|
+
Parses the provisioning URI for the OTP; works for either TOTP or HOTP.
|
|
36
|
+
|
|
37
|
+
See also:
|
|
38
|
+
https://github.com/google/google-authenticator/wiki/Key-Uri-Format
|
|
39
|
+
|
|
40
|
+
:param uri: the hotp/totp URI to parse
|
|
41
|
+
:returns: OTP object
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
# Secret (to be filled in later)
|
|
45
|
+
secret = None
|
|
46
|
+
|
|
47
|
+
# Data we'll parse to the correct constructor
|
|
48
|
+
otp_data = {} # type: Dict[str, Any]
|
|
49
|
+
|
|
50
|
+
# Parse with URLlib
|
|
51
|
+
parsed_uri = urlparse(unquote(uri))
|
|
52
|
+
|
|
53
|
+
if parsed_uri.scheme != 'otpauth':
|
|
54
|
+
raise ValueError('Not an otpauth URI')
|
|
55
|
+
|
|
56
|
+
# Parse issuer/accountname info
|
|
57
|
+
accountinfo_parts = split(':|%3A', parsed_uri.path[1:], maxsplit=1)
|
|
58
|
+
if len(accountinfo_parts) == 1:
|
|
59
|
+
otp_data['name'] = accountinfo_parts[0]
|
|
60
|
+
else:
|
|
61
|
+
otp_data['issuer'] = accountinfo_parts[0]
|
|
62
|
+
otp_data['name'] = accountinfo_parts[1]
|
|
63
|
+
|
|
64
|
+
# Parse values
|
|
65
|
+
for key, value in parse_qsl(parsed_uri.query):
|
|
66
|
+
if key == 'secret':
|
|
67
|
+
secret = value
|
|
68
|
+
elif key == 'issuer':
|
|
69
|
+
if 'issuer' in otp_data and otp_data['issuer'] is not None and otp_data['issuer'] != value:
|
|
70
|
+
raise ValueError('If issuer is specified in both label and parameters, it should be equal.')
|
|
71
|
+
otp_data['issuer'] = value
|
|
72
|
+
elif key == 'algorithm':
|
|
73
|
+
if value == 'SHA1':
|
|
74
|
+
otp_data['digest'] = hashlib.sha1
|
|
75
|
+
elif value == 'SHA256':
|
|
76
|
+
otp_data['digest'] = hashlib.sha256
|
|
77
|
+
elif value == 'SHA512':
|
|
78
|
+
otp_data['digest'] = hashlib.sha512
|
|
79
|
+
else:
|
|
80
|
+
raise ValueError('Invalid value for algorithm, must be SHA1, SHA256 or SHA512')
|
|
81
|
+
elif key == 'digits':
|
|
82
|
+
digits = int(value)
|
|
83
|
+
if digits not in [6, 8]:
|
|
84
|
+
raise ValueError('Digits may only be 6 or 8')
|
|
85
|
+
otp_data['digits'] = digits
|
|
86
|
+
elif key == 'period':
|
|
87
|
+
otp_data['interval'] = int(value)
|
|
88
|
+
elif key == 'counter':
|
|
89
|
+
otp_data['initial_count'] = int(value)
|
|
90
|
+
else:
|
|
91
|
+
raise ValueError('{} is not a valid parameter'.format(key))
|
|
92
|
+
|
|
93
|
+
if not secret:
|
|
94
|
+
raise ValueError('No secret found in URI')
|
|
95
|
+
|
|
96
|
+
# Create objects
|
|
97
|
+
if parsed_uri.netloc == 'totp':
|
|
98
|
+
return TOTP(secret, **otp_data)
|
|
99
|
+
elif parsed_uri.netloc == 'hotp':
|
|
100
|
+
return HOTP(secret, **otp_data)
|
|
101
|
+
|
|
102
|
+
raise ValueError('Not a supported OTP type')
|
|
@@ -1,36 +1,43 @@
|
|
|
1
|
-
from
|
|
1
|
+
from typing import Any, Optional
|
|
2
2
|
|
|
3
3
|
from . import utils
|
|
4
4
|
from .otp import OTP
|
|
5
|
-
|
|
5
|
+
|
|
6
6
|
|
|
7
7
|
class HOTP(OTP):
|
|
8
8
|
"""
|
|
9
9
|
Handler for HMAC-based OTP counters.
|
|
10
10
|
"""
|
|
11
|
-
def
|
|
11
|
+
def __init__(self, *args: Any, initial_count: int = 0, **kwargs: Any) -> None:
|
|
12
|
+
"""
|
|
13
|
+
:param initial_count: starting HMAC counter value, defaults to 0
|
|
14
|
+
"""
|
|
15
|
+
self.initial_count = initial_count
|
|
16
|
+
super(HOTP, self).__init__(*args, **kwargs)
|
|
17
|
+
|
|
18
|
+
def at(self, count: int) -> str:
|
|
12
19
|
"""
|
|
13
20
|
Generates the OTP for the given count.
|
|
14
21
|
|
|
15
22
|
:param count: the OTP HMAC counter
|
|
16
|
-
:type count: int
|
|
17
23
|
:returns: OTP
|
|
18
|
-
:rtype: str
|
|
19
24
|
"""
|
|
20
|
-
return self.generate_otp(count)
|
|
25
|
+
return self.generate_otp(self.initial_count + count)
|
|
21
26
|
|
|
22
|
-
def verify(self, otp, counter):
|
|
27
|
+
def verify(self, otp: str, counter: int) -> bool:
|
|
23
28
|
"""
|
|
24
29
|
Verifies the OTP passed in against the current counter OTP.
|
|
25
30
|
|
|
26
31
|
:param otp: the OTP to check against
|
|
27
|
-
:
|
|
28
|
-
:param count: the OTP HMAC counter
|
|
29
|
-
:type count: int
|
|
32
|
+
:param counter: the OTP HMAC counter
|
|
30
33
|
"""
|
|
31
34
|
return utils.strings_equal(str(otp), str(self.at(counter)))
|
|
32
35
|
|
|
33
|
-
def provisioning_uri(
|
|
36
|
+
def provisioning_uri(
|
|
37
|
+
self,
|
|
38
|
+
name: Optional[str] = None,
|
|
39
|
+
initial_count: Optional[int] = None,
|
|
40
|
+
issuer_name: Optional[str] = None) -> str:
|
|
34
41
|
"""
|
|
35
42
|
Returns the provisioning URI for the OTP. This can then be
|
|
36
43
|
encoded in a QR Code and used to provision an OTP app like
|
|
@@ -40,19 +47,16 @@ class HOTP(OTP):
|
|
|
40
47
|
https://github.com/google/google-authenticator/wiki/Key-Uri-Format
|
|
41
48
|
|
|
42
49
|
:param name: name of the user account
|
|
43
|
-
:type name: str
|
|
44
50
|
:param initial_count: starting HMAC counter value, defaults to 0
|
|
45
|
-
:type initial_count: int
|
|
46
51
|
:param issuer_name: the name of the OTP issuer; this will be the
|
|
47
52
|
organization title of the OTP entry in Authenticator
|
|
48
53
|
:returns: provisioning URI
|
|
49
|
-
:rtype: str
|
|
50
54
|
"""
|
|
51
55
|
return utils.build_uri(
|
|
52
56
|
self.secret,
|
|
53
|
-
name,
|
|
54
|
-
initial_count=initial_count,
|
|
55
|
-
|
|
57
|
+
name=name if name else self.name,
|
|
58
|
+
initial_count=initial_count if initial_count else self.initial_count,
|
|
59
|
+
issuer=issuer_name if issuer_name else self.issuer,
|
|
56
60
|
algorithm=self.digest().name,
|
|
57
61
|
digits=self.digits
|
|
58
62
|
)
|
|
@@ -1,32 +1,39 @@
|
|
|
1
|
-
from
|
|
1
|
+
from typing import Any, Optional
|
|
2
2
|
|
|
3
3
|
import base64
|
|
4
4
|
import hashlib
|
|
5
5
|
import hmac
|
|
6
|
-
|
|
6
|
+
|
|
7
7
|
|
|
8
8
|
class OTP(object):
|
|
9
9
|
"""
|
|
10
10
|
Base class for OTP handlers.
|
|
11
11
|
"""
|
|
12
|
-
def __init__(
|
|
12
|
+
def __init__(
|
|
13
|
+
self,
|
|
14
|
+
s: str,
|
|
15
|
+
digits: int = 6,
|
|
16
|
+
digest: Any = hashlib.sha1,
|
|
17
|
+
name: Optional[str] = None,
|
|
18
|
+
issuer: Optional[str] = None
|
|
19
|
+
) -> None:
|
|
13
20
|
"""
|
|
14
21
|
:param s: secret in base32 format
|
|
15
|
-
:type s: str
|
|
16
22
|
:param digits: number of integers in the OTP. Some apps expect this to be 6 digits, others support more.
|
|
17
|
-
:type digits: int
|
|
18
23
|
:param digest: digest function to use in the HMAC (expected to be sha1)
|
|
19
|
-
:
|
|
24
|
+
:param name: account name
|
|
25
|
+
:param issuer: issuer
|
|
20
26
|
"""
|
|
21
27
|
self.digits = digits
|
|
22
28
|
self.digest = digest
|
|
23
29
|
self.secret = s
|
|
30
|
+
self.name = name or 'Secret'
|
|
31
|
+
self.issuer = issuer
|
|
24
32
|
|
|
25
|
-
def generate_otp(self, input):
|
|
33
|
+
def generate_otp(self, input: int) -> str:
|
|
26
34
|
"""
|
|
27
35
|
:param input: the HMAC counter value to use as the OTP input.
|
|
28
36
|
Usually either the counter, or the computed integer based on the Unix timestamp
|
|
29
|
-
:type input: int
|
|
30
37
|
"""
|
|
31
38
|
if input < 0:
|
|
32
39
|
raise ValueError('input must be positive integer')
|
|
@@ -43,14 +50,14 @@ class OTP(object):
|
|
|
43
50
|
|
|
44
51
|
return str_code
|
|
45
52
|
|
|
46
|
-
def byte_secret(self):
|
|
53
|
+
def byte_secret(self) -> bytes:
|
|
47
54
|
missing_padding = len(self.secret) % 8
|
|
48
55
|
if missing_padding != 0:
|
|
49
56
|
self.secret += '=' * (8 - missing_padding)
|
|
50
57
|
return base64.b32decode(self.secret, casefold=True)
|
|
51
58
|
|
|
52
59
|
@staticmethod
|
|
53
|
-
def int_to_bytestring(i, padding=8):
|
|
60
|
+
def int_to_bytestring(i: int, padding: int = 8) -> bytes:
|
|
54
61
|
"""
|
|
55
62
|
Turns an integer to the OATH specified
|
|
56
63
|
bytestring, which is fed to the HMAC
|
|
File without changes
|
|
@@ -1,60 +1,58 @@
|
|
|
1
|
-
from
|
|
1
|
+
from typing import Any, Union, Optional
|
|
2
2
|
|
|
3
|
+
import calendar
|
|
3
4
|
import datetime
|
|
4
5
|
import time
|
|
5
6
|
|
|
6
7
|
from . import utils
|
|
7
8
|
from .otp import OTP
|
|
8
|
-
|
|
9
|
+
|
|
9
10
|
|
|
10
11
|
class TOTP(OTP):
|
|
11
12
|
"""
|
|
12
13
|
Handler for time-based OTP counters.
|
|
13
14
|
"""
|
|
14
|
-
def __init__(self, *args, **kwargs):
|
|
15
|
+
def __init__(self, *args: Any, interval: int = 30, **kwargs: Any) -> None:
|
|
15
16
|
"""
|
|
16
17
|
:param interval: the time interval in seconds
|
|
17
18
|
for OTP. This defaults to 30.
|
|
18
|
-
:type interval: int
|
|
19
19
|
"""
|
|
20
|
-
self.interval =
|
|
20
|
+
self.interval = interval
|
|
21
21
|
super(TOTP, self).__init__(*args, **kwargs)
|
|
22
22
|
|
|
23
|
-
def at(self, for_time, counter_offset=0):
|
|
23
|
+
def at(self, for_time: Union[int, datetime.datetime], counter_offset: int = 0) -> str:
|
|
24
24
|
"""
|
|
25
25
|
Accepts either a Unix timestamp integer or a datetime object.
|
|
26
26
|
|
|
27
|
+
To get the time until the next timecode change (seconds until the current OTP expires), use this instead::
|
|
28
|
+
|
|
29
|
+
totp = pyotp.TOTP(...)
|
|
30
|
+
time_remaining = totp.interval - datetime.datetime.now().timestamp() % totp.interval
|
|
31
|
+
|
|
27
32
|
:param for_time: the time to generate an OTP for
|
|
28
|
-
:type for_time: int or datetime
|
|
29
33
|
:param counter_offset: the amount of ticks to add to the time counter
|
|
30
34
|
:returns: OTP value
|
|
31
|
-
:rtype: str
|
|
32
35
|
"""
|
|
33
36
|
if not isinstance(for_time, datetime.datetime):
|
|
34
37
|
for_time = datetime.datetime.fromtimestamp(int(for_time))
|
|
35
38
|
return self.generate_otp(self.timecode(for_time) + counter_offset)
|
|
36
39
|
|
|
37
|
-
def now(self):
|
|
40
|
+
def now(self) -> str:
|
|
38
41
|
"""
|
|
39
42
|
Generate the current time OTP
|
|
40
43
|
|
|
41
44
|
:returns: OTP value
|
|
42
|
-
:rtype: str
|
|
43
45
|
"""
|
|
44
46
|
return self.generate_otp(self.timecode(datetime.datetime.now()))
|
|
45
47
|
|
|
46
|
-
def verify(self, otp, for_time=None, valid_window=0):
|
|
48
|
+
def verify(self, otp: str, for_time: Optional[datetime.datetime] = None, valid_window: int = 0) -> bool:
|
|
47
49
|
"""
|
|
48
50
|
Verifies the OTP passed in against the current time OTP.
|
|
49
51
|
|
|
50
52
|
:param otp: the OTP to check against
|
|
51
|
-
:type otp: str
|
|
52
53
|
:param for_time: Time to check OTP at (defaults to now)
|
|
53
|
-
:type for_time: int or datetime
|
|
54
54
|
:param valid_window: extends the validity to this many counter ticks before and after the current one
|
|
55
|
-
:type valid_window: int
|
|
56
55
|
:returns: True if verification succeeded, False otherwise
|
|
57
|
-
:rtype: bool
|
|
58
56
|
"""
|
|
59
57
|
if for_time is None:
|
|
60
58
|
for_time = datetime.datetime.now()
|
|
@@ -67,7 +65,7 @@ class TOTP(OTP):
|
|
|
67
65
|
|
|
68
66
|
return utils.strings_equal(str(otp), str(self.at(for_time)))
|
|
69
67
|
|
|
70
|
-
def provisioning_uri(self, name, issuer_name=None):
|
|
68
|
+
def provisioning_uri(self, name: Optional[str] = None, issuer_name: Optional[str] = None) -> str:
|
|
71
69
|
"""
|
|
72
70
|
Returns the provisioning URI for the OTP. This can then be
|
|
73
71
|
encoded in a QR Code and used to provision an OTP app like
|
|
@@ -76,17 +74,20 @@ class TOTP(OTP):
|
|
|
76
74
|
See also:
|
|
77
75
|
https://github.com/google/google-authenticator/wiki/Key-Uri-Format
|
|
78
76
|
|
|
79
|
-
:param name: name of the user account
|
|
80
|
-
:type name: str
|
|
81
|
-
:param issuer_name: the name of the OTP issuer; this will be the
|
|
82
|
-
organization title of the OTP entry in Authenticator
|
|
83
|
-
:returns: provisioning URI
|
|
84
|
-
:rtype: str
|
|
85
77
|
"""
|
|
86
|
-
return utils.build_uri(self.secret, name
|
|
78
|
+
return utils.build_uri(self.secret, name if name else self.name,
|
|
79
|
+
issuer=issuer_name if issuer_name else self.issuer,
|
|
87
80
|
algorithm=self.digest().name,
|
|
88
81
|
digits=self.digits, period=self.interval)
|
|
89
82
|
|
|
90
|
-
def timecode(self, for_time):
|
|
91
|
-
|
|
92
|
-
|
|
83
|
+
def timecode(self, for_time: datetime.datetime) -> int:
|
|
84
|
+
"""
|
|
85
|
+
Accepts either a timezone naive (`for_time.tzinfo is None`) or
|
|
86
|
+
a timezone aware datetime as argument and returns the
|
|
87
|
+
corresponding counter value (timecode).
|
|
88
|
+
|
|
89
|
+
"""
|
|
90
|
+
if for_time.tzinfo:
|
|
91
|
+
return int(calendar.timegm(for_time.utctimetuple()) / self.interval)
|
|
92
|
+
else:
|
|
93
|
+
return int(time.mktime(for_time.timetuple()) / self.interval)
|
|
@@ -1,19 +1,18 @@
|
|
|
1
|
-
from
|
|
1
|
+
from typing import Dict, Optional, Union
|
|
2
2
|
|
|
3
3
|
import unicodedata
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
algorithm=None, digits=None, period=None):
|
|
4
|
+
from hmac import compare_digest
|
|
5
|
+
from urllib.parse import quote, urlencode
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def build_uri(
|
|
9
|
+
secret: str,
|
|
10
|
+
name: str,
|
|
11
|
+
initial_count: Optional[int] = None,
|
|
12
|
+
issuer: Optional[str] = None,
|
|
13
|
+
algorithm: Optional[str] = None,
|
|
14
|
+
digits: Optional[int] = None,
|
|
15
|
+
period: Optional[int] = None) -> str:
|
|
17
16
|
"""
|
|
18
17
|
Returns the provisioning URI for the OTP; works for either TOTP or HOTP.
|
|
19
18
|
|
|
@@ -26,24 +25,16 @@ def build_uri(secret, name, initial_count=None, issuer_name=None,
|
|
|
26
25
|
https://github.com/google/google-authenticator/wiki/Key-Uri-Format
|
|
27
26
|
|
|
28
27
|
:param secret: the hotp/totp secret used to generate the URI
|
|
29
|
-
:type secret: str
|
|
30
28
|
:param name: name of the account
|
|
31
|
-
:type name: str
|
|
32
29
|
:param initial_count: starting counter value, defaults to None.
|
|
33
30
|
If none, the OTP type will be assumed as TOTP.
|
|
34
|
-
:
|
|
35
|
-
:param issuer_name: the name of the OTP issuer; this will be the
|
|
31
|
+
:param issuer: the name of the OTP issuer; this will be the
|
|
36
32
|
organization title of the OTP entry in Authenticator
|
|
37
|
-
:type issuer_name: str
|
|
38
33
|
:param algorithm: the algorithm used in the OTP generation.
|
|
39
|
-
:type algorithm: str
|
|
40
34
|
:param digits: the length of the OTP generated code.
|
|
41
|
-
:type digits: int
|
|
42
35
|
:param period: the number of seconds the OTP generator is set to
|
|
43
36
|
expire every code.
|
|
44
|
-
:type period: int
|
|
45
37
|
:returns: provisioning uri
|
|
46
|
-
:rtype: str
|
|
47
38
|
"""
|
|
48
39
|
# initial_count may be 0 as a valid param
|
|
49
40
|
is_initial_count_present = (initial_count is not None)
|
|
@@ -56,17 +47,17 @@ def build_uri(secret, name, initial_count=None, issuer_name=None,
|
|
|
56
47
|
otp_type = 'hotp' if is_initial_count_present else 'totp'
|
|
57
48
|
base_uri = 'otpauth://{0}/{1}?{2}'
|
|
58
49
|
|
|
59
|
-
url_args = {'secret': secret}
|
|
50
|
+
url_args = {'secret': secret} # type: Dict[str, Union[None, int, str]]
|
|
60
51
|
|
|
61
52
|
label = quote(name)
|
|
62
|
-
if
|
|
63
|
-
label = quote(
|
|
64
|
-
url_args['issuer'] =
|
|
53
|
+
if issuer is not None:
|
|
54
|
+
label = quote(issuer) + ':' + label
|
|
55
|
+
url_args['issuer'] = issuer
|
|
65
56
|
|
|
66
57
|
if is_initial_count_present:
|
|
67
58
|
url_args['counter'] = initial_count
|
|
68
59
|
if is_algorithm_set:
|
|
69
|
-
url_args['algorithm'] = algorithm.upper()
|
|
60
|
+
url_args['algorithm'] = algorithm.upper() # type: ignore
|
|
70
61
|
if is_digits_set:
|
|
71
62
|
url_args['digits'] = digits
|
|
72
63
|
if is_period_set:
|
|
@@ -76,26 +67,7 @@ def build_uri(secret, name, initial_count=None, issuer_name=None,
|
|
|
76
67
|
return uri
|
|
77
68
|
|
|
78
69
|
|
|
79
|
-
def
|
|
80
|
-
differences = 0
|
|
81
|
-
for c1, c2 in izip_longest(s1, s2):
|
|
82
|
-
if c1 is None or c2 is None:
|
|
83
|
-
differences = 1
|
|
84
|
-
continue
|
|
85
|
-
differences |= ord(c1) ^ ord(c2)
|
|
86
|
-
return differences == 0
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
try:
|
|
90
|
-
# Python 3.3+ and 2.7.7+ include a timing-attack-resistant
|
|
91
|
-
# comparison function, which is probably more reliable than ours.
|
|
92
|
-
# Use it if available.
|
|
93
|
-
from hmac import compare_digest
|
|
94
|
-
except ImportError:
|
|
95
|
-
compare_digest = _compare_digest
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
def strings_equal(s1, s2):
|
|
70
|
+
def strings_equal(s1: str, s2: str) -> bool:
|
|
99
71
|
"""
|
|
100
72
|
Timing-attack resistant string comparison.
|
|
101
73
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 1.1
|
|
2
2
|
Name: pyotp
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.4.1
|
|
4
4
|
Summary: Python One Time Password Library
|
|
5
5
|
Home-page: https://github.com/pyotp/pyotp
|
|
6
6
|
Author: PyOTP contributors
|
|
@@ -84,11 +84,15 @@ Description: PyOTP - The Python One-Time Password Library
|
|
|
84
84
|
hotp.verify('316439', 1401) # => True
|
|
85
85
|
hotp.verify('316439', 1402) # => False
|
|
86
86
|
|
|
87
|
-
Generating a
|
|
88
|
-
|
|
89
|
-
::
|
|
87
|
+
Generating a Secret Key
|
|
88
|
+
~~~~~~~~~~~~~~~~~~~~~~~
|
|
89
|
+
A helper function is provided to generate a 16 character base32 secret, compatible with Google Authenticator and other OTP apps::
|
|
90
|
+
|
|
91
|
+
pyotp.random_base32()
|
|
90
92
|
|
|
91
|
-
|
|
93
|
+
Some applications want the secret key to be formatted as a hex-encoded string::
|
|
94
|
+
|
|
95
|
+
pyotp.random_hex() # returns a 32-character hex-encoded secret
|
|
92
96
|
|
|
93
97
|
Google Authenticator Compatible
|
|
94
98
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
@@ -96,17 +100,27 @@ Description: PyOTP - The Python One-Time Password Library
|
|
|
96
100
|
PyOTP works with the Google Authenticator iPhone and Android app, as well as other OTP apps like Authy. PyOTP includes the
|
|
97
101
|
ability to generate provisioning URIs for use with the QR Code scanner built into these MFA client apps::
|
|
98
102
|
|
|
99
|
-
pyotp.totp.TOTP('JBSWY3DPEHPK3PXP').provisioning_uri(
|
|
103
|
+
pyotp.totp.TOTP('JBSWY3DPEHPK3PXP').provisioning_uri(name='alice@google.com', issuer_name='Secure App')
|
|
100
104
|
|
|
101
105
|
>>> 'otpauth://totp/Secure%20App:alice%40google.com?secret=JBSWY3DPEHPK3PXP&issuer=Secure%20App'
|
|
102
106
|
|
|
103
|
-
pyotp.hotp.HOTP('JBSWY3DPEHPK3PXP').provisioning_uri("alice@google.com",
|
|
107
|
+
pyotp.hotp.HOTP('JBSWY3DPEHPK3PXP').provisioning_uri(name="alice@google.com", issuer_name="Secure App", initial_count=0)
|
|
104
108
|
|
|
105
109
|
>>> 'otpauth://hotp/Secure%20App:alice%40google.com?secret=JBSWY3DPEHPK3PXP&issuer=Secure%20App&counter=0'
|
|
106
110
|
|
|
107
111
|
This URL can then be rendered as a QR Code (for example, using https://github.com/neocotic/qrious) which can then be scanned
|
|
108
112
|
and added to the users list of OTP credentials.
|
|
109
113
|
|
|
114
|
+
Parsing these URLs is also supported::
|
|
115
|
+
|
|
116
|
+
pyotp.parse_uri('otpauth://totp/Secure%20App:alice%40google.com?secret=JBSWY3DPEHPK3PXP&issuer=Secure%20App')
|
|
117
|
+
|
|
118
|
+
>>> <pyotp.totp.TOTP object at 0xFFFFFFFF>
|
|
119
|
+
|
|
120
|
+
pyotp.parse_uri('otpauth://hotp/Secure%20App:alice%40google.com?secret=JBSWY3DPEHPK3PXP&issuer=Secure%20App&counter=0'
|
|
121
|
+
|
|
122
|
+
>>> <pyotp.totp.HOTP object at 0xFFFFFFFF>
|
|
123
|
+
|
|
110
124
|
Working example
|
|
111
125
|
~~~~~~~~~~~~~~~
|
|
112
126
|
|
|
@@ -123,10 +137,10 @@ Description: PyOTP - The Python One-Time Password Library
|
|
|
123
137
|
Links
|
|
124
138
|
~~~~~
|
|
125
139
|
|
|
126
|
-
* `Project home page (GitHub) <https://github.com/
|
|
140
|
+
* `Project home page (GitHub) <https://github.com/pyauth/pyotp>`_
|
|
127
141
|
* `Documentation (Read the Docs) <https://pyotp.readthedocs.io/en/latest/>`_
|
|
128
142
|
* `Package distribution (PyPI) <https://pypi.python.org/pypi/pyotp>`_
|
|
129
|
-
* `Change log <https://github.com/
|
|
143
|
+
* `Change log <https://github.com/pyauth/pyotp/blob/master/Changes.rst>`_
|
|
130
144
|
* `RFC 4226: HOTP: An HMAC-Based One-Time Password <https://tools.ietf.org/html/rfc4226>`_
|
|
131
145
|
* `RFC 6238: TOTP: Time-Based One-Time Password Algorithm <https://tools.ietf.org/html/rfc6238>`_
|
|
132
146
|
* `ROTP <https://github.com/mdp/rotp>`_ - Original Ruby OTP library by `Mark Percival <https://github.com/mdp>`_
|
|
@@ -139,10 +153,10 @@ Description: PyOTP - The Python One-Time Password Library
|
|
|
139
153
|
* `WebAuthn <https://www.w3.org/TR/webauthn/>`_
|
|
140
154
|
* `PyWARP <https://github.com/pyauth/pywarp>`_
|
|
141
155
|
|
|
142
|
-
.. image:: https://
|
|
143
|
-
:target: https://
|
|
144
|
-
.. image:: https://img.shields.io/codecov/c/github/
|
|
145
|
-
:target: https://codecov.io/github/
|
|
156
|
+
.. image:: https://github.com/pyauth/pyotp/workflows/Python%20package/badge.svg
|
|
157
|
+
:target: https://github.com/pyauth/pyotp/actions
|
|
158
|
+
.. image:: https://img.shields.io/codecov/c/github/pyauth/pyotp/master.svg
|
|
159
|
+
:target: https://codecov.io/github/pyauth/pyotp?branch=master
|
|
146
160
|
.. image:: https://img.shields.io/pypi/v/pyotp.svg
|
|
147
161
|
:target: https://pypi.python.org/pypi/pyotp
|
|
148
162
|
.. image:: https://img.shields.io/pypi/l/pyotp.svg
|
|
@@ -157,7 +171,6 @@ Classifier: License :: OSI Approved :: MIT License
|
|
|
157
171
|
Classifier: Operating System :: MacOS :: MacOS X
|
|
158
172
|
Classifier: Operating System :: POSIX
|
|
159
173
|
Classifier: Programming Language :: Python
|
|
160
|
-
Classifier: Programming Language :: Python :: 2.7
|
|
161
174
|
Classifier: Programming Language :: Python :: 3.4
|
|
162
175
|
Classifier: Programming Language :: Python :: 3.5
|
|
163
176
|
Classifier: Programming Language :: Python :: 3.6
|
|
@@ -1,18 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env python
|
|
2
2
|
# coding: utf-8
|
|
3
3
|
|
|
4
|
-
from __future__ import absolute_import, division, print_function, unicode_literals
|
|
5
|
-
|
|
6
4
|
import base64, datetime, hashlib, os, sys, unittest
|
|
7
5
|
from warnings import warn
|
|
8
6
|
|
|
9
|
-
|
|
10
|
-
from urllib.parse import urlparse, parse_qsl
|
|
11
|
-
except ImportError:
|
|
12
|
-
from urlparse import urlparse, parse_qsl
|
|
7
|
+
from urllib.parse import urlparse, parse_qsl
|
|
13
8
|
|
|
14
9
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src'))
|
|
15
|
-
import pyotp
|
|
10
|
+
import pyotp
|
|
11
|
+
|
|
16
12
|
|
|
17
13
|
class HOTPExampleValuesFromTheRFC(unittest.TestCase):
|
|
18
14
|
def test_match_rfc(self):
|
|
@@ -42,37 +38,55 @@ class HOTPExampleValuesFromTheRFC(unittest.TestCase):
|
|
|
42
38
|
self.assertFalse(hotp.verify('520489', 10))
|
|
43
39
|
|
|
44
40
|
def test_provisioning_uri(self):
|
|
45
|
-
hotp = pyotp.HOTP('wrn3pqx5uqxqvnqr')
|
|
41
|
+
hotp = pyotp.HOTP('wrn3pqx5uqxqvnqr', name='mark@percival')
|
|
46
42
|
|
|
47
|
-
url = urlparse(
|
|
48
|
-
hotp.provisioning_uri('mark@percival'))
|
|
43
|
+
url = urlparse(hotp.provisioning_uri())
|
|
49
44
|
self.assertEqual(url.scheme, 'otpauth')
|
|
50
45
|
self.assertEqual(url.netloc, 'hotp')
|
|
51
46
|
self.assertEqual(url.path, '/mark%40percival')
|
|
52
47
|
self.assertEqual(dict(parse_qsl(url.query)),
|
|
53
48
|
{'secret': 'wrn3pqx5uqxqvnqr', 'counter': '0'})
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
49
|
+
self.assertEqual(
|
|
50
|
+
hotp.provisioning_uri(),
|
|
51
|
+
pyotp.parse_uri(
|
|
52
|
+
hotp.provisioning_uri()
|
|
53
|
+
).provisioning_uri()
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
hotp = pyotp.HOTP('wrn3pqx5uqxqvnqr', name='mark@percival', initial_count=12)
|
|
57
|
+
url = urlparse(hotp.provisioning_uri())
|
|
57
58
|
self.assertEqual(url.scheme, 'otpauth')
|
|
58
59
|
self.assertEqual(url.netloc, 'hotp')
|
|
59
60
|
self.assertEqual(url.path, '/mark%40percival')
|
|
60
61
|
self.assertEqual(dict(parse_qsl(url.query)),
|
|
61
62
|
{'secret': 'wrn3pqx5uqxqvnqr', 'counter': '12'})
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
63
|
+
self.assertEqual(
|
|
64
|
+
hotp.provisioning_uri(),
|
|
65
|
+
pyotp.parse_uri(
|
|
66
|
+
hotp.provisioning_uri()
|
|
67
|
+
).provisioning_uri()
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
hotp = pyotp.HOTP('wrn3pqx5uqxqvnqr', name='mark@percival',
|
|
71
|
+
issuer='FooCorp!')
|
|
72
|
+
url = urlparse(hotp.provisioning_uri())
|
|
65
73
|
self.assertEqual(url.scheme, 'otpauth')
|
|
66
74
|
self.assertEqual(url.netloc, 'hotp')
|
|
67
75
|
self.assertEqual(url.path, '/FooCorp%21:mark%40percival')
|
|
68
76
|
self.assertEqual(dict(parse_qsl(url.query)),
|
|
69
77
|
{'secret': 'wrn3pqx5uqxqvnqr', 'counter': '0',
|
|
70
78
|
'issuer': 'FooCorp!'})
|
|
79
|
+
self.assertEqual(
|
|
80
|
+
hotp.provisioning_uri(),
|
|
81
|
+
pyotp.parse_uri(
|
|
82
|
+
hotp.provisioning_uri()
|
|
83
|
+
).provisioning_uri()
|
|
84
|
+
)
|
|
71
85
|
|
|
72
86
|
key = 'c7uxuqhgflpw7oruedmglbrk7u6242vb'
|
|
73
|
-
hotp = pyotp.HOTP(key, digits=8, digest=hashlib.sha256
|
|
74
|
-
|
|
75
|
-
|
|
87
|
+
hotp = pyotp.HOTP(key, digits=8, digest=hashlib.sha256,
|
|
88
|
+
name='baco@peperina', issuer='FooCorp')
|
|
89
|
+
url = urlparse(hotp.provisioning_uri())
|
|
76
90
|
self.assertEqual(url.scheme, 'otpauth')
|
|
77
91
|
self.assertEqual(url.netloc, 'hotp')
|
|
78
92
|
self.assertEqual(url.path, '/FooCorp:baco%40peperina')
|
|
@@ -80,11 +94,16 @@ class HOTPExampleValuesFromTheRFC(unittest.TestCase):
|
|
|
80
94
|
{'secret': 'c7uxuqhgflpw7oruedmglbrk7u6242vb',
|
|
81
95
|
'counter': '0', 'issuer': 'FooCorp',
|
|
82
96
|
'digits': '8', 'algorithm': 'SHA256'})
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
97
|
+
self.assertEqual(
|
|
98
|
+
hotp.provisioning_uri(),
|
|
99
|
+
pyotp.parse_uri(
|
|
100
|
+
hotp.provisioning_uri()
|
|
101
|
+
).provisioning_uri()
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
hotp = pyotp.HOTP(key, digits=8, name='baco@peperina',
|
|
105
|
+
issuer='Foo Corp', initial_count=10)
|
|
106
|
+
url = urlparse(hotp.provisioning_uri())
|
|
88
107
|
self.assertEqual(url.scheme, 'otpauth')
|
|
89
108
|
self.assertEqual(url.netloc, 'hotp')
|
|
90
109
|
self.assertEqual(url.path, '/Foo%20Corp:baco%40peperina')
|
|
@@ -92,6 +111,12 @@ class HOTPExampleValuesFromTheRFC(unittest.TestCase):
|
|
|
92
111
|
{'secret': 'c7uxuqhgflpw7oruedmglbrk7u6242vb',
|
|
93
112
|
'counter': '10', 'issuer': 'Foo Corp',
|
|
94
113
|
'digits': '8'})
|
|
114
|
+
self.assertEqual(
|
|
115
|
+
hotp.provisioning_uri(),
|
|
116
|
+
pyotp.parse_uri(
|
|
117
|
+
hotp.provisioning_uri()
|
|
118
|
+
).provisioning_uri()
|
|
119
|
+
)
|
|
95
120
|
|
|
96
121
|
def test_other_secret(self):
|
|
97
122
|
hotp = pyotp.HOTP(
|
|
@@ -183,27 +208,40 @@ class TOTPExampleValuesFromTheRFC(unittest.TestCase):
|
|
|
183
208
|
self.assertFalse(totp.verify('050471'))
|
|
184
209
|
|
|
185
210
|
def test_provisioning_uri(self):
|
|
186
|
-
totp = pyotp.TOTP('wrn3pqx5uqxqvnqr')
|
|
187
|
-
url = urlparse(
|
|
188
|
-
totp.provisioning_uri('mark@percival'))
|
|
211
|
+
totp = pyotp.TOTP('wrn3pqx5uqxqvnqr', name='mark@percival')
|
|
212
|
+
url = urlparse(totp.provisioning_uri())
|
|
189
213
|
self.assertEqual(url.scheme, 'otpauth')
|
|
190
214
|
self.assertEqual(url.netloc, 'totp')
|
|
191
215
|
self.assertEqual(url.path, '/mark%40percival')
|
|
192
216
|
self.assertEqual(dict(parse_qsl(url.query)),
|
|
193
217
|
{'secret': 'wrn3pqx5uqxqvnqr'})
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
218
|
+
self.assertEqual(
|
|
219
|
+
totp.provisioning_uri(),
|
|
220
|
+
pyotp.parse_uri(
|
|
221
|
+
totp.provisioning_uri()
|
|
222
|
+
).provisioning_uri()
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
totp = pyotp.TOTP('wrn3pqx5uqxqvnqr', name='mark@percival',
|
|
226
|
+
issuer='FooCorp!')
|
|
227
|
+
url = urlparse(totp.provisioning_uri())
|
|
197
228
|
self.assertEqual(url.scheme, 'otpauth')
|
|
198
229
|
self.assertEqual(url.netloc, 'totp')
|
|
199
230
|
self.assertEqual(url.path, '/FooCorp%21:mark%40percival')
|
|
200
231
|
self.assertEqual(dict(parse_qsl(url.query)),
|
|
201
232
|
{'secret': 'wrn3pqx5uqxqvnqr',
|
|
202
233
|
'issuer': 'FooCorp!'})
|
|
234
|
+
self.assertEqual(
|
|
235
|
+
totp.provisioning_uri(),
|
|
236
|
+
pyotp.parse_uri(
|
|
237
|
+
totp.provisioning_uri()
|
|
238
|
+
).provisioning_uri()
|
|
239
|
+
)
|
|
203
240
|
|
|
204
241
|
key = 'c7uxuqhgflpw7oruedmglbrk7u6242vb'
|
|
205
|
-
totp = pyotp.TOTP(key, digits=8, interval=60, digest=hashlib.sha256
|
|
206
|
-
|
|
242
|
+
totp = pyotp.TOTP(key, digits=8, interval=60, digest=hashlib.sha256,
|
|
243
|
+
name='baco@peperina', issuer='FooCorp')
|
|
244
|
+
url = urlparse(totp.provisioning_uri())
|
|
207
245
|
self.assertEqual(url.scheme, 'otpauth')
|
|
208
246
|
self.assertEqual(url.netloc, 'totp')
|
|
209
247
|
self.assertEqual(url.path, '/FooCorp:baco%40peperina')
|
|
@@ -212,9 +250,16 @@ class TOTPExampleValuesFromTheRFC(unittest.TestCase):
|
|
|
212
250
|
'issuer': 'FooCorp',
|
|
213
251
|
'digits': '8', 'period': '60',
|
|
214
252
|
'algorithm': 'SHA256'})
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
253
|
+
self.assertEqual(
|
|
254
|
+
totp.provisioning_uri(),
|
|
255
|
+
pyotp.parse_uri(
|
|
256
|
+
totp.provisioning_uri()
|
|
257
|
+
).provisioning_uri()
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
totp = pyotp.TOTP(key, digits=8, interval=60,
|
|
261
|
+
name='baco@peperina', issuer='FooCorp')
|
|
262
|
+
url = urlparse(totp.provisioning_uri())
|
|
218
263
|
self.assertEqual(url.scheme, 'otpauth')
|
|
219
264
|
self.assertEqual(url.netloc, 'totp')
|
|
220
265
|
self.assertEqual(url.path, '/FooCorp:baco%40peperina')
|
|
@@ -222,9 +267,15 @@ class TOTPExampleValuesFromTheRFC(unittest.TestCase):
|
|
|
222
267
|
{'secret': 'c7uxuqhgflpw7oruedmglbrk7u6242vb',
|
|
223
268
|
'issuer': 'FooCorp',
|
|
224
269
|
'digits': '8', 'period': '60'})
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
270
|
+
self.assertEqual(
|
|
271
|
+
totp.provisioning_uri(),
|
|
272
|
+
pyotp.parse_uri(
|
|
273
|
+
totp.provisioning_uri()
|
|
274
|
+
).provisioning_uri()
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
totp = pyotp.TOTP(key, digits=8, name='baco@peperina', issuer='FooCorp')
|
|
278
|
+
url = urlparse(totp.provisioning_uri())
|
|
228
279
|
self.assertEqual(url.scheme, 'otpauth')
|
|
229
280
|
self.assertEqual(url.netloc, 'totp')
|
|
230
281
|
self.assertEqual(url.path, '/FooCorp:baco%40peperina')
|
|
@@ -232,10 +283,22 @@ class TOTPExampleValuesFromTheRFC(unittest.TestCase):
|
|
|
232
283
|
{'secret': 'c7uxuqhgflpw7oruedmglbrk7u6242vb',
|
|
233
284
|
'issuer': 'FooCorp',
|
|
234
285
|
'digits': '8'})
|
|
286
|
+
self.assertEqual(
|
|
287
|
+
totp.provisioning_uri(),
|
|
288
|
+
pyotp.parse_uri(
|
|
289
|
+
totp.provisioning_uri()
|
|
290
|
+
).provisioning_uri()
|
|
291
|
+
)
|
|
235
292
|
|
|
236
293
|
def test_random_key_generation(self):
|
|
237
294
|
self.assertEqual(len(pyotp.random_base32()), 16)
|
|
238
295
|
self.assertEqual(len(pyotp.random_base32(length=20)), 20)
|
|
296
|
+
self.assertEqual(len(pyotp.random_hex()), 32)
|
|
297
|
+
self.assertEqual(len(pyotp.random_hex(length=64)), 64)
|
|
298
|
+
with self.assertRaises(Exception):
|
|
299
|
+
pyotp.random_base32(length=15)
|
|
300
|
+
with self.assertRaises(Exception):
|
|
301
|
+
pyotp.random_hex(length=24)
|
|
239
302
|
|
|
240
303
|
|
|
241
304
|
class CompareDigestTest(unittest.TestCase):
|
|
@@ -251,10 +314,6 @@ class CompareDigestTest(unittest.TestCase):
|
|
|
251
314
|
self.assertFalse(self.method("a" * 999 + "b", "a" * 1000))
|
|
252
315
|
|
|
253
316
|
|
|
254
|
-
class FallBackCompareDigestTest(CompareDigestTest):
|
|
255
|
-
method = staticmethod(pyotp.utils._compare_digest)
|
|
256
|
-
|
|
257
|
-
|
|
258
317
|
class StringComparisonTest(CompareDigestTest):
|
|
259
318
|
method = staticmethod(pyotp.utils.strings_equal)
|
|
260
319
|
|
|
@@ -281,6 +340,79 @@ class ValidWindowTest(unittest.TestCase):
|
|
|
281
340
|
self.assertFalse(totp.verify("195979", 200, 1))
|
|
282
341
|
|
|
283
342
|
|
|
343
|
+
class ParseUriTest(unittest.TestCase):
|
|
344
|
+
def test_invalids(self):
|
|
345
|
+
with self.assertRaises(ValueError) as cm:
|
|
346
|
+
pyotp.parse_uri('http://hello.com')
|
|
347
|
+
self.assertEqual('Not an otpauth URI', str(cm.exception))
|
|
348
|
+
|
|
349
|
+
with self.assertRaises(ValueError) as cm:
|
|
350
|
+
pyotp.parse_uri('otpauth://totp')
|
|
351
|
+
self.assertEqual('No secret found in URI', str(cm.exception))
|
|
352
|
+
|
|
353
|
+
with self.assertRaises(ValueError) as cm:
|
|
354
|
+
pyotp.parse_uri('otpauth://derp?secret=foo')
|
|
355
|
+
self.assertEqual('Not a supported OTP type', str(cm.exception))
|
|
356
|
+
|
|
357
|
+
with self.assertRaises(ValueError) as cm:
|
|
358
|
+
pyotp.parse_uri('otpauth://totp?foo=secret')
|
|
359
|
+
self.assertEqual('foo is not a valid parameter', str(cm.exception))
|
|
360
|
+
|
|
361
|
+
with self.assertRaises(ValueError) as cm:
|
|
362
|
+
pyotp.parse_uri('otpauth://totp?digits=-1')
|
|
363
|
+
self.assertEqual('Digits may only be 6 or 8', str(cm.exception))
|
|
364
|
+
|
|
365
|
+
with self.assertRaises(ValueError) as cm:
|
|
366
|
+
pyotp.parse_uri('otpauth://totp/SomeIssuer:?issuer=AnotherIssuer')
|
|
367
|
+
self.assertEqual('If issuer is specified in both label and parameters, it should be equal.', str(cm.exception))
|
|
368
|
+
|
|
369
|
+
with self.assertRaises(ValueError) as cm:
|
|
370
|
+
pyotp.parse_uri('otpauth://totp?algorithm=aes')
|
|
371
|
+
self.assertEqual('Invalid value for algorithm, must be SHA1, SHA256 or SHA512', str(cm.exception))
|
|
372
|
+
|
|
373
|
+
@unittest.skipIf(sys.version_info < (3, 6), "Skipping test that requires deterministic dict key enumeration")
|
|
374
|
+
def test_algorithms(self):
|
|
375
|
+
otp = pyotp.parse_uri('otpauth://totp?algorithm=SHA1&secret=GEZDGNBV&algorithm=SHA1')
|
|
376
|
+
self.assertEqual(hashlib.sha1, otp.digest)
|
|
377
|
+
self.assertEqual(otp.at(0), '734055')
|
|
378
|
+
self.assertEqual(otp.at(30), '662488')
|
|
379
|
+
self.assertEqual(otp.at(60), '289363')
|
|
380
|
+
self.assertEqual(otp.provisioning_uri(), 'otpauth://totp/Secret?secret=GEZDGNBV')
|
|
381
|
+
self.assertEqual(otp.provisioning_uri(name='n', issuer_name='i'), 'otpauth://totp/i:n?secret=GEZDGNBV&issuer=i')
|
|
382
|
+
|
|
383
|
+
otp = pyotp.parse_uri('otpauth://totp?algorithm=SHA1&secret=GEZDGNBV&algorithm=SHA1&period=60')
|
|
384
|
+
self.assertEqual(hashlib.sha1, otp.digest)
|
|
385
|
+
self.assertEqual(otp.at(30), '734055')
|
|
386
|
+
self.assertEqual(otp.at(60), '662488')
|
|
387
|
+
self.assertEqual(otp.provisioning_uri(name='n', issuer_name='i'),
|
|
388
|
+
'otpauth://totp/i:n?secret=GEZDGNBV&issuer=i&period=60')
|
|
389
|
+
|
|
390
|
+
otp = pyotp.parse_uri('otpauth://hotp?algorithm=SHA1&secret=GEZDGNBV&algorithm=SHA1')
|
|
391
|
+
self.assertEqual(hashlib.sha1, otp.digest)
|
|
392
|
+
self.assertEqual(otp.at(0), '734055')
|
|
393
|
+
self.assertEqual(otp.at(1), '662488')
|
|
394
|
+
self.assertEqual(otp.at(2), '289363')
|
|
395
|
+
self.assertEqual(otp.provisioning_uri(name='n', issuer_name='i'),
|
|
396
|
+
'otpauth://hotp/i:n?secret=GEZDGNBV&issuer=i&counter=0')
|
|
397
|
+
|
|
398
|
+
otp = pyotp.parse_uri('otpauth://hotp?algorithm=SHA1&secret=GEZDGNBV&algorithm=SHA1&counter=1')
|
|
399
|
+
self.assertEqual(hashlib.sha1, otp.digest)
|
|
400
|
+
self.assertEqual(otp.at(0), '662488')
|
|
401
|
+
self.assertEqual(otp.at(1), '289363')
|
|
402
|
+
self.assertEqual(otp.provisioning_uri(name='n', issuer_name='i'),
|
|
403
|
+
'otpauth://hotp/i:n?secret=GEZDGNBV&issuer=i&counter=1')
|
|
404
|
+
|
|
405
|
+
otp = pyotp.parse_uri('otpauth://totp?algorithm=SHA1&secret=GEZDGNBV&algorithm=SHA256')
|
|
406
|
+
self.assertEqual(hashlib.sha256, otp.digest)
|
|
407
|
+
self.assertEqual(otp.at(0), '918961')
|
|
408
|
+
self.assertEqual(otp.at(9000), '934470')
|
|
409
|
+
|
|
410
|
+
otp = pyotp.parse_uri('otpauth://totp?algorithm=SHA1&secret=GEZDGNBV&algorithm=SHA512')
|
|
411
|
+
self.assertEqual(hashlib.sha512, otp.digest)
|
|
412
|
+
self.assertEqual(otp.at(0), '816660')
|
|
413
|
+
self.assertEqual(otp.at(9000), '524153')
|
|
414
|
+
|
|
415
|
+
|
|
284
416
|
class Timecop(object):
|
|
285
417
|
"""
|
|
286
418
|
Half-assed clone of timecop.rb, just enough to pass our tests.
|
|
@@ -305,5 +437,6 @@ class Timecop(object):
|
|
|
305
437
|
timecop = self
|
|
306
438
|
return FrozenDateTime
|
|
307
439
|
|
|
440
|
+
|
|
308
441
|
if __name__ == '__main__':
|
|
309
442
|
unittest.main()
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
from __future__ import (absolute_import, division,
|
|
2
|
-
print_function, unicode_literals)
|
|
3
|
-
|
|
4
|
-
from pyotp.hotp import HOTP # noqa
|
|
5
|
-
from pyotp.otp import OTP # noqa
|
|
6
|
-
from pyotp.totp import TOTP # noqa
|
|
7
|
-
from . import utils # noqa
|
|
8
|
-
|
|
9
|
-
def random_base32(length=16, random=None,
|
|
10
|
-
chars=list('ABCDEFGHIJKLMNOPQRSTUVWXYZ234567')):
|
|
11
|
-
|
|
12
|
-
# Use secrets module if available (Python version >= 3.6) per PEP 506
|
|
13
|
-
try:
|
|
14
|
-
import secrets
|
|
15
|
-
random = secrets.SystemRandom()
|
|
16
|
-
except ImportError:
|
|
17
|
-
import random as _random
|
|
18
|
-
random = _random.SystemRandom()
|
|
19
|
-
|
|
20
|
-
return ''.join(
|
|
21
|
-
random.choice(chars)
|
|
22
|
-
for _ in range(length)
|
|
23
|
-
)
|
pyotp-2.3.0/src/pyotp/compat.py
DELETED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|