diff options
| author | Simeon Simeonov | 2026-05-01 14:34:04 +0200 |
|---|---|---|
| committer | Simeon Simeonov | 2026-05-01 14:34:04 +0200 |
| commit | bfae04723bd3155801f079ab5238018ec8a0cb09 (patch) | |
| tree | 7db35cefe64d00bb23b26513fa13afbf26c073dd | |
| parent | 751c74e7de1c78a151fdd8b76f220d8411a2108a (diff) | |
| -rw-r--r-- | CHANGELOG.md | 28 | ||||
| -rw-r--r-- | README.md | 31 | ||||
| -rw-r--r-- | src/otp2289/__init__.py | 4 | ||||
| -rw-r--r-- | src/otp2289/__main__.py | 153 | ||||
| -rw-r--r-- | src/otp2289/generator.py | 314 | ||||
| -rw-r--r-- | src/otp2289/server.py | 104 | ||||
| -rw-r--r-- | tests/test_generator.py | 278 | ||||
| -rw-r--r-- | tests/test_server.py | 4 | ||||
| -rw-r--r-- | tests/test_static.py | 38 |
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 | ||
| @@ -10,7 +10,7 @@ It requires no additional libraries. | |||
| 10 | The main reason for writing this library was the need to login into my | 10 | The main reason for writing this library was the need to login into my |
| 11 | FreeBSD servers using [opiepasswd](https://en.wikipedia.org/wiki/OPIE_Authentication_System). | 11 | FreeBSD 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 | ||
| 15 | I decided to license the library under the | 15 | I 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 | ) |
| 35 | from .server import ( | 36 | from .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 | ||
| 54 | def 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 | |||
| 66 | def 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 | |||
| 54 | def generate_otp_response(args: argparse.Namespace) -> str: | 104 | def 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 | ||
| 91 | def generate_otp_range(args: argparse.Namespace) -> str: | 146 | def 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 | ||
| 175 | def 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 | |||
| 187 | def 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 | |||
| 219 | def main(inargs: list[str] | None = None) -> None: | 244 | def 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 | ||
| 27 | from __future__ import annotations | ||
| 28 | |||
| 27 | import hashlib | 29 | import hashlib |
| 28 | import string | 30 | import string |
| 29 | import typing | 31 | import typing |
| 30 | from collections.abc import Iterator | 32 | |
| 33 | if typing.TYPE_CHECKING: | ||
| 34 | from collections.abc import Iterator | ||
| 31 | 35 | ||
| 32 | OTP_ALGO_MD5: typing.Final[int] = 1 | 36 | OTP_ALGO_MD5: typing.Final[int] = 1 |
| 33 | OTP_ALGO_SHA1: typing.Final[int] = 2 | 37 | OTP_ALGO_SHA1: typing.Final[int] = 2 |
| 34 | 38 | ||
| 35 | # useful constants | 39 | # useful constants |
| 36 | OTP2289_BITSTREAM_SIZE: typing.Final[int] = 64 | 40 | OTP2289_BITSTREAM_SIZE: typing.Final[int] = 64 |
| 41 | OTP2289_HEX_DIGEST_SIZE: typing.Final[int] = 16 | ||
| 37 | OTP2289_MAX_SEED_LENGTH: typing.Final[int] = 16 | 42 | OTP2289_MAX_SEED_LENGTH: typing.Final[int] = 16 |
| 38 | OTP2289_MIN_PASSWORD_LENGTH: typing.Final[int] = 10 | 43 | OTP2289_MIN_PASSWORD_LENGTH: typing.Final[int] = 10 |
| 39 | OTP2289_SHA1_DIGEST_SIZE: typing.Final[int] = 20 | 44 | OTP2289_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 | ||
| 2102 | class OTPChallengeError(Exception): | ||
| 2103 | """OTPChallengeError class""" | ||
| 2104 | |||
| 2105 | |||
| 2097 | class OTPGeneratorError(Exception): | 2106 | class OTPGeneratorError(Exception): |
| 2098 | """OTPGeneratorError class""" | 2107 | """OTPGeneratorError class""" |
| 2099 | 2108 | ||
| 2100 | 2109 | ||
| 2101 | class OTPChallengeError(Exception): | 2110 | class OTPResponseError(Exception): |
| 2102 | """OTPChallengeError class""" | 2111 | """OTPResponseError class""" |
| 2103 | 2112 | ||
| 2104 | 2113 | ||
| 2105 | class OTPResponse: | 2114 | class 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 | ||
| 2169 | class OTPGenerator: | 2320 | class 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 | |||
| 32 | if typing.TYPE_CHECKING: | 32 | if typing.TYPE_CHECKING: |
| 33 | from collections.abc import Iterator | 33 | from collections.abc import Iterator |
| 34 | 34 | ||
| 35 | from .generator import OTP_ALGO_MD5, OTPGenerator, OTPGeneratorError | 35 | from .generator import ( |
| 36 | 36 | OTP_ALGO_MD5, | |
| 37 | OTP2289_HEX_DIGEST_SIZE: typing.Final[int] = 16 | 37 | OTPGenerator, |
| 38 | OTPGeneratorError, | ||
| 39 | OTPResponse, | ||
| 40 | OTPResponseError, | ||
| 41 | ) | ||
| 38 | 42 | ||
| 39 | 43 | ||
| 40 | class OTPStateError(Exception): | 44 | class 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 | |||
| 208 | def 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 | |||
| 238 | def 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 | ||
| 191 | def test_sha1() -> None: | 280 | def 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 | |||
| 32 | def test_bytes_and_tokens() -> None: | 32 | def 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) |
