summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSimeon Simeonov2026-05-01 14:34:04 +0200
committerSimeon Simeonov2026-05-01 14:34:04 +0200
commitbfae04723bd3155801f079ab5238018ec8a0cb09 (patch)
tree7db35cefe64d00bb23b26513fa13afbf26c073dd
parent751c74e7de1c78a151fdd8b76f220d8411a2108a (diff)
Redesign API by introducing otp2289.OTPResponse typeHEADv2.0.0nextmaster
-rw-r--r--CHANGELOG.md28
-rw-r--r--README.md31
-rw-r--r--src/otp2289/__init__.py4
-rw-r--r--src/otp2289/__main__.py153
-rw-r--r--src/otp2289/generator.py314
-rw-r--r--src/otp2289/server.py104
-rw-r--r--tests/test_generator.py278
-rw-r--r--tests/test_server.py4
-rw-r--r--tests/test_static.py38
9 files changed, 560 insertions, 394 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0b220de..fe4f0be 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,44 +1,48 @@
1# Changelog 1# Changelog
2 2
3## [2.0.0](https://github.com/blackm0re/pyotp2289/tree/2.0.0) (2025-01-03) 3## [2.0.0](https://codeberg.org/sgs/pyotp2289/releases/tag/v2.0.0) (2026-05-01)
4 4
5[Full Changelog](https://github.com/blackm0re/pyotp2289/compare/1.2.1...2.0.0) 5[Full Changelog](https://codeberg.org/sgs/pyotp2289/compare/v1.2.1...v2.0.0)
6 6
7**Changes:** 7**Changes:**
8 8
9- Rename *Exception to *Error and do some additional linting 9- Redesign API by introducing the otp2289.OTPResponse type
10 10
11- Add type checking and linting
11 12
12## [1.2.1](https://github.com/blackm0re/pyotp2289/tree/1.2.1) (2023-01-08) 13- Move the project from GitHub to Codeberg
13 14
14[Full Changelog](https://github.com/blackm0re/pyotp2289/compare/1.2.0...1.2.1) 15
16## [1.2.1](https://codeberg.org/sgs/pyotp2289/releases/tag/v1.2.1) (2023-01-08)
17
18[Full Changelog](https://codeberg.org/sgs/pyotp2289/compare/v1.2.0...v1.2.1)
15 19
16**Changes:** 20**Changes:**
17 21
18- Include the unit tests in the sdist package 22- Include the unit tests in the sdist package
19 23
20 24
21## [1.2.0](https://github.com/blackm0re/pyotp2289/tree/1.2.0) (2023-01-05) 25## [1.2.0](https://codeberg.org/sgs/pyotp2289/releases/tag/v1.2.0) (2023-01-05)
22 26
23[Full Changelog](https://github.com/blackm0re/pyotp2289/compare/1.1.1...1.2.0) 27[Full Changelog](https://codeberg.org/sgs/pyotp2289/compare/v1.1.1...v1.2.0)
24 28
25**Changes:** 29**Changes:**
26 30
27- Add the `OTPState.ot_hex` property 31- Add the `OTPState.ot_hex` property
28 32
29 33
30## [1.1.1](https://github.com/blackm0re/pyotp2289/tree/1.1.1) (2022-04-02) 34## [1.1.1](https://codeberg.org/sgs/pyotp2289/releases/tag/v1.1.1) (2022-04-02)
31 35
32[Full Changelog](https://github.com/blackm0re/pyotp2289/compare/1.1.0...1.1.1) 36[Full Changelog](https://codeberg.org/sgs/pyotp2289/compare/v1.1.0...v1.1.1)
33 37
34**Changes:** 38**Changes:**
35 39
36- Reintroduce setup.py for better campatibility 40- Reintroduce setup.py for better campatibility
37 41
38 42
39## [1.1.0](https://github.com/blackm0re/pyotp2289/tree/1.1.0) (2022-03-29) 43## [1.1.0](https://codeberg.org/sgs/pyotp2289/releases/tag/v1.1.0) (2022-03-29)
40 44
41[Full Changelog](https://github.com/blackm0re/pyotp2289/compare/1.0.0...1.1.0) 45[Full Changelog](https://codeberg.org/sgs/pyotp2289/compare/v1.0.0...v1.1.0)
42 46
43**Changes:** 47**Changes:**
44 48
@@ -51,7 +55,7 @@
51- use *setuptools* instead of the deprecated *distutils* 55- use *setuptools* instead of the deprecated *distutils*
52 56
53 57
54# [1.0.0](https://github.com/blackm0re/pyotp2289/tree/1.0.0) (2020-04-07) 58# [1.0.0](https://codeberg.org/sgs/pyotp2289/releases/tag/v1.0.0) (2020-04-07)
55 59
56**Changes:** 60**Changes:**
57 61
diff --git a/README.md b/README.md
index 38fa9a7..ef100aa 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@ It requires no additional libraries.
10The main reason for writing this library was the need to login into my 10The main reason for writing this library was the need to login into my
11FreeBSD servers using [opiepasswd](https://en.wikipedia.org/wiki/OPIE_Authentication_System). 11FreeBSD servers using [opiepasswd](https://en.wikipedia.org/wiki/OPIE_Authentication_System).
12 12
13*opiepasswd* has since been removed from FreeBSD since version 14. 13*opiepasswd* has meanwhile been removed from FreeBSD ( >=14).
14 14
15I decided to license the library under the 15I decided to license the library under the
16[Simplified BSD License / 2-clause BSD license](https://codeberg.org/sgs/pyotp2289/src/branch/master/LICENSE) and not under the 16[Simplified BSD License / 2-clause BSD license](https://codeberg.org/sgs/pyotp2289/src/branch/master/LICENSE) and not under the
@@ -120,17 +120,16 @@ RFC-2289 consists of interactions between them.
120 # initialization digest (hash). The password 'This is a test.' will give you 120 # initialization digest (hash). The password 'This is a test.' will give you
121 # the same results as in the following example. 121 # the same results as in the following example.
122 passwd_bytes = getpass.getpass().encode() # Fetch the password as bytes 122 passwd_bytes = getpass.getpass().encode() # Fetch the password as bytes
123 generator = otp2289.generator.OTPGenerator(passwd_bytes, 123 generator = otp2289.OTPGenerator(passwd_bytes, 'TesT', otp2289.OTP_ALGO_MD5)
124 'TesT', 124 response = generator.generate_otp_response(500) # otp2289.OTPResponse
125 otp2289.OTP_ALGO_MD5) 125 digest = response.hexdigest
126 digest = generator.generate_otp_hexdigest(500)
127 # digest is now: 0x2b8d82b6ac14346c 126 # digest is now: 0x2b8d82b6ac14346c
128 # the client sends it to the server 127 # the client sends it to the server
129 128
130 # the server creates the first state. Note that step is decremented by 1: 129 # the server creates the first state. Note that step is decremented by 1:
131 state = otp2289.server.OTPState(digest, 499, 'TesT', otp2289.OTP_ALGO_MD5) 130 state = otp2289.OTPState(digest, 499, 'TesT', otp2289.OTP_ALGO_MD5)
132 # the state can be stored in a OTPStore container: 131 # the state can be stored in a OTPStore container:
133 store = otp2289.server.OTPStore() 132 store = otp2289.OTPStore()
134 # key can be any str that can be used to reference the state (f.i username) 133 # key can be any str that can be used to reference the state (f.i username)
135 store.add_state('myusername', state) # where key can be any str that can be 134 store.add_state('myusername', state) # where key can be any str that can be
136 # OTPStore is provided only for convenience as it is not part of RFC-2289. 135 # OTPStore is provided only for convenience as it is not part of RFC-2289.
@@ -145,12 +144,16 @@ RFC-2289 consists of interactions between them.
145 # created earlier. RFC-2289 defines two types of responses: 144 # created earlier. RFC-2289 defines two types of responses:
146 # - hex (like '0x2b8d82b6ac14346c') - more suited for automation 145 # - hex (like '0x2b8d82b6ac14346c') - more suited for automation
147 # - tokens consisting of 6 short words - better when responding manually 146 # - tokens consisting of 6 short words - better when responding manually
148 hex_response = generator.generate_otp_hexdigest(499) # '0x6323f96296a2526b' 147 #
149 token_response = generator.generate_otp_words(499) 148 # OTPResponse object can represent a response in both formats:
149 response = generator.generate_otp_response(499)
150 hex_response = response.hexdigest # '0x6323f96296a2526b'
151 token_response = response.words
150 # token_response is now: 'CANT JAW BITS NU LO PUP' 152 # token_response is now: 'CANT JAW BITS NU LO PUP'
151 # a possible shortcut may be to use the challenge-string directly: 153 # a possible shortcut may be to use the challenge-string directly:
152 hex_response = generator.generate_otp_hexdigest_from_challenge(challenge) 154 response = generator.generate_otp_response_from_challenge(challenge)
153 token_response = generator.generate_otp_words_from_challenge(challenge) 155 hex_response = response.hexdigest
156 token_response = response.words
154 # ... giving the same results. 157 # ... giving the same results.
155 158
156 # once the response is received, the server validates it by yet again using 159 # once the response is received, the server validates it by yet again using
@@ -158,9 +161,11 @@ RFC-2289 consists of interactions between them.
158 result = state.response_validates(hex_response) 161 result = state.response_validates(hex_response)
159 # or 162 # or
160 result = state.response_validates(token_response) 163 result = state.response_validates(token_response)
164 # or using the OTPResponse object directly
165 result = state.response_validates(response)
161 # result should be True if the response matches the state, False if not 166 # result should be True if the response matches the state, False if not
162 # in case of invalid response or response checksum doesn't match, a 167 # in case of invalid response or response checksum doesn't match, a
163 # otp2289.server.OTPInvalidResponse exception is raised. 168 # otp2289.OTPInvalidResponse exception is raised.
164 169
165 # once the state has successfully validated the corresponding response, 170 # once the state has successfully validated the corresponding response,
166 # the state **must never be used again** and a state corresponding to the 171 # the state **must never be used again** and a state corresponding to the
@@ -170,7 +175,7 @@ RFC-2289 consists of interactions between them.
170 # the next authentication attempt... 175 # the next authentication attempt...
171 challenge = state.challenge_string # challenge is now 'otp-md5 498 TesT ' 176 challenge = state.challenge_string # challenge is now 'otp-md5 498 TesT '
172 # ... and on the client side... 177 # ... and on the client side...
173 hex_response = generator.generate_otp_hexdigest_from_challenge(challenge) 178 response = generator.generate_otp_response_from_challenge(challenge)
174 # etc. etc... 179 # etc. etc...
175 ``` 180 ```
176 181
diff --git a/src/otp2289/__init__.py b/src/otp2289/__init__.py
index 8f8cf24..da65d9e 100644
--- a/src/otp2289/__init__.py
+++ b/src/otp2289/__init__.py
@@ -31,6 +31,7 @@ from .generator import (
31 OTPGenerator, 31 OTPGenerator,
32 OTPGeneratorError, 32 OTPGeneratorError,
33 OTPResponse, 33 OTPResponse,
34 OTPResponseError,
34) 35)
35from .server import ( 36from .server import (
36 OTPInvalidResponseError, 37 OTPInvalidResponseError,
@@ -41,7 +42,7 @@ from .server import (
41) 42)
42 43
43__author__ = 'Simeon Simeonov' 44__author__ = 'Simeon Simeonov'
44__version__ = '2.0.0a' 45__version__ = '2.0.0'
45__license__ = 'BSD 2-Clause' 46__license__ = 'BSD 2-Clause'
46 47
47 48
@@ -63,6 +64,7 @@ __all__ = [
63 'OTPGeneratorError', 64 'OTPGeneratorError',
64 'OTPInvalidResponseError', 65 'OTPInvalidResponseError',
65 'OTPResponse', 66 'OTPResponse',
67 'OTPResponseError',
66 'OTPState', 68 'OTPState',
67 'OTPStateError', 69 'OTPStateError',
68 'OTPStore', 70 'OTPStore',
diff --git a/src/otp2289/__main__.py b/src/otp2289/__main__.py
index 0fdcc52..c5d28b6 100644
--- a/src/otp2289/__main__.py
+++ b/src/otp2289/__main__.py
@@ -51,6 +51,56 @@ def eprint(
51 print(*value, sep=sep, end=end, file=sys.stderr, flush=True) 51 print(*value, sep=sep, end=end, file=sys.stderr, flush=True)
52 52
53 53
54def get_rnd_seed() -> str:
55 """
56 Returns a random seed in the format:
57
58 2 random letters (capitalize()) + 5 random digits
59 """
60 rnd = secrets.SystemRandom()
61 return ''.join(
62 rnd.choices(string.ascii_lowercase, k=2)
63 ).capitalize() + ''.join(rnd.choices(string.digits, k=5))
64
65
66def initiate_new_sequence(args: argparse.Namespace) -> str:
67 """
68 Generates a new sequence based on the parameters sent from the parser.
69
70 :param args: The arguments assigned from argparse
71 :type args: argparse.Namespace
72
73 :raises otp2289.OTPChallengeError: If the challenge is invalid
74
75 :raises otp2289.OTPGeneratorError: If generator parameters are wrong
76
77 :raises otp2289.OTPResponseError: If tokens or hex can not be generated
78
79 :return: The response string
80 :rtype: str
81 """
82 if not args.seed:
83 args.seed = get_rnd_seed()
84
85 header = ''
86 if not args.quiet:
87 header = (
88 f'Seed: {args.seed}, Step: {args.step}, '
89 f'Hash: {args.hash_algo}{os.linesep}'
90 )
91 generator = otp2289.OTPGenerator(
92 args.password.encode(), args.seed, args.hash_algo
93 )
94 if args.challenge_string:
95 return (
96 header
97 + generator.generate_otp_response_from_challenge(
98 args.challenge_string
99 ).hexdigest
100 )
101 return header + generator.generate_otp_response(args.step).hexdigest
102
103
54def generate_otp_response(args: argparse.Namespace) -> str: 104def generate_otp_response(args: argparse.Namespace) -> str:
55 """ 105 """
56 Generates a response based on the parameters sent from the parser 106 Generates a response based on the parameters sent from the parser
@@ -62,20 +112,22 @@ def generate_otp_response(args: argparse.Namespace) -> str:
62 112
63 :raises otp2289.OTPGeneratorError: If generator parameters are wrong 113 :raises otp2289.OTPGeneratorError: If generator parameters are wrong
64 114
115 :raises otp2289.OTPResponseError: If tokens or hex can not be generated
116
65 :return: The response string 117 :return: The response string
66 :rtype: str 118 :rtype: str
67 """ 119 """
68 generator = otp2289.generator.OTPGenerator( 120 generator = otp2289.OTPGenerator(
69 args.password.encode(), args.seed, args.hash_algo 121 args.password.encode(), args.seed, args.hash_algo
70 ) 122 )
71 if args.challenge_string: 123 if args.challenge_string:
72 if args.output_format == 'token': 124 response = generator.generate_otp_response_from_challenge(
73 return generator.generate_otp_words_from_challenge( 125 args.challange_string
74 args.challenge_string
75 )
76 return generator.generate_otp_hexdigest_from_challenge(
77 args.challenge_string
78 ) 126 )
127 if args.output_format == 'token':
128 return response.words
129 return response.hexdigest
130
79 # regular parameters 131 # regular parameters
80 header = '' 132 header = ''
81 if not args.quiet: 133 if not args.quiet:
@@ -83,9 +135,12 @@ def generate_otp_response(args: argparse.Namespace) -> str:
83 f'Seed: {args.seed}, Step: {args.step}, ' 135 f'Seed: {args.seed}, Step: {args.step}, '
84 f'Hash: {args.hash_algo}{os.linesep}' 136 f'Hash: {args.hash_algo}{os.linesep}'
85 ) 137 )
138
139 response = generator.generate_otp_response(args.step)
140
86 if args.output_format == 'token': 141 if args.output_format == 'token':
87 return header + generator.generate_otp_words(args.step) 142 return header + response.words
88 return header + generator.generate_otp_hexdigest(args.step) 143 return header + response.hexdigest
89 144
90 145
91def generate_otp_range(args: argparse.Namespace) -> str: 146def generate_otp_range(args: argparse.Namespace) -> str:
@@ -99,20 +154,24 @@ def generate_otp_range(args: argparse.Namespace) -> str:
99 154
100 :raises otp2289.OTPGeneratorError: If generator parameters are wrong 155 :raises otp2289.OTPGeneratorError: If generator parameters are wrong
101 156
157 :raises otp2289.OTPResponseError: If tokens or hex can not be generated
158
102 :return: The responses string 159 :return: The responses string
103 :rtype: str 160 :rtype: str
104 """ 161 """
105 generator = otp2289.generator.OTPGenerator( 162 generator = otp2289.OTPGenerator(
106 args.password.encode(), args.seed, args.hash_algo 163 args.password.encode(), args.seed, args.hash_algo
107 ) 164 )
108 if args.output_format == 'token':
109 method = generator.generate_otp_words
110 else:
111 method = generator.generate_otp_hexdigest
112 165
113 # handle most cases explicitly 166 # handle most cases explicitly
114 if args.range == 1: 167 if args.range == 1:
115 return f'{args.step}: ' + method(args.step) 168 response = generator.generate_otp_response(args.step)
169 if args.output_format == 'token':
170 response_str = response.words
171 else:
172 response_str = response.hexdigest
173 return f'{args.step}: ' + response_str
174
116 args.range = min(args.range, args.step + 1) 175 args.range = min(args.range, args.step + 1)
117 176
118 # any need for quiet? 177 # any need for quiet?
@@ -122,9 +181,19 @@ def generate_otp_range(args: argparse.Namespace) -> str:
122 f'Seed: {args.seed}, Step: {args.step}, ' 181 f'Seed: {args.seed}, Step: {args.step}, '
123 f'Hash: {args.hash_algo}, Range: {args.range}{os.linesep}' 182 f'Hash: {args.hash_algo}, Range: {args.range}{os.linesep}'
124 ) 183 )
184
185 if args.output_format == 'token':
186 return header + os.linesep.join(
187 [
188 f'{step}: ' + generator.generate_otp_response(step).words
189 for step in range(args.step, args.step - args.range, -1)
190 ]
191 )
192
193 # assume hex
125 return header + os.linesep.join( 194 return header + os.linesep.join(
126 [ 195 [
127 f'{step}: ' + method(step) 196 f'{step}: ' + generator.generate_otp_response(step).hexdigest
128 for step in range(args.step, args.step - args.range, -1) 197 for step in range(args.step, args.step - args.range, -1)
129 ] 198 ]
130 ) 199 )
@@ -172,50 +241,6 @@ def get_password(args: argparse.Namespace) -> str:
172 return args.password 241 return args.password
173 242
174 243
175def get_rnd_seed() -> str:
176 """
177 Returns a random seed in the format:
178
179 2 random letters (capitalize()) + 5 random digits
180 """
181 rnd = secrets.SystemRandom()
182 return ''.join(
183 rnd.choices(string.ascii_lowercase, k=2)
184 ).capitalize() + ''.join(rnd.choices(string.digits, k=5))
185
186
187def initiate_new_sequence(args: argparse.Namespace) -> str:
188 """
189 Generates a new sequence based on the parameters sent from the parser.
190
191 :param args: The arguments assigned from argparse
192 :type args: argparse.Namespace
193
194 :raises otp2289.OTPChallengeError: If the challenge is invalid
195
196 :raises otp2289.OTPGeneratorError: If generator parameters are wrong
197
198 :return: The response string
199 :rtype: str
200 """
201 if not args.seed:
202 args.seed = get_rnd_seed()
203 header = ''
204 if not args.quiet:
205 header = (
206 f'Seed: {args.seed}, Step: {args.step}, '
207 f'Hash: {args.hash_algo}{os.linesep}'
208 )
209 generator = otp2289.generator.OTPGenerator(
210 args.password.encode(), args.seed, args.hash_algo
211 )
212 if args.challenge_string:
213 return header + generator.generate_otp_hexdigest_from_challenge(
214 args.challenge_string
215 )
216 return header + generator.generate_otp_hexdigest(args.step)
217
218
219def main(inargs: list[str] | None = None) -> None: 244def main(inargs: list[str] | None = None) -> None:
220 """the main entry point""" 245 """the main entry point"""
221 parser = argparse.ArgumentParser( 246 parser = argparse.ArgumentParser(
@@ -278,8 +303,8 @@ def main(inargs: list[str] | None = None) -> None:
278 parser.add_argument( 303 parser.add_argument(
279 '-a', 304 '-a',
280 '--hash-algorithm', 305 '--hash-algorithm',
281 metavar='<md5 | sha1>',
282 type=str, 306 type=str,
307 choices=['md5', 'sha1'],
283 dest='hash_algo', 308 dest='hash_algo',
284 default='md5', 309 default='md5',
285 help='The hash algorithm to use. Possible values: md5 (default), sha1', 310 help='The hash algorithm to use. Possible values: md5 (default), sha1',
@@ -296,8 +321,8 @@ def main(inargs: list[str] | None = None) -> None:
296 parser.add_argument( 321 parser.add_argument(
297 '-f', 322 '-f',
298 '--output-format', 323 '--output-format',
299 metavar='<hex | token>',
300 type=str, 324 type=str,
325 choices=['hex', 'token'],
301 dest='output_format', 326 dest='output_format',
302 default='hex', 327 default='hex',
303 help='The output format to use. Possible values: hex (default), token', 328 help='The output format to use. Possible values: hex (default), token',
@@ -368,9 +393,9 @@ def main(inargs: list[str] | None = None) -> None:
368 if args.generate_otp_response: 393 if args.generate_otp_response:
369 print(generate_otp_response(args)) 394 print(generate_otp_response(args))
370 sys.exit(0) 395 sys.exit(0)
371 except otp2289.generator.OTPGeneratorError as exp: 396 except otp2289.OTPGeneratorError as exp:
372 eprint(f'GeneratorException: {exp}') 397 eprint(f'GeneratorException: {exp}')
373 except otp2289.generator.OTPChallengeError as exp: 398 except otp2289.OTPChallengeError as exp:
374 eprint(f'ChallengeException: {exp}') 399 eprint(f'ChallengeException: {exp}')
375 except Exception as exp: 400 except Exception as exp:
376 eprint(f'Unknown error: {exp}') 401 eprint(f'Unknown error: {exp}')
diff --git a/src/otp2289/generator.py b/src/otp2289/generator.py
index 32123b4..17024a2 100644
--- a/src/otp2289/generator.py
+++ b/src/otp2289/generator.py
@@ -24,16 +24,21 @@
24# THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24# THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
25"""A pure Python implementation of the RFC-2289 OTP generator""" 25"""A pure Python implementation of the RFC-2289 OTP generator"""
26 26
27from __future__ import annotations
28
27import hashlib 29import hashlib
28import string 30import string
29import typing 31import typing
30from collections.abc import Iterator 32
33if typing.TYPE_CHECKING:
34 from collections.abc import Iterator
31 35
32OTP_ALGO_MD5: typing.Final[int] = 1 36OTP_ALGO_MD5: typing.Final[int] = 1
33OTP_ALGO_SHA1: typing.Final[int] = 2 37OTP_ALGO_SHA1: typing.Final[int] = 2
34 38
35# useful constants 39# useful constants
36OTP2289_BITSTREAM_SIZE: typing.Final[int] = 64 40OTP2289_BITSTREAM_SIZE: typing.Final[int] = 64
41OTP2289_HEX_DIGEST_SIZE: typing.Final[int] = 16
37OTP2289_MAX_SEED_LENGTH: typing.Final[int] = 16 42OTP2289_MAX_SEED_LENGTH: typing.Final[int] = 16
38OTP2289_MIN_PASSWORD_LENGTH: typing.Final[int] = 10 43OTP2289_MIN_PASSWORD_LENGTH: typing.Final[int] = 10
39OTP2289_SHA1_DIGEST_SIZE: typing.Final[int] = 20 44OTP2289_SHA1_DIGEST_SIZE: typing.Final[int] = 20
@@ -2094,12 +2099,16 @@ RFC1760_TOKENS = [
2094_ALGO_DICT = {OTP_ALGO_MD5: 'md5', OTP_ALGO_SHA1: 'sha1'} 2099_ALGO_DICT = {OTP_ALGO_MD5: 'md5', OTP_ALGO_SHA1: 'sha1'}
2095 2100
2096 2101
2102class OTPChallengeError(Exception):
2103 """OTPChallengeError class"""
2104
2105
2097class OTPGeneratorError(Exception): 2106class OTPGeneratorError(Exception):
2098 """OTPGeneratorError class""" 2107 """OTPGeneratorError class"""
2099 2108
2100 2109
2101class OTPChallengeError(Exception): 2110class OTPResponseError(Exception):
2102 """OTPChallengeError class""" 2111 """OTPResponseError class"""
2103 2112
2104 2113
2105class OTPResponse: 2114class OTPResponse:
@@ -2120,10 +2129,24 @@ class OTPResponse:
2120 """bytes representation of the object""" 2129 """bytes representation of the object"""
2121 return self._response_bytes 2130 return self._response_bytes
2122 2131
2132 def __eq__(self, value: object, /) -> bool:
2133 """definition for type equality"""
2134 if not isinstance(value, OTPResponse):
2135 return False
2136
2137 return self._response_bytes == value.response_bytes
2138
2123 def __hash__(self) -> int: 2139 def __hash__(self) -> int:
2124 """Uses the hash value of _response_bytes""" 2140 """Uses the hash value of _response_bytes"""
2125 return hash(self._response_bytes) 2141 return hash(self._response_bytes)
2126 2142
2143 def __repr__(self) -> str:
2144 """repr implementation"""
2145 return (
2146 f'{self.__class__} at {id(self)} '
2147 f'(response_bytes={self._response_bytes!r})'
2148 )
2149
2127 @property 2150 @property
2128 def hexdigest(self) -> str: 2151 def hexdigest(self) -> str:
2129 """Hexdigest representation of the OTP response""" 2152 """Hexdigest representation of the OTP response"""
@@ -2139,6 +2162,59 @@ class OTPResponse:
2139 """Tokens representation of the OTP response""" 2162 """Tokens representation of the OTP response"""
2140 return self._words 2163 return self._words
2141 2164
2165 @classmethod
2166 def from_hex(cls, ot_hex: str) -> OTPResponse:
2167 """
2168 Generates instance from the provided hexidigest with or without
2169 leading 0x as specified by RFC-2289.
2170
2171 :param ot_hex: The one-time hex to validate
2172 :type ot_hex: str
2173
2174 :raises otp2289.OTPResponseError: When the ot_hex is invalid
2175
2176 :return: A new OTPResponse object
2177 :rtype: otp2289.OTPResponse
2178 """
2179 return cls(OTPResponse.hex_to_bytes(ot_hex))
2180
2181 @classmethod
2182 def from_tokens(cls, tokens_str: str) -> OTPResponse:
2183 """
2184 Generates instance from a 6 words token as specified by RFC-2289.
2185
2186 :param tokens_str: String representing 6 words tokens
2187 :type tokens_str: str
2188
2189 :raises otp2289.OTPResponseError: When the tokens_str is invalid
2190
2191 :return: A new OTPResponse object
2192 :rtype: otp2289.OTPResponse
2193 """
2194 return cls(OTPResponse.tokens_to_bytes(tokens_str))
2195
2196 @staticmethod
2197 def bit_pair_sum(bit_stream: str) -> int:
2198 """
2199 Split bit_stream in bit-pairs and sum them all together.
2200
2201 :param bit_stream: The bit-stream object
2202 :type bit_stream: str
2203
2204 :return: The sum of all bit-pairs in bit_stream
2205 :rtype: int
2206 """
2207 if not isinstance(bit_stream, str):
2208 raise OTPResponseError('bit_stream must be of type str')
2209 if len(bit_stream) != OTP2289_BITSTREAM_SIZE:
2210 raise OTPResponseError(
2211 f'bit_stream must be of size {OTP2289_BITSTREAM_SIZE}'
2212 )
2213 value = 0
2214 for pair in zip(bit_stream[::2], bit_stream[1::2], strict=True):
2215 value += int(''.join(pair), 2)
2216 return value
2217
2142 @staticmethod 2218 @staticmethod
2143 def bytes_to_tokens(hash_bytes: bytes) -> str: 2219 def bytes_to_tokens(hash_bytes: bytes) -> str:
2144 """ 2220 """
@@ -2151,7 +2227,7 @@ class OTPResponse:
2151 :rtype: str 2227 :rtype: str
2152 """ 2228 """
2153 bit_stream = ''.join([f'{byte:0>8b}' for byte in hash_bytes]) 2229 bit_stream = ''.join([f'{byte:0>8b}' for byte in hash_bytes])
2154 bit_pair_sum = OTPGenerator.bit_pair_sum(bit_stream) 2230 bit_pair_sum = OTPResponse.bit_pair_sum(bit_stream)
2155 tokens = [] 2231 tokens = []
2156 tokens.append(RFC1760_TOKENS[int(bit_stream[:11], 2)]) 2232 tokens.append(RFC1760_TOKENS[int(bit_stream[:11], 2)])
2157 tokens.append(RFC1760_TOKENS[int(bit_stream[11:22], 2)]) 2233 tokens.append(RFC1760_TOKENS[int(bit_stream[11:22], 2)])
@@ -2165,6 +2241,81 @@ class OTPResponse:
2165 ) 2241 )
2166 return ' '.join(tokens) 2242 return ' '.join(tokens)
2167 2243
2244 @staticmethod
2245 def hex_to_bytes(ot_hex: str) -> bytes:
2246 """
2247 Returns bytes from the provided hexidigest.
2248
2249 :param ot_hex: The one-time hex to validate
2250 :type ot_hex: str
2251
2252 :raises otp2289.OTPResponseError: If hex does not validate
2253
2254 :return: The validated hex (without leading 0x) converted to bytes
2255 :rtype: bytes
2256 """
2257 if not isinstance(ot_hex, str):
2258 raise OTPResponseError('OT-hex must be a str')
2259 if ot_hex.startswith('0x'):
2260 ot_hex = ot_hex[2:]
2261 ot_hex = ot_hex.strip().lower()
2262 if len(ot_hex) != OTP2289_HEX_DIGEST_SIZE:
2263 raise OTPResponseError(
2264 f'The length of the hex should be {OTP2289_HEX_DIGEST_SIZE} '
2265 '(representing 64 bits digest)'
2266 )
2267 try:
2268 return bytes.fromhex(ot_hex)
2269 except ValueError:
2270 raise OTPResponseError('Invalid OT-hex') from None
2271
2272 @staticmethod
2273 def tokens_to_bytes(tokens_str: str) -> bytes:
2274 """
2275 Returns bytes from a 6 words token as specified by RFC-2289.
2276
2277 :param tokens_str: String representing 6 words tokens
2278 :type tokens_str: str
2279
2280 :raises otp2289.OTPResponseError: When the tokens_str is invalid
2281
2282 :return: 6 words tokens
2283 :rtype: bytes
2284 """
2285 if not isinstance(tokens_str, str):
2286 raise OTPResponseError('tokens must be a str')
2287 tokens = tokens_str.split()
2288 if len(tokens) != OTP2289_TOKENS_COUNT:
2289 raise OTPResponseError(
2290 f'Tokens-string does not contain {OTP2289_TOKENS_COUNT} tokens'
2291 )
2292 token_ints = []
2293 try:
2294 token_ints = [
2295 RFC1760_TOKENS.index(token.upper()) for token in tokens
2296 ]
2297 except ValueError:
2298 raise OTPResponseError(
2299 'One or more words not present in RFC1760'
2300 ) from None
2301 # now we build a string of bits
2302 bit_stream = format(token_ints[0], '011b')
2303 bit_stream += format(token_ints[1], '011b')
2304 bit_stream += format(token_ints[2], '011b')
2305 bit_stream += format(token_ints[3], '011b')
2306 bit_stream += format(token_ints[4], '011b')
2307 bit_stream += format(token_ints[5], '011b')
2308 # we have 66 bits: 64 digest + 2 bit pair sum (control number)
2309 # RFC-2289: All OTP generators MUST calculate this checksum and all
2310 # OTP servers MUST verify this checksum explicitly as part of the
2311 # operation of decoding this representation of the one-time password.
2312 if (
2313 f'{OTPResponse.bit_pair_sum(bit_stream[:64]):0>8b}'[-2:]
2314 != bit_stream[-2:]
2315 ):
2316 raise OTPResponseError('Invalid bit checksum')
2317 return int(bit_stream[:64], 2).to_bytes(8, 'big')
2318
2168 2319
2169class OTPGenerator: 2320class OTPGenerator:
2170 """OTPGenerator class""" 2321 """OTPGenerator class"""
@@ -2211,28 +2362,6 @@ class OTPGenerator:
2211 ) 2362 )
2212 2363
2213 @staticmethod 2364 @staticmethod
2214 def bit_pair_sum(bit_stream: str) -> int:
2215 """
2216 Split bit_stream in bit-pairs and sum them all together.
2217
2218 :param bit_stream: The bit-stream object
2219 :type bit_stream: str
2220
2221 :return: The sum of all bit-pairs in bit_stream
2222 :rtype: int
2223 """
2224 if not isinstance(bit_stream, str):
2225 raise OTPGeneratorError('bit_stream must be of type str')
2226 if len(bit_stream) != OTP2289_BITSTREAM_SIZE:
2227 raise OTPGeneratorError(
2228 f'bit_stream must be of size {OTP2289_BITSTREAM_SIZE}'
2229 )
2230 value = 0
2231 for pair in zip(bit_stream[::2], bit_stream[1::2], strict=True):
2232 value += int(''.join(pair), 2)
2233 return value
2234
2235 @staticmethod
2236 def get_tokens_from_challenge(challenge: str) -> tuple[str, str, int]: 2365 def get_tokens_from_challenge(challenge: str) -> tuple[str, str, int]:
2237 """ 2366 """
2238 Returns tokens (seed, hash_algo and step) from a challenge string. 2367 Returns tokens (seed, hash_algo and step) from a challenge string.
@@ -2335,54 +2464,6 @@ class OTPGenerator:
2335 return bytes([byte_str1[i] ^ byte_str2[i] for i in range(length)]) 2464 return bytes([byte_str1[i] ^ byte_str2[i] for i in range(length)])
2336 2465
2337 @staticmethod 2466 @staticmethod
2338 def tokens_to_bytes(tokens_str: str) -> bytes:
2339 """
2340 Returns bytes from a 6 words token as specified by RFC-2289.
2341
2342 :param tokens_str: String representing 6 words tokens
2343 :type tokens_str: str
2344
2345 :raises otp2289.OTPGeneratorError: When the tokens_str is invalid
2346
2347 :return: 6 words tokens
2348 :rtype: bytes
2349 """
2350 if not isinstance(tokens_str, str):
2351 raise OTPGeneratorError('tokens must be a str')
2352 tokens = tokens_str.split()
2353 if len(tokens) != OTP2289_TOKENS_COUNT:
2354 raise OTPGeneratorError(
2355 f'Tokens-string does not contain {OTP2289_SHA1_DIGEST_SIZE} '
2356 'tokens'
2357 )
2358 token_ints = []
2359 try:
2360 token_ints = [
2361 RFC1760_TOKENS.index(token.upper()) for token in tokens
2362 ]
2363 except ValueError:
2364 raise OTPGeneratorError(
2365 'One or more words not present in RFC1760'
2366 ) from None
2367 # now we build a string of bits
2368 bit_stream = format(token_ints[0], '011b')
2369 bit_stream += format(token_ints[1], '011b')
2370 bit_stream += format(token_ints[2], '011b')
2371 bit_stream += format(token_ints[3], '011b')
2372 bit_stream += format(token_ints[4], '011b')
2373 bit_stream += format(token_ints[5], '011b')
2374 # we have 66 bits: 64 digest + 2 bit pair sum (control number)
2375 # RFC-2289: All OTP generators MUST calculate this checksum and all
2376 # OTP servers MUST verify this checksum explicitly as part of the
2377 # operation of decoding this representation of the one-time password.
2378 if (
2379 f'{OTPGenerator.bit_pair_sum(bit_stream[:64]):0>8b}'[-2:]
2380 != bit_stream[-2:]
2381 ):
2382 raise OTPGeneratorError('Invalid bit checksum')
2383 return int(bit_stream[:64], 2).to_bytes(8, 'big')
2384
2385 @staticmethod
2386 def validate_hash_algo(hash_algo: int | str) -> str: 2467 def validate_hash_algo(hash_algo: int | str) -> str:
2387 """ 2468 """
2388 Validates the provided hash-algorithm. 2469 Validates the provided hash-algorithm.
@@ -2456,56 +2537,24 @@ class OTPGenerator:
2456 raise OTPGeneratorError('Step value MUST be >= 0') 2537 raise OTPGeneratorError('Step value MUST be >= 0')
2457 return step 2538 return step
2458 2539
2459 def generate_otp_hexdigest(self, step: int) -> str: 2540 def generate_otp_response(self, step: int) -> OTPResponse:
2460 """ 2541 """
2461 Generates the OTP hexdigest for the given step. 2542 Generates OTPResponse instance for the given step
2462 2543
2463 :param step: The step to generate OTP for 2544 :param step: The step to generate OTP for
2464 :type step: int 2545 :type step: int
2465 2546
2466 :return: Hexdigest for the given step 2547 :return: OTPResponse instance for the given step
2467 :rtype: str 2548 :rtype: OTPResponse
2468 """ 2549 """
2469 response = OTPResponse(self._generate_otp_bytes(step)) 2550 return OTPResponse(self._generate_otp_bytes(step))
2470 return response.hexdigest
2471 2551
2472 def generate_otp_hexdigest_from_challenge(self, challenge: str) -> str: 2552 def generate_otp_response_from_challenge(
2553 self, challenge: str
2554 ) -> OTPResponse:
2473 """ 2555 """
2474 Same as generate_otp_hexdigest, but it generates hex. from a challenge. 2556 Same as generate_otp_response,
2475 2557 but it generates OTPResponse from a challenge.
2476 RFC-2289 states:
2477 The challenge MUST be in a standard syntax so
2478 that automated generators can recognize the challenge in context and
2479 extract these parameters. The syntax of the challenge is:
2480 otp-<algorithm identifier> <sequence integer> <seed>
2481
2482 :param challenge: The challenge string
2483 :type challenge: str
2484
2485 :return: Hexdigest for the given challenge
2486 :rtype: str
2487 """
2488 seed, hash_algo, step = self.get_tokens_from_challenge(challenge)
2489 self._seed = self.validate_seed(seed)
2490 self._hash_algo = self.validate_hash_algo(hash_algo)
2491 return self.generate_otp_hexdigest(step)
2492
2493 def generate_otp_words(self, step: int) -> str:
2494 """
2495 Generates the OTP six words token for the given step.
2496
2497 :param step: The step to generate OTP for
2498 :type step: int
2499
2500 :return: Six words (separated by single space) token for the given step
2501 :rtype: str
2502 """
2503 response = OTPResponse(self._generate_otp_bytes(step))
2504 return response.words
2505
2506 def generate_otp_words_from_challenge(self, challenge: str) -> str:
2507 """
2508 Same as generate_otp_words, but it generates words from a challenge.
2509 2558
2510 RFC-2289 states: 2559 RFC-2289 states:
2511 The challenge MUST be in a standard syntax so 2560 The challenge MUST be in a standard syntax so
@@ -2522,35 +2571,14 @@ class OTPGenerator:
2522 seed, hash_algo, step = self.get_tokens_from_challenge(challenge) 2571 seed, hash_algo, step = self.get_tokens_from_challenge(challenge)
2523 self._seed = self.validate_seed(seed) 2572 self._seed = self.validate_seed(seed)
2524 self._hash_algo = self.validate_hash_algo(hash_algo) 2573 self._hash_algo = self.validate_hash_algo(hash_algo)
2525 return self.generate_otp_words(step) 2574 return self.generate_otp_response(step)
2526 2575
2527 def hexdigest_range( 2576 def otp_response_range(
2528 self, start: int = 499, stop: int = 0 2577 self, start: int = 499, stop: int = 0
2529 ) -> Iterator[str]: 2578 ) -> Iterator[OTPResponse]:
2530 """
2531 Returns an iterator that providing hexdigests corresponding to steps
2532 from `start` to and including `stop`.
2533
2534 :param start: The start of the range (default: 499)
2535 :type start: int
2536
2537 :param stop: The last step (default: 0)
2538 :type stop: int
2539
2540 :return: Iterator
2541 :rtype: generator
2542 """
2543 if not isinstance(start, int) and isinstance(stop, int):
2544 raise OTPGeneratorError('Step value MUST be an int')
2545 if start < stop:
2546 raise OTPGeneratorError('Start value can not be lower than stop')
2547 for step in range(start, stop - 1, -1):
2548 yield self.generate_otp_hexdigest(step)
2549
2550 def words_range(self, start: int = 499, stop: int = 0) -> Iterator[str]:
2551 """ 2579 """
2552 Returns an iterator that providing the words corresponding to steps 2580 Returns an iterator that is providing OTPResponse instances
2553 from `start` to and including `stop`. 2581 corresponding to steps from `start` to and including `stop`
2554 2582
2555 :param start: The start of the range (default: 499) 2583 :param start: The start of the range (default: 499)
2556 :type start: int 2584 :type start: int
@@ -2566,7 +2594,7 @@ class OTPGenerator:
2566 if start < stop: 2594 if start < stop:
2567 raise OTPGeneratorError('Start value can not be lower than stop') 2595 raise OTPGeneratorError('Start value can not be lower than stop')
2568 for step in range(start, stop - 1, -1): 2596 for step in range(start, stop - 1, -1):
2569 yield self.generate_otp_words(step) 2597 yield self.generate_otp_response(step)
2570 2598
2571 def _generate_otp_bytes(self, step: int) -> bytes: 2599 def _generate_otp_bytes(self, step: int) -> bytes:
2572 """ 2600 """
diff --git a/src/otp2289/server.py b/src/otp2289/server.py
index 99ee460..62e6205 100644
--- a/src/otp2289/server.py
+++ b/src/otp2289/server.py
@@ -32,9 +32,13 @@ import typing
32if typing.TYPE_CHECKING: 32if typing.TYPE_CHECKING:
33 from collections.abc import Iterator 33 from collections.abc import Iterator
34 34
35from .generator import OTP_ALGO_MD5, OTPGenerator, OTPGeneratorError 35from .generator import (
36 36 OTP_ALGO_MD5,
37OTP2289_HEX_DIGEST_SIZE: typing.Final[int] = 16 37 OTPGenerator,
38 OTPGeneratorError,
39 OTPResponse,
40 OTPResponseError,
41)
38 42
39 43
40class OTPStateError(Exception): 44class OTPStateError(Exception):
@@ -89,12 +93,15 @@ class OTPState:
89 self._seed = OTPGenerator.validate_seed(seed) 93 self._seed = OTPGenerator.validate_seed(seed)
90 self._hash_algo = OTPGenerator.validate_hash_algo(hash_algo) 94 self._hash_algo = OTPGenerator.validate_hash_algo(hash_algo)
91 self._step = OTPGenerator.validate_step(current_step) 95 self._step = OTPGenerator.validate_step(current_step)
92 except OTPGeneratorError as exp: 96 except OTPGeneratorError as err:
93 raise OTPStateError(exp.args[0]) from exp 97 raise OTPStateError(err.args[0]) from err
94 98
95 self._current_digest = None 99 self._current_digest = None
96 if ot_hex is not None: 100 if ot_hex is not None:
97 self._current_digest = self.validate_hex(ot_hex) 101 try:
102 self._current_digest = OTPResponse.hex_to_bytes(ot_hex)
103 except OTPResponseError as err:
104 raise OTPStateError(err.args[0]) from err
98 self._new_digest_hex = None # set upon a successful validation 105 self._new_digest_hex = None # set upon a successful validation
99 106
100 def __repr__(self) -> str: 107 def __repr__(self) -> str:
@@ -154,70 +161,43 @@ class OTPState:
154 :type dict_obj: dict 161 :type dict_obj: dict
155 162
156 :return: A new OTPState object 163 :return: A new OTPState object
157 :rtype: otp2289.OTPStore 164 :rtype: otp2289.OTPState
158 """ 165 """
159 return cls(**dict_obj) 166 return cls(**dict_obj)
160 167
161 @staticmethod 168 @staticmethod
162 def response_to_bytes(response: str) -> bytes: 169 def response_string_to_otp_response(response_str: str) -> OTPResponse:
163 """ 170 """
164 A wrapper that handles/validates the response as specified by RFC-2289. 171 A wrapper that handles/validates the response as specified by RFC-2289.
165 172
166 The method first checks if response is a token and tries to convert 173 The method first checks if the response string is a token and tries to
167 it to bytes. If that fails, the method assumes that response is a hex. 174 convert it to a OTPResponse instance. If that fails, the method assumes
168 If neither of those attempts succeeds OTPInvalidResponseError is raised 175 that response string is a hex. If neither of those attempts succeeds
169 It is up to the caller to run another iteration and compare the result 176 OTPInvalidResponseError is raised. It is up to the caller to run
170 to an existing digest in this state. 177 another iteration and compare the result to an existing digest in
178 this state.
171 179
172 :param response: The response to this state (its challenge) 180 :param response_str: The response string to this state (its challenge)
173 :type response: str 181 :type response_str: str
174 182
175 :raises otp2289.OTPInvalidResponseError: If the response is 183 :raises otp2289.OTPInvalidResponseError: If the response is
176 corrupt/illegal, but not if it 184 corrupt/illegal, but not if it
177 simply does not validate 185 simply does not validate
178 186
179 :return: The bytes representation of response (if any) 187 :return: OTPResponse instance
180 :rtype: bytes 188 :rtype: otp2289.OTPResponse
181 """ 189 """
182 try: 190 try:
183 return OTPGenerator.tokens_to_bytes(response) 191 return OTPResponse.from_tokens(response_str)
184 except OTPGeneratorError: 192 except OTPResponseError:
185 # now assume hex... 193 # now assume hex...
186 try: 194 try:
187 return OTPState.validate_hex(response) 195 return OTPResponse.from_hex(response_str)
188 except OTPStateError: 196 except OTPResponseError:
189 raise OTPInvalidResponseError( 197 raise OTPInvalidResponseError(
190 'The response is neither a valid token or hex' 198 'The response is neither a valid token or hex'
191 ) from None 199 ) from None
192 200
193 @staticmethod
194 def validate_hex(ot_hex: str) -> bytes:
195 """
196 Validates the provided hexidigest.
197
198 :param ot_hex: The one-time hex to validate
199 :type ot_hex: str
200
201 :raises otp2289.OTPStateError: If hex does not validate
202
203 :return: The validated hex (without leading 0x) converted to bytes
204 :rtype: bytes
205 """
206 if not isinstance(ot_hex, str):
207 raise OTPStateError('OT-hex must be a str')
208 if ot_hex.startswith('0x'):
209 ot_hex = ot_hex[2:]
210 ot_hex = ot_hex.strip().lower()
211 if len(ot_hex) != OTP2289_HEX_DIGEST_SIZE:
212 raise OTPStateError(
213 f'The length of the hex should be {OTP2289_HEX_DIGEST_SIZE} '
214 '(representing 64 bits digest)'
215 )
216 try:
217 return bytes.fromhex(ot_hex)
218 except ValueError:
219 raise OTPStateError('Invalid OT-hex') from None
220
221 def get_next_state(self) -> OTPState | None: 201 def get_next_state(self) -> OTPState | None:
222 """ 202 """
223 Returns the next state for a validated OTPState. 203 Returns the next state for a validated OTPState.
@@ -235,26 +215,38 @@ class OTPState:
235 ) 215 )
236 216
237 def response_validates( 217 def response_validates(
238 self, response: str, *, store_valid_response: bool = True 218 self, response: str | OTPResponse, *, store_valid_response: bool = True
239 ) -> bool: 219 ) -> bool:
240 """ 220 """
241 Validates the incoming response as specified by RFC-2289. 221 Validates the incoming response as specified by RFC-2289.
242 222
243 :param response: The response to this state (its challenge) 223 :param response: The response to this state (its challenge)
244 :type response: str 224 :type response: str or OTPResponse
245 225
246 :param store_valid_response: Should a valid response be stored 226 :param store_valid_response: Should a valid response be stored
247 :type store_valid_response: bool 227 :type store_valid_response: bool
248 228
249 :raises otp2289.OTPInvalidResponseError: If the response does not match 229 :raises otp2289.OTPInvalidResponseError: If the response is corrupt
250 this state 230 or invalid
251 231
252 :return: Returns True if response validates, False otherwise 232 :return: Returns True if response validates, False otherwise
253 :rtype: bool 233 :rtype: bool
254 """ 234 """
255 # self.response_to_bytes raises OTPInvalidResponseError in case 235 # self.response_string_to_otp_response raises OTPInvalidResponseError
256 # response is corrupt or in a wrong format 236 # in case response is corrupt or in a wrong format
257 response_bytes = self.response_to_bytes(response) 237 if not isinstance(response, (str, OTPResponse)):
238 raise OTPInvalidResponseError(
239 'response must be of type str or OTPResponse'
240 )
241
242 if isinstance(response, str):
243 response_bytes = self.response_string_to_otp_response(
244 response
245 ).response_bytes
246 else:
247 # assume OTPResponse
248 response_bytes = response.response_bytes
249
258 if self._hash_algo == 'md5': 250 if self._hash_algo == 'md5':
259 digest = hashlib.md5(response_bytes).digest() 251 digest = hashlib.md5(response_bytes).digest()
260 if ( 252 if (
diff --git a/tests/test_generator.py b/tests/test_generator.py
index 08947e5..68d3bfe 100644
--- a/tests/test_generator.py
+++ b/tests/test_generator.py
@@ -35,26 +35,26 @@ def test_caller_exceptions() -> None:
35 b'This is a test.', 'TeSt', otp2289.OTP_ALGO_MD5 35 b'This is a test.', 'TeSt', otp2289.OTP_ALGO_MD5
36 ) 36 )
37 with pytest.raises(otp2289.OTPGeneratorError) as exc_info: 37 with pytest.raises(otp2289.OTPGeneratorError) as exc_info:
38 gen.generate_otp_words('3') # ty: ignore[invalid-argument-type] 38 gen.generate_otp_response('3') # ty: ignore[invalid-argument-type]
39 assert exc_info.type is otp2289.OTPGeneratorError 39 assert exc_info.type is otp2289.OTPGeneratorError
40 assert exc_info.value.args[0] == 'Step value MUST be an int' 40 assert exc_info.value.args[0] == 'Step value MUST be an int'
41 with pytest.raises(otp2289.OTPGeneratorError) as exc_info: 41 with pytest.raises(otp2289.OTPGeneratorError) as exc_info:
42 gen.generate_otp_hexdigest(-1) 42 gen.generate_otp_response(-1)
43 assert exc_info.type is otp2289.OTPGeneratorError 43 assert exc_info.type is otp2289.OTPGeneratorError
44 assert exc_info.value.args[0] == 'Step value MUST be >= 0' 44 assert exc_info.value.args[0] == 'Step value MUST be >= 0'
45 with pytest.raises(otp2289.OTPChallengeError) as exc_info: 45 with pytest.raises(otp2289.OTPChallengeError) as exc_info:
46 gen.generate_otp_hexdigest_from_challenge( 46 gen.generate_otp_response_from_challenge(
47 b'md5 fbd TeSt' # ty: ignore[invalid-argument-type] 47 b'md5 fbd TeSt' # ty: ignore[invalid-argument-type]
48 ) 48 )
49 assert exc_info.type is otp2289.OTPChallengeError 49 assert exc_info.type is otp2289.OTPChallengeError
50 assert exc_info.value.args[0] == 'Challenge must be str' 50 assert exc_info.value.args[0] == 'Challenge must be str'
51 with pytest.raises(otp2289.OTPChallengeError) as exc_info: 51 with pytest.raises(otp2289.OTPChallengeError) as exc_info:
52 gen.generate_otp_hexdigest_from_challenge('md5 fbd TeSt') 52 gen.generate_otp_response_from_challenge('md5 fbd TeSt')
53 assert exc_info.type is otp2289.OTPChallengeError 53 assert exc_info.type is otp2289.OTPChallengeError
54 assert exc_info.value.args[0] == 'Invalid challenge' 54 assert exc_info.value.args[0] == 'Invalid challenge'
55 with pytest.raises(otp2289.generator.OTPChallengeError) as exc_info: 55 with pytest.raises(otp2289.OTPChallengeError) as exc_info:
56 gen.generate_otp_hexdigest_from_challenge('otp-md5 fbd TeSt') 56 gen.generate_otp_response_from_challenge('otp-md5 fbd TeSt')
57 assert exc_info.type is otp2289.generator.OTPChallengeError 57 assert exc_info.type is otp2289.OTPChallengeError
58 assert exc_info.value.args[0] == 'Invalid challenge' 58 assert exc_info.value.args[0] == 'Invalid challenge'
59 59
60 60
@@ -102,9 +102,9 @@ def test_constructor_exceptions() -> None:
102 assert exc_info.type is otp2289.OTPGeneratorError 102 assert exc_info.type is otp2289.OTPGeneratorError
103 assert exc_info.value.args[0] == 'hash_algo must be an int or a str' 103 assert exc_info.value.args[0] == 'hash_algo must be an int or a str'
104 # test the package structure as well 104 # test the package structure as well
105 with pytest.raises(otp2289.generator.OTPGeneratorError) as exc_info: 105 with pytest.raises(otp2289.OTPGeneratorError) as exc_info:
106 otp2289.generator.OTPGenerator(b'This is a test.', 'TeSt', 'foo') 106 otp2289.OTPGenerator(b'This is a test.', 'TeSt', 'foo')
107 assert exc_info.type is otp2289.generator.OTPGeneratorError 107 assert exc_info.type is otp2289.OTPGeneratorError
108 assert exc_info.value.args[0] == ( 108 assert exc_info.value.args[0] == (
109 'foo is not supported by this version of the hashlib module' 109 'foo is not supported by this version of the hashlib module'
110 ) 110 )
@@ -133,59 +133,148 @@ def test_md5() -> None:
133 gen = otp2289.OTPGenerator( 133 gen = otp2289.OTPGenerator(
134 b'This is a test.', 'TeSt', otp2289.OTP_ALGO_MD5 134 b'This is a test.', 'TeSt', otp2289.OTP_ALGO_MD5
135 ) 135 )
136 res_words = gen.generate_otp_words(0) 136 response = gen.generate_otp_response(0)
137 res_hex = gen.generate_otp_hexdigest(0) 137 assert isinstance(response, otp2289.OTPResponse)
138 assert isinstance(res_words, str) 138 assert isinstance(response.words, str)
139 assert isinstance(res_hex, str) 139 assert isinstance(response.hexdigest, str)
140 assert res_hex == '0x9e876134d90499dd' 140 assert response.hexdigest == '0x9e876134d90499dd'
141 assert res_words == 'INCH SEA ANNE LONG AHEM TOUR' 141 assert response.words == 'INCH SEA ANNE LONG AHEM TOUR'
142
142 # step 1 143 # step 1
143 assert gen.generate_otp_hexdigest(1) == '0x7965e05436f5029f' 144 response = gen.generate_otp_response(1)
144 assert gen.generate_otp_words(1) == 'EASE OIL FUM CURE AWRY AVIS' 145 assert response.hexdigest == '0x7965e05436f5029f'
145 assert gen.generate_otp_hexdigest_from_challenge('otp-md5 1 TeSt') == ( 146 assert response.words == 'EASE OIL FUM CURE AWRY AVIS'
146 '0x7965e05436f5029f' 147 assert (
148 gen.generate_otp_response_from_challenge('otp-md5 1 TeSt').hexdigest
149 == '0x7965e05436f5029f'
147 ) 150 )
148 assert gen.generate_otp_words_from_challenge('otp-md5 1 TeSt') == ( 151 assert (
149 'EASE OIL FUM CURE AWRY AVIS' 152 gen.generate_otp_response_from_challenge('otp-md5 1 TeSt').words
153 == 'EASE OIL FUM CURE AWRY AVIS'
150 ) 154 )
155
151 # step 99 156 # step 99
152 assert gen.generate_otp_hexdigest(99) == '0x50fe1962c4965880' 157 response = gen.generate_otp_response(99)
153 assert gen.generate_otp_words(99) == 'BAIL TUFT BITS GANG CHEF THY' 158 assert response.hexdigest == '0x50fe1962c4965880'
154 assert gen.generate_otp_hexdigest_from_challenge('otp-md5 99 TeSt') == ( 159 assert response.words == 'BAIL TUFT BITS GANG CHEF THY'
155 '0x50fe1962c4965880' 160 assert (
161 gen.generate_otp_response_from_challenge('otp-md5 99 TeSt').hexdigest
162 == '0x50fe1962c4965880'
156 ) 163 )
157 assert gen.generate_otp_words_from_challenge('otp-md5 99 TeSt') == ( 164 assert (
158 'BAIL TUFT BITS GANG CHEF THY' 165 gen.generate_otp_response_from_challenge('otp-md5 99 TeSt').words
166 == 'BAIL TUFT BITS GANG CHEF THY'
159 ) 167 )
168
160 # iterator test 169 # iterator test
161 hexdigests = list(gen.hexdigest_range(105)) # testing the range itself 170 hexdigests = list(gen.otp_response_range(105)) # testing the range itself
162 words = list(gen.words_range(99)) 171 words = list(gen.otp_response_range(99))
163 hexdigests.reverse() 172 hexdigests.reverse()
164 words.reverse() 173 words.reverse()
165 assert hexdigests[0] == '0x9e876134d90499dd' 174 assert hexdigests[0].hexdigest == '0x9e876134d90499dd'
166 assert hexdigests[1] == '0x7965e05436f5029f' 175 assert hexdigests[1].hexdigest == '0x7965e05436f5029f'
167 assert hexdigests[99] == '0x50fe1962c4965880' 176 assert hexdigests[99].hexdigest == '0x50fe1962c4965880'
168 assert words[0] == 'INCH SEA ANNE LONG AHEM TOUR' 177 assert words[0].words == 'INCH SEA ANNE LONG AHEM TOUR'
169 assert words[1] == 'EASE OIL FUM CURE AWRY AVIS' 178 assert words[1].words == 'EASE OIL FUM CURE AWRY AVIS'
170 assert words[99] == 'BAIL TUFT BITS GANG CHEF THY' 179 assert words[99].words == 'BAIL TUFT BITS GANG CHEF THY'
180
171 # pass='AbCdEfGhIjK', seed='alpha1' 181 # pass='AbCdEfGhIjK', seed='alpha1'
172 gen = otp2289.OTPGenerator(b'AbCdEfGhIjK', 'alpha1', otp2289.OTP_ALGO_MD5) 182 gen = otp2289.OTPGenerator(b'AbCdEfGhIjK', 'alpha1', otp2289.OTP_ALGO_MD5)
173 assert gen.generate_otp_hexdigest(0) == '0x87066dd9644bf206' 183 response = gen.generate_otp_response(0)
174 assert gen.generate_otp_words(0) == 'FULL PEW DOWN ONCE MORT ARC' 184 assert response.hexdigest == '0x87066dd9644bf206'
175 assert gen.generate_otp_hexdigest(1) == '0x7cd34c1040add14b' 185 assert response.words == 'FULL PEW DOWN ONCE MORT ARC'
176 assert gen.generate_otp_words(1) == 'FACT HOOF AT FIST SITE KENT' 186 response = gen.generate_otp_response(1)
177 assert gen.generate_otp_hexdigest(99) == '0x5aa37a81f212146c' 187 assert response.hexdigest == '0x7cd34c1040add14b'
178 assert gen.generate_otp_words(99) == 'BODE HOP JAKE STOW JUT RAP' 188 assert response.words == 'FACT HOOF AT FIST SITE KENT'
189 response = gen.generate_otp_response(99)
190 assert response.hexdigest == '0x5aa37a81f212146c'
191 assert response.words == 'BODE HOP JAKE STOW JUT RAP'
192
179 # pass="OTP's are good", seed='correct' 193 # pass="OTP's are good", seed='correct'
180 gen = otp2289.OTPGenerator( 194 gen = otp2289.OTPGenerator(
181 b"OTP's are good", 'correct', otp2289.OTP_ALGO_MD5 195 b"OTP's are good", 'correct', otp2289.OTP_ALGO_MD5
182 ) 196 )
183 assert gen.generate_otp_hexdigest(0) == '0xf205753943de4cf9' 197 response = gen.generate_otp_response(0)
184 assert gen.generate_otp_words(0) == 'ULAN NEW ARMY FUSE SUIT EYED' 198 assert response.hexdigest == '0xf205753943de4cf9'
185 assert gen.generate_otp_hexdigest(1) == '0xddcdac956f234937' 199 assert response.words == 'ULAN NEW ARMY FUSE SUIT EYED'
186 assert gen.generate_otp_words(1) == 'SKIM CULT LOB SLAM POE HOWL' 200 response = gen.generate_otp_response(1)
187 assert gen.generate_otp_hexdigest(99) == '0xb203e28fa525be47' 201 assert response.hexdigest == '0xddcdac956f234937'
188 assert gen.generate_otp_words(99) == 'LONG IVY JULY AJAR BOND LEE' 202 assert response.words == 'SKIM CULT LOB SLAM POE HOWL'
203 response = gen.generate_otp_response(99)
204 assert response.hexdigest == '0xb203e28fa525be47'
205 assert response.words == 'LONG IVY JULY AJAR BOND LEE'
206
207
208def test_otp_response() -> None:
209 """Tests OTPResponse"""
210 gen = otp2289.OTPGenerator(
211 b'This is a test.', 'TeSt', otp2289.OTP_ALGO_MD5
212 )
213 response = gen.generate_otp_response(0)
214 assert response.response_bytes == bytes(response)
215
216 # From 'RFC-2289 Appendix C - OTP Verification Examples'
217 # hexdigest: 9e876134d90499dd
218 # words: INCH SEA ANNE LONG AHEM TOUR
219 response_from_tokens = otp2289.OTPResponse.from_tokens(
220 'INCH SEA ANNE LONG AHEM TOUR'
221 )
222 response_from_hex1 = otp2289.OTPResponse.from_hex('9e876134d90499dd')
223 response_from_hex2 = otp2289.OTPResponse.from_hex('0x9e876134d90499dd')
224 assert (
225 response_from_tokens.response_bytes
226 == response_from_hex1.response_bytes
227 )
228 assert (
229 response_from_tokens.response_bytes
230 == response_from_hex2.response_bytes
231 )
232
233 # same tests, only using __eq__
234 assert response_from_tokens == response_from_hex1
235 assert response_from_tokens == response_from_hex2
236
237
238def test_otp_response_exceptions() -> None:
239 """Tests OTPResponse exceptions"""
240 with pytest.raises(otp2289.OTPResponseError) as exc_info:
241 otp2289.OTPResponse.from_hex(
242 b'9e876134d90499dd' # ty: ignore[invalid-argument-type]
243 )
244 assert exc_info.type is otp2289.OTPResponseError
245 assert exc_info.value.args[0] == 'OT-hex must be a str'
246 with pytest.raises(otp2289.OTPResponseError) as exc_info:
247 otp2289.OTPResponse.from_hex('9e876134d90499d')
248 assert exc_info.type is otp2289.OTPResponseError
249 assert exc_info.value.args[0] == (
250 'The length of the hex should be 16 (representing 64 bits digest)'
251 )
252 with pytest.raises(otp2289.OTPResponseError) as exc_info:
253 otp2289.OTPResponse.from_hex('9e876134d90499dg')
254 assert exc_info.type is otp2289.OTPResponseError
255 assert exc_info.value.args[0] == 'Invalid OT-hex'
256
257 # .from_tokens
258 with pytest.raises(otp2289.OTPResponseError) as exc_info:
259 otp2289.OTPResponse.from_tokens(
260 b'INCH SEA ANNE LONG AHEM TOUR' # ty: ignore[invalid-argument-type]
261 )
262 assert exc_info.type is otp2289.OTPResponseError
263 assert exc_info.value.args[0] == 'tokens must be a str'
264 with pytest.raises(otp2289.OTPResponseError) as exc_info:
265 otp2289.OTPResponse.from_tokens('INCH SEA ANNE LONG AHEM')
266 assert exc_info.type is otp2289.OTPResponseError
267 assert exc_info.value.args[0] == 'Tokens-string does not contain 6 tokens'
268 with pytest.raises(otp2289.OTPResponseError) as exc_info:
269 otp2289.OTPResponse.from_tokens('INCH SEA ANNE LONG AHEM SIMEON')
270 assert exc_info.type is otp2289.OTPResponseError
271 assert exc_info.value.args[0] == (
272 'One or more words not present in RFC1760'
273 )
274 with pytest.raises(otp2289.OTPResponseError) as exc_info:
275 otp2289.OTPResponse.from_tokens('INCH SEA ANNE LONG AHEM LEE')
276 assert exc_info.type is otp2289.OTPResponseError
277 assert exc_info.value.args[0] == 'Invalid bit checksum'
189 278
190 279
191def test_sha1() -> None: 280def test_sha1() -> None:
@@ -198,54 +287,71 @@ def test_sha1() -> None:
198 gen = otp2289.OTPGenerator( 287 gen = otp2289.OTPGenerator(
199 b'This is a test.', 'TeSt', otp2289.OTP_ALGO_SHA1 288 b'This is a test.', 'TeSt', otp2289.OTP_ALGO_SHA1
200 ) 289 )
201 res_hex = gen.generate_otp_hexdigest(step=0) 290 response = gen.generate_otp_response(step=0)
202 res_words = gen.generate_otp_words(step=0) 291 assert isinstance(response, otp2289.OTPResponse)
203 assert isinstance(res_words, str) 292 assert isinstance(response.words, str)
204 assert isinstance(res_hex, str) 293 assert isinstance(response.hexdigest, str)
205 assert res_hex == '0xbb9e6ae1979d8ff4' 294 assert response.hexdigest == '0xbb9e6ae1979d8ff4'
206 assert res_words == 'MILT VARY MAST OK SEES WENT' 295 assert response.words == 'MILT VARY MAST OK SEES WENT'
207 assert gen.generate_otp_hexdigest(1) == '0x63d936639734385b' 296 response = gen.generate_otp_response(1)
208 assert gen.generate_otp_words(1) == 'CART OTTO HIVE ODE VAT NUT' 297 assert response.hexdigest == '0x63d936639734385b'
209 assert gen.generate_otp_hexdigest_from_challenge('otp-sha1 1 TeSt') == ( 298 assert response.words == 'CART OTTO HIVE ODE VAT NUT'
210 '0x63d936639734385b' 299 assert (
300 gen.generate_otp_response_from_challenge('otp-sha1 1 TeSt').hexdigest
301 == '0x63d936639734385b'
211 ) 302 )
212 assert gen.generate_otp_words_from_challenge('otp-sha1 1 TeSt') == ( 303 assert (
213 'CART OTTO HIVE ODE VAT NUT' 304 gen.generate_otp_response_from_challenge('otp-sha1 1 TeSt').words
305 == 'CART OTTO HIVE ODE VAT NUT'
214 ) 306 )
215 assert gen.generate_otp_hexdigest(99) == '0x87fec7768b73ccf9' 307 response = gen.generate_otp_response(99)
216 assert gen.generate_otp_words(99) == 'GAFF WAIT SKID GIG SKY EYED' 308 assert response.hexdigest == '0x87fec7768b73ccf9'
217 assert gen.generate_otp_hexdigest_from_challenge('otp-sha1 99 TeSt') == ( 309 assert response.words == 'GAFF WAIT SKID GIG SKY EYED'
218 '0x87fec7768b73ccf9' 310 assert (
311 gen.generate_otp_response_from_challenge(
312 'otp-sha1 99 TeSt'
313 ).hexdigest
314 == '0x87fec7768b73ccf9'
219 ) 315 )
220 assert gen.generate_otp_words_from_challenge('otp-sha1 99 TeSt') == ( 316 assert (
221 'GAFF WAIT SKID GIG SKY EYED' 317 gen.generate_otp_response_from_challenge('otp-sha1 99 TeSt').words
318 == 'GAFF WAIT SKID GIG SKY EYED'
222 ) 319 )
320
223 # iterator test 321 # iterator test
224 hexdigests = list(gen.hexdigest_range(105)) 322 hexdigests = list(gen.otp_response_range(105))
225 words = list(gen.words_range(99)) 323 words = list(gen.otp_response_range(99))
226 hexdigests.reverse() 324 hexdigests.reverse()
227 words.reverse() 325 words.reverse()
228 assert hexdigests[0] == '0xbb9e6ae1979d8ff4' 326 assert hexdigests[0].hexdigest == '0xbb9e6ae1979d8ff4'
229 assert hexdigests[1] == '0x63d936639734385b' 327 assert hexdigests[1].hexdigest == '0x63d936639734385b'
230 assert hexdigests[99] == '0x87fec7768b73ccf9' 328 assert hexdigests[99].hexdigest == '0x87fec7768b73ccf9'
231 assert words[0] == 'MILT VARY MAST OK SEES WENT' 329 assert words[0].words == 'MILT VARY MAST OK SEES WENT'
232 assert words[1] == 'CART OTTO HIVE ODE VAT NUT' 330 assert words[1].words == 'CART OTTO HIVE ODE VAT NUT'
233 assert words[99] == 'GAFF WAIT SKID GIG SKY EYED' 331 assert words[99].words == 'GAFF WAIT SKID GIG SKY EYED'
332
234 # pass='AbCdEfGhIjK', seed='alpha1' 333 # pass='AbCdEfGhIjK', seed='alpha1'
235 gen = otp2289.OTPGenerator(b'AbCdEfGhIjK', 'alpha1', otp2289.OTP_ALGO_SHA1) 334 gen = otp2289.OTPGenerator(b'AbCdEfGhIjK', 'alpha1', otp2289.OTP_ALGO_SHA1)
236 assert gen.generate_otp_hexdigest(0) == '0xad85f658ebe383c9' 335 response = gen.generate_otp_response(0)
237 assert gen.generate_otp_words(0) == 'LEST OR HEEL SCOT ROB SUIT' 336 assert response.hexdigest == '0xad85f658ebe383c9'
238 assert gen.generate_otp_hexdigest(1) == '0xd07ce229b5cf119b' 337 assert response.words == 'LEST OR HEEL SCOT ROB SUIT'
239 assert gen.generate_otp_words(1) == 'RITE TAKE GELD COST TUNE RECK' 338 response = gen.generate_otp_response(1)
240 assert gen.generate_otp_hexdigest(99) == '0x27bc71035aaf3dc6' 339 assert response.hexdigest == '0xd07ce229b5cf119b'
241 assert gen.generate_otp_words(99) == 'MAY STAR TIN LYON VEDA STAN' 340 assert response.words == 'RITE TAKE GELD COST TUNE RECK'
341 response = gen.generate_otp_response(99)
342 assert response.hexdigest == '0x27bc71035aaf3dc6'
343 assert response.words == 'MAY STAR TIN LYON VEDA STAN'
344
242 # pass="OTP's are good", seed='correct' 345 # pass="OTP's are good", seed='correct'
243 gen = otp2289.OTPGenerator( 346 gen = otp2289.OTPGenerator(
244 b"OTP's are good", 'correct', otp2289.OTP_ALGO_SHA1 347 b"OTP's are good", 'correct', otp2289.OTP_ALGO_SHA1
245 ) 348 )
246 assert gen.generate_otp_hexdigest(0) == '0xd51f3e99bf8e6f0b' 349 response = gen.generate_otp_response(0)
247 assert gen.generate_otp_words(0) == 'RUST WELT KICK FELL TAIL FRAU' 350 assert response.hexdigest == '0xd51f3e99bf8e6f0b'
248 assert gen.generate_otp_hexdigest(1) == '0x82aeb52d943774e4' 351 assert response.words == 'RUST WELT KICK FELL TAIL FRAU'
249 assert gen.generate_otp_words(1) == 'FLIT DOSE ALSO MEW DRUM DEFY' 352 response = gen.generate_otp_response(1)
250 assert gen.generate_otp_hexdigest(99) == '0x4f296a74fe1567ec' 353 assert response.hexdigest == '0x82aeb52d943774e4'
251 assert gen.generate_otp_words(99) == 'AURA ALOE HURL WING BERG WAIT' 354 assert response.words == 'FLIT DOSE ALSO MEW DRUM DEFY'
355 response = gen.generate_otp_response(99)
356 assert response.hexdigest == '0x4f296a74fe1567ec'
357 assert response.words == 'AURA ALOE HURL WING BERG WAIT'
diff --git a/tests/test_server.py b/tests/test_server.py
index 3099cf7..41a6aea 100644
--- a/tests/test_server.py
+++ b/tests/test_server.py
@@ -75,6 +75,8 @@ def test_state_validation_md5() -> None:
75 assert state.validated is False 75 assert state.validated is False
76 assert state.response_validates('0x9e876134d90499dd') is True 76 assert state.response_validates('0x9e876134d90499dd') is True
77 assert state.response_validates('INCH SEA ANNE LONG AHEM TOUR') is True 77 assert state.response_validates('INCH SEA ANNE LONG AHEM TOUR') is True
78 response = otp2289.OTPResponse(bytes.fromhex('9e876134d90499dd'))
79 assert state.response_validates(response) is True
78 assert state.ot_hex == '7965e05436f5029f' 80 assert state.ot_hex == '7965e05436f5029f'
79 assert state.validated is True 81 assert state.validated is True
80 82
@@ -87,6 +89,8 @@ def test_state_validation_sha1() -> None:
87 assert state.validated is False 89 assert state.validated is False
88 assert state.response_validates('0xbb9e6ae1979d8ff4') is True 90 assert state.response_validates('0xbb9e6ae1979d8ff4') is True
89 assert state.response_validates('MILT VARY MAST OK SEES WENT') is True 91 assert state.response_validates('MILT VARY MAST OK SEES WENT') is True
92 response = otp2289.OTPResponse(bytes.fromhex('bb9e6ae1979d8ff4'))
93 assert state.response_validates(response) is True
90 assert state.ot_hex == '63d936639734385b' 94 assert state.ot_hex == '63d936639734385b'
91 assert state.validated is True 95 assert state.validated is True
92 96
diff --git a/tests/test_static.py b/tests/test_static.py
index 589fc17..7268e84 100644
--- a/tests/test_static.py
+++ b/tests/test_static.py
@@ -32,58 +32,58 @@ import otp2289
32def test_bytes_and_tokens() -> None: 32def test_bytes_and_tokens() -> None:
33 """Tests the official hex and tokens defined in RFC2289""" 33 """Tests the official hex and tokens defined in RFC2289"""
34 assert bytes.fromhex('9e876134d90499dd') == ( 34 assert bytes.fromhex('9e876134d90499dd') == (
35 otp2289.OTPGenerator.tokens_to_bytes('INCH SEA ANNE LONG AHEM TOUR') 35 otp2289.OTPResponse.tokens_to_bytes('INCH SEA ANNE LONG AHEM TOUR')
36 ) 36 )
37 assert bytes.fromhex('7965e05436f5029f') == ( 37 assert bytes.fromhex('7965e05436f5029f') == (
38 otp2289.OTPGenerator.tokens_to_bytes('EASE OIL FUM CURE AWRY AVIS') 38 otp2289.OTPResponse.tokens_to_bytes('EASE OIL FUM CURE AWRY AVIS')
39 ) 39 )
40 assert bytes.fromhex('50fe1962c4965880') == ( 40 assert bytes.fromhex('50fe1962c4965880') == (
41 otp2289.OTPGenerator.tokens_to_bytes('BAIL TUFT BITS GANG CHEF THY') 41 otp2289.OTPResponse.tokens_to_bytes('BAIL TUFT BITS GANG CHEF THY')
42 ) 42 )
43 assert bytes.fromhex('87066dd9644bf206') == ( 43 assert bytes.fromhex('87066dd9644bf206') == (
44 otp2289.OTPGenerator.tokens_to_bytes('FULL PEW DOWN ONCE MORT ARC') 44 otp2289.OTPResponse.tokens_to_bytes('FULL PEW DOWN ONCE MORT ARC')
45 ) 45 )
46 assert bytes.fromhex('7cd34c1040add14b') == ( 46 assert bytes.fromhex('7cd34c1040add14b') == (
47 otp2289.OTPGenerator.tokens_to_bytes('FACT HOOF AT FIST SITE KENT') 47 otp2289.OTPResponse.tokens_to_bytes('FACT HOOF AT FIST SITE KENT')
48 ) 48 )
49 assert bytes.fromhex('5aa37a81f212146c') == ( 49 assert bytes.fromhex('5aa37a81f212146c') == (
50 otp2289.OTPGenerator.tokens_to_bytes('BODE HOP JAKE STOW JUT RAP') 50 otp2289.OTPResponse.tokens_to_bytes('BODE HOP JAKE STOW JUT RAP')
51 ) 51 )
52 assert bytes.fromhex('f205753943de4cf9') == ( 52 assert bytes.fromhex('f205753943de4cf9') == (
53 otp2289.OTPGenerator.tokens_to_bytes('ULAN NEW ARMY FUSE SUIT EYED') 53 otp2289.OTPResponse.tokens_to_bytes('ULAN NEW ARMY FUSE SUIT EYED')
54 ) 54 )
55 assert bytes.fromhex('ddcdac956f234937') == ( 55 assert bytes.fromhex('ddcdac956f234937') == (
56 otp2289.OTPGenerator.tokens_to_bytes('SKIM CULT LOB SLAM POE HOWL') 56 otp2289.OTPResponse.tokens_to_bytes('SKIM CULT LOB SLAM POE HOWL')
57 ) 57 )
58 assert bytes.fromhex('b203e28fa525be47') == ( 58 assert bytes.fromhex('b203e28fa525be47') == (
59 otp2289.OTPGenerator.tokens_to_bytes('LONG IVY JULY AJAR BOND LEE') 59 otp2289.OTPResponse.tokens_to_bytes('LONG IVY JULY AJAR BOND LEE')
60 ) 60 )
61 assert bytes.fromhex('bb9e6ae1979d8ff4') == ( 61 assert bytes.fromhex('bb9e6ae1979d8ff4') == (
62 otp2289.OTPGenerator.tokens_to_bytes('MILT VARY MAST OK SEES WENT') 62 otp2289.OTPResponse.tokens_to_bytes('MILT VARY MAST OK SEES WENT')
63 ) 63 )
64 assert bytes.fromhex('63d936639734385b') == ( 64 assert bytes.fromhex('63d936639734385b') == (
65 otp2289.OTPGenerator.tokens_to_bytes('CART OTTO HIVE ODE VAT NUT') 65 otp2289.OTPResponse.tokens_to_bytes('CART OTTO HIVE ODE VAT NUT')
66 ) 66 )
67 assert bytes.fromhex('87fec7768b73ccf9') == ( 67 assert bytes.fromhex('87fec7768b73ccf9') == (
68 otp2289.OTPGenerator.tokens_to_bytes('GAFF WAIT SKID GIG SKY EYED') 68 otp2289.OTPResponse.tokens_to_bytes('GAFF WAIT SKID GIG SKY EYED')
69 ) 69 )
70 assert bytes.fromhex('ad85f658ebe383c9') == ( 70 assert bytes.fromhex('ad85f658ebe383c9') == (
71 otp2289.OTPGenerator.tokens_to_bytes('LEST OR HEEL SCOT ROB SUIT') 71 otp2289.OTPResponse.tokens_to_bytes('LEST OR HEEL SCOT ROB SUIT')
72 ) 72 )
73 assert bytes.fromhex('d07ce229b5cf119b') == ( 73 assert bytes.fromhex('d07ce229b5cf119b') == (
74 otp2289.OTPGenerator.tokens_to_bytes('RITE TAKE GELD COST TUNE RECK') 74 otp2289.OTPResponse.tokens_to_bytes('RITE TAKE GELD COST TUNE RECK')
75 ) 75 )
76 assert bytes.fromhex('27bc71035aaf3dc6') == ( 76 assert bytes.fromhex('27bc71035aaf3dc6') == (
77 otp2289.OTPGenerator.tokens_to_bytes('MAY STAR TIN LYON VEDA STAN') 77 otp2289.OTPResponse.tokens_to_bytes('MAY STAR TIN LYON VEDA STAN')
78 ) 78 )
79 assert bytes.fromhex('d51f3e99bf8e6f0b') == ( 79 assert bytes.fromhex('d51f3e99bf8e6f0b') == (
80 otp2289.OTPGenerator.tokens_to_bytes('RUST WELT KICK FELL TAIL FRAU') 80 otp2289.OTPResponse.tokens_to_bytes('RUST WELT KICK FELL TAIL FRAU')
81 ) 81 )
82 assert bytes.fromhex('82aeb52d943774e4') == ( 82 assert bytes.fromhex('82aeb52d943774e4') == (
83 otp2289.OTPGenerator.tokens_to_bytes('FLIT DOSE ALSO MEW DRUM DEFY') 83 otp2289.OTPResponse.tokens_to_bytes('FLIT DOSE ALSO MEW DRUM DEFY')
84 ) 84 )
85 assert bytes.fromhex('4f296a74fe1567ec') == ( 85 assert bytes.fromhex('4f296a74fe1567ec') == (
86 otp2289.OTPGenerator.tokens_to_bytes('AURA ALOE HURL WING BERG WAIT') 86 otp2289.OTPResponse.tokens_to_bytes('AURA ALOE HURL WING BERG WAIT')
87 ) 87 )
88 88
89 89
@@ -92,4 +92,4 @@ def test_random_bytes() -> None:
92 for _ in range(10): 92 for _ in range(10):
93 rnd_bytes = os.urandom(8) # 64 bits 93 rnd_bytes = os.urandom(8) # 64 bits
94 tokens = otp2289.OTPResponse.bytes_to_tokens(rnd_bytes) 94 tokens = otp2289.OTPResponse.bytes_to_tokens(rnd_bytes)
95 assert rnd_bytes == otp2289.OTPGenerator.tokens_to_bytes(tokens) 95 assert rnd_bytes == otp2289.OTPResponse.tokens_to_bytes(tokens)