From 2a6299c32e0baf3df06f8e6d6bc8695b951d630d Mon Sep 17 00:00:00 2001 From: Simeon Simeonov Date: Mon, 30 Mar 2020 21:57:53 +0200 Subject: Implement OTPState in the server module --- README.md | 24 ++++++- otp2289/__init__.py | 6 +- otp2289/generator.py | 118 ++++++++++++++++++++++++++------- otp2289/server.py | 171 ++++++++++++++++++++++++++++++++++++++++++++++++ tests/test_generator.py | 20 +++--- tests/test_server.py | 84 ++++++++++++++++++++++++ tests/test_static.py | 79 ++++++++++++++++++++++ 7 files changed, 467 insertions(+), 35 deletions(-) create mode 100644 tests/test_server.py create mode 100644 tests/test_static.py diff --git a/README.md b/README.md index fef620a..ac3bca9 100644 --- a/README.md +++ b/README.md @@ -57,10 +57,32 @@ one-time password it received, and must store the corresponding one- time password sequence number. The server must also facilitate the changing of the user's secret pass-phrase in a secure manner." +"The OTP system generator passes the user's secret pass-phrase, along +with a seed received from the server as part of the challenge, +through multiple iterations of a secure hash function to produce a +one-time password. After each successful authentication, the number +of secure hash function iterations is reduced by one. Thus, a unique +sequence of passwords is generated. The server verifies the one-time +password received from the generator by computing the secure hash +function once and comparing the result with the previously accepted +one-time password." + ## Examples -TODO + ```python + import getpass + + import otp2289 + + # create a generator object + passwd_bytes = getpass.getpass().encode() # Type: This is a test. + generator = otp2289.generator.OTPGenerator(passwd_bytes, + 'TesT', + otp2289.OTP_ALGO_MD5) + generator.generate_otp_hexdigest(0) + generator.gen.generate_otp_words(0) + ``` ## Author diff --git a/otp2289/__init__.py b/otp2289/__init__.py index 44c39bb..2fba4ea 100644 --- a/otp2289/__init__.py +++ b/otp2289/__init__.py @@ -29,6 +29,7 @@ from .generator import (OTP_ALGO_MD5, OTPChallengeException, OTPGenerator, OTPGeneratorException) +from .server import OTPInvalidResponse, OTPState, OTPStateException __author__ = 'Simeon Simeonov' @@ -50,4 +51,7 @@ __all__ = ['OTP_ALGO_MD5', 'OTP_ALGO_SHA1', 'OTPChallengeException', 'OTPGenerator', - 'OTPGeneratorException'] + 'OTPGeneratorException', + 'OTPInvalidResponse', + 'OTPState', + 'OTPStateException'] diff --git a/otp2289/generator.py b/otp2289/generator.py index 7001014..ac3bb3c 100644 --- a/otp2289/generator.py +++ b/otp2289/generator.py @@ -320,19 +320,19 @@ class OTPGenerator: :param hash_algo: The hash algo, defaults to OTP_ALGO_MD5 :type hash_algo: int or str - :raises OTPGeneratorException: In case input does not validate + :raises OTPGeneratorException: In case the input does not validate """ # enforce the rfc2289 constraints + self._seed = seed + if self._seed: # the seed was set here. Validate it + self._seed = self.validate_seed(self._seed) + self._hash_algo = self.validate_hash_algo(hash_algo) if not isinstance(password, bytes): raise OTPGeneratorException('Password must be a byte-string') if len(password) < 10: raise OTPGeneratorException( 'Password must be longer than 10 bytes') self._password = password - self._seed = seed - if self._seed: # the seed was set here. Validate it - self._seed = self.validate_seed(self._seed) - self._hash_algo = self.validate_hash_algo(hash_algo) @staticmethod def bit_pair_sum(bit_stream): @@ -354,6 +354,31 @@ class OTPGenerator: value += int(''.join(pair), 2) return value + @staticmethod + def bytes_to_tokens(hash_bytes): + """ + Returns a 6 words token from bytes as specified by RFC-2289. + + :param hash_bytes: The input bytes + :type hash_bytes: bytes + + :return: 6 words tokens + :rtype: str + """ + bit_stream = ''.join( + ['{0:0>8b}'.format(byte) for byte in hash_bytes]) + bit_pair_sum = OTPGenerator.bit_pair_sum(bit_stream) + tokens = [] + tokens.append(RFC1760_TOKENS[int(bit_stream[:11], 2)]) + tokens.append(RFC1760_TOKENS[int(bit_stream[11:22], 2)]) + tokens.append(RFC1760_TOKENS[int(bit_stream[22:33], 2)]) + tokens.append(RFC1760_TOKENS[int(bit_stream[33:44], 2)]) + tokens.append(RFC1760_TOKENS[int(bit_stream[44:55], 2)]) + tokens.append( + RFC1760_TOKENS[int( + bit_stream[55:64] + '{0:0>8b}'.format(bit_pair_sum)[-2:], 2)]) + return ' '.join(tokens) + @staticmethod def get_tokens_from_challenge(challenge): """ @@ -452,6 +477,50 @@ class OTPGenerator: [byte_str1[i] ^ byte_str2[i] for i in range(length)] ) + @staticmethod + def tokens_to_bytes(tokens_str): + """ + Returns bytes from a 6 words token as specified by RFC-2289. + + :param tokens_str: String representing 6 words tokens + :type tokens_str: str + + :raises OTPGeneratorException: When the tokens_str is invalid + + :return: 6 words tokens + :rtype: bytes + """ + if not isinstance(tokens_str, str): + raise OTPGeneratorException('tokens must be a str') + tokens = tokens_str.split() + if len(tokens) != 6: + raise OTPGeneratorException( + 'Tokens-string does not contain 6 tokens') + token_ints = [] + try: + token_ints = [RFC1760_TOKENS.index(token.upper()) for token in + tokens] + except ValueError: + raise OTPGeneratorException( + 'One or more words not present in RFC1760') + # now we build a string of bits + bit_stream = format(token_ints[0], '011b') + bit_stream += format(token_ints[1], '011b') + bit_stream += format(token_ints[2], '011b') + bit_stream += format(token_ints[3], '011b') + bit_stream += format(token_ints[4], '011b') + bit_stream += format(token_ints[5], '011b') + # we have 66 bits: 64 digest + 2 bit pair sum (control number) + # RFC-2289: All OTP generators MUST calculate this checksum and all + # OTP servers MUST verify this checksum explicitly as part of the + # operation of decoding this representation of the one-time password. + if ( + '{0:0>8b}'.format(OTPGenerator.bit_pair_sum( + bit_stream[:64]))[-2:] != bit_stream[-2:] + ): + raise OTPGeneratorException('Invalid bit checksum') + return int(bit_stream[:64], 2).to_bytes(8, 'big') + @staticmethod def validate_hash_algo(hash_algo): """ @@ -503,6 +572,25 @@ class OTPGenerator: 'The seed MUST consist of purely alphanumeric characters') return seed + @staticmethod + def validate_step(step): + """ + Validates the provided step as defined by RFC-2289. + + :param seed: The step received from the challenge + :type seed: int + + :raises OTPGeneratorException: In case step does not validate + + :return: The validated (and very same) step + :rtype: int + """ + if not isinstance(step, int): + raise OTPGeneratorException('Step value MUST be an int') + if step < 0: + raise OTPGeneratorException('Step value MUST be >= 0') + return step + def generate_otp_hexdigest(self, step): """ Generates the OTP hexdigest for the given step. @@ -549,20 +637,7 @@ class OTPGenerator: :return: Six words (separated by single space) token for the given step :rtype: str """ - digest = self._generate_otp_bytes(step) - bit_stream = ''.join( - ['{0:0>8b}'.format(byte) for byte in digest]) - bit_pair_sum = self.bit_pair_sum(bit_stream) - tokens = list() - tokens.append(RFC1760_TOKENS[int(bit_stream[:11], 2)]) - tokens.append(RFC1760_TOKENS[int(bit_stream[11:22], 2)]) - tokens.append(RFC1760_TOKENS[int(bit_stream[22:33], 2)]) - tokens.append(RFC1760_TOKENS[int(bit_stream[33:44], 2)]) - tokens.append(RFC1760_TOKENS[int(bit_stream[44:55], 2)]) - tokens.append( - RFC1760_TOKENS[int( - bit_stream[55:64] + '{0:0>8b}'.format(bit_pair_sum)[-2:], 2)]) - return ' '.join(tokens) + return self.bytes_to_tokens(self._generate_otp_bytes(step)) def generate_otp_words_from_challenge(self, challenge): """ @@ -641,10 +716,7 @@ class OTPGenerator: :return: The digest bytes for the given step :rtype: bytes """ - if not isinstance(step, int): - raise OTPGeneratorException('Step value MUST be an int') - if step < 0: - raise OTPGeneratorException('Step value MUST be >= 0') + step = self.validate_step(step) digest = b'' for _ in range(step + 1): hash_obj = hashlib.new(self._hash_algo) diff --git a/otp2289/server.py b/otp2289/server.py index 096ebd7..4151026 100644 --- a/otp2289/server.py +++ b/otp2289/server.py @@ -24,3 +24,174 @@ # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF # THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. """A pure Python implementation of the RFC-2289 OTP server""" +import binascii +import hashlib + +from .generator import (OTP_ALGO_MD5, + OTPGenerator, + OTPGeneratorException) + + +class OTPStateException(Exception): + """OTPStateException class""" + + +class OTPInvalidResponse(Exception): + """OTPInvalidResponse class""" + + +class OTPState: + """ + OTPState class + + The OTPState class represents a single state on the server side that can: + - generate a challenge + - validate the corresponding generated response from the generator + """ + + def __init__(self, ot_hex, current_step, seed, hash_algo=OTP_ALGO_MD5): + """ + Constructs an OTPState object with the given arguments. + + Keyword Arguments: + :param ot_hex: The one-time hex from the last successful + authentication or the first OTP of a newly + initialized sequence + :type ot_hex: str + + :param current_step: The current step that is sent with the challenge + :type current_step: int + + :param seed: The seed that is sent with the challenge + :type seed: str + + :param hash_algo: The hash algo, defaults to OTP_ALGO_MD5 + :type hash_algo: int or str + + :raises OTPStateException: In case the input does not validate + """ + # enforce the rfc2289 constraints + try: + self._seed = OTPGenerator.validate_seed(seed) + self._hash_algo = OTPGenerator.validate_hash_algo(hash_algo) + self._step = OTPGenerator.validate_step(current_step) + except OTPGeneratorException as exp: + raise OTPStateException(exp.args[0]) + self._current_digest = self.validate_hex(ot_hex) + self._new_digest_hex = None # set upon a successful validation + + def __str__(self): + """Duplicate the challenge string""" + return f'otp-{self._hash_algo} {self._step} {self._seed} ' + + @property + def challenge_string(self): + """challenge_string-property""" + # RFC-2289: "...the entire challenge string MUST be + # terminated with either a space or a new line." + return f'otp-{self._hash_algo} {self._step} {self._seed} ' + + @property + def validated(self): + """validated-property""" + return bool(self._new_digest_hex) + + @staticmethod + def response_to_bytes(response): + """ + A wrapper that handles/validates the response as specified by RFC-2289. + + The method first checks if response is a token and tries to convert + it to bytes. If that fails, the method assumes that response is a hex. + If neither of those attempts succeeds OTPInvalidResponse is raised. + It is up to the caller to run another iteration and compare the result + to an existing digest in this state. + + :param response: The response to this state (its challenge) + :type response: str + + :raises OTPInvalidResponse: If the response is corrupt / illegal, + but not if it simply does not validate + + :return: The bytes representation of response (if any) + :rtype: bytes + """ + try: + return OTPGenerator.tokens_to_bytes(response) + except OTPGeneratorException: + # now assume hex... + try: + return OTPState.validate_hex(response) + except OTPStateException: + raise OTPInvalidResponse( + 'The response is neither a valid token or hex') + + @staticmethod + def validate_hex(ot_hex): + """ + Validates the provided hexidigest. + + :param ot_hex: The one-time hex to validate + :type ot_hex: str + + :raises OTPStateException: In case hex does not validate + + :return: The validated hex (without leading 0x) converted to bytes + :rtype: bytes + """ + if not isinstance(ot_hex, str): + raise OTPStateException('OT-hex must be a str') + if ot_hex.startswith('0x'): + ot_hex = ot_hex[2:] + ot_hex = ot_hex.strip().lower() + if len(ot_hex) != 16: + raise OTPStateException('The length of the hex should be 16 ' + '(representing 64 bits digest)') + try: + return binascii.unhexlify(ot_hex) + except binascii.Error: + raise OTPStateException('Invalid OT-hex') + + def response_validates(self, response, store_valid_response=True): + """ + Validates the incoming response as specified by RFC-2289. + + :param response: The response to this state (its challenge) + :type response: str + + :param store_valid_response: Should a valid response be stored + :type store_valid_response: bool + + :raises OTPInvalidResponse: If the response does not match this state + + :return: Returns True if response validates, False otherwise + :rtype: bool + """ + # self.response_to_bytes raises OTPInvalidResponse in case response + # is corrupt or in a wrong format + response_bytes = self.response_to_bytes(response) + if self._hash_algo == 'md5': + digest = hashlib.md5(response_bytes).digest() + if ( + OTPGenerator.strxor(digest[0:8], digest[8:]) == + self._current_digest + ): + if store_valid_response: + self._new_digest_hex = binascii.hexlify( + response_bytes).decode() + return True + return False + if self._hash_algo == 'sha1': + digest = hashlib.sha1(response_bytes).digest() + if ( + OTPGenerator.sha1_digest_folding( + hashlib.sha1( + response_bytes).digest()) == self._current_digest + ): + if store_valid_response: + self._new_digest_hex = binascii.hexlify( + response_bytes).decode() + return True + return False + # this should not happen since the hash_algo is validated by the caller + raise OTPInvalidResponse(f'Ivalid hash_algo: {self._hash_algo}') diff --git a/tests/test_generator.py b/tests/test_generator.py index 74fc3a4..50274e2 100644 --- a/tests/test_generator.py +++ b/tests/test_generator.py @@ -61,16 +61,6 @@ def test_constructor_exceptions(): Tests the exceptions when initializing a new object (in the constructor) """ # test the otp2289.OTPGenerator __init__ and validators - with pytest.raises(otp2289.OTPGeneratorException) as exc_info: - otp2289.OTPGenerator('1234567', 'TeStø'.encode(), otp2289.OTP_ALGO_MD5) - assert exc_info.type is otp2289.OTPGeneratorException - assert exc_info.value.args[0] == 'Password must be a byte-string' - with pytest.raises(otp2289.OTPGeneratorException) as exc_info: - otp2289.OTPGenerator('1234567'.encode(), - 'TeStø'.encode(), - otp2289.OTP_ALGO_MD5) - assert exc_info.type is otp2289.OTPGeneratorException - assert exc_info.value.args[0] == 'Password must be longer than 10 bytes' with pytest.raises(otp2289.OTPGeneratorException) as exc_info: otp2289.OTPGenerator('This is a test.'.encode(), 'TeStø'.encode(), @@ -108,6 +98,16 @@ def test_constructor_exceptions(): assert exc_info.type is otp2289.generator.OTPGeneratorException assert exc_info.value.args[0] == ('foo is not supported by this version ' 'of the hashlib module') + with pytest.raises(otp2289.OTPGeneratorException) as exc_info: + otp2289.OTPGenerator('1234567', 'TeSt', otp2289.OTP_ALGO_MD5) + assert exc_info.type is otp2289.OTPGeneratorException + assert exc_info.value.args[0] == 'Password must be a byte-string' + with pytest.raises(otp2289.OTPGeneratorException) as exc_info: + otp2289.OTPGenerator('1234567'.encode(), + 'TeSt', + otp2289.OTP_ALGO_MD5) + assert exc_info.type is otp2289.OTPGeneratorException + assert exc_info.value.args[0] == 'Password must be longer than 10 bytes' def test_md5(): diff --git a/tests/test_server.py b/tests/test_server.py new file mode 100644 index 0000000..c7cdb12 --- /dev/null +++ b/tests/test_server.py @@ -0,0 +1,84 @@ +# -*- coding: utf-8 -*- +# SPDX-License-Identifier: BSD-2-Clause-FreeBSD +# +# Copyright (c) 2020, Simeon Simeonov +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE AUTHORS ``AS IS'' AND ANY EXPRESS OR +# IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES +# OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. +# IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, +# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT +# NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF +# THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +"""Tests for otp2289.server""" +import pytest + +import otp2289 + + +def test_state_caller_exceptions(): + """Tests the exceptions when calling the OTPState objects""" + state = otp2289.OTPState('0x7965e05436f5029f', + 1, + 'TeSt', + otp2289.OTP_ALGO_MD5) + with pytest.raises(otp2289.OTPInvalidResponse) as exc_info: + state.response_validates('bla') + assert exc_info.type is otp2289.OTPInvalidResponse + assert exc_info.value.args[0] == ( + 'The response is neither a valid token or hex') + + +def test_state_constructor_exceptions(): + """Tests the exceptions when initializing new OTPState objects""" + with pytest.raises(otp2289.OTPStateException) as exc_info: + otp2289.OTPState('0x7965e05436f5029t', + 1, + 'TeStø'.encode(), + otp2289.OTP_ALGO_MD5) + assert exc_info.type is otp2289.OTPStateException + assert exc_info.value.args[0] == 'Seed must be a string' + with pytest.raises(otp2289.OTPStateException) as exc_info: + otp2289.OTPState('0x7965e05436f5029t', + '1', + 'TeSt', + otp2289.OTP_ALGO_MD5) + assert exc_info.type is otp2289.OTPStateException + assert exc_info.value.args[0] == 'Step value MUST be an int' + + +def test_state_validation_md5(): + """Tests the OTPState validation functionality for MD5""" + state = otp2289.OTPState('0x7965e05436f5029f', + 1, + 'TeSt', + otp2289.OTP_ALGO_MD5) + assert state.validated is False + assert state.response_validates('0x9e876134d90499dd') is True + assert state.response_validates('INCH SEA ANNE LONG AHEM TOUR') is True + assert state.validated is True + + +def test_state_validation_sha1(): + """Tests the OTPState validation functionality for SHA1""" + state = otp2289.OTPState('0x63d936639734385b', + 1, + 'TeSt', + otp2289.OTP_ALGO_SHA1) + assert state.validated is False + assert state.response_validates('0xbb9e6ae1979d8ff4') is True + assert state.response_validates('MILT VARY MAST OK SEES WENT') is True + assert state.validated is True diff --git a/tests/test_static.py b/tests/test_static.py new file mode 100644 index 0000000..dc6e2d7 --- /dev/null +++ b/tests/test_static.py @@ -0,0 +1,79 @@ +# -*- coding: utf-8 -*- +# SPDX-License-Identifier: BSD-2-Clause-FreeBSD +# +# Copyright (c) 2020, Simeon Simeonov +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE AUTHORS ``AS IS'' AND ANY EXPRESS OR +# IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES +# OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. +# IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, +# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT +# NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF +# THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +"""Tests for the static methods and basic bit, byte, token functionality""" +import binascii +import os + +import otp2289 + + +def test_bytes_and_tokens(): + """Tests the official hex and tokens defined in RFC2289""" + assert binascii.unhexlify('9e876134d90499dd') == ( + otp2289.OTPGenerator.tokens_to_bytes('INCH SEA ANNE LONG AHEM TOUR')) + assert binascii.unhexlify('7965e05436f5029f') == ( + otp2289.OTPGenerator.tokens_to_bytes('EASE OIL FUM CURE AWRY AVIS')) + assert binascii.unhexlify('50fe1962c4965880') == ( + otp2289.OTPGenerator.tokens_to_bytes('BAIL TUFT BITS GANG CHEF THY')) + assert binascii.unhexlify('87066dd9644bf206') == ( + otp2289.OTPGenerator.tokens_to_bytes('FULL PEW DOWN ONCE MORT ARC')) + assert binascii.unhexlify('7cd34c1040add14b') == ( + otp2289.OTPGenerator.tokens_to_bytes('FACT HOOF AT FIST SITE KENT')) + assert binascii.unhexlify('5aa37a81f212146c') == ( + otp2289.OTPGenerator.tokens_to_bytes('BODE HOP JAKE STOW JUT RAP')) + assert binascii.unhexlify('f205753943de4cf9') == ( + otp2289.OTPGenerator.tokens_to_bytes('ULAN NEW ARMY FUSE SUIT EYED')) + assert binascii.unhexlify('ddcdac956f234937') == ( + otp2289.OTPGenerator.tokens_to_bytes('SKIM CULT LOB SLAM POE HOWL')) + assert binascii.unhexlify('b203e28fa525be47') == ( + otp2289.OTPGenerator.tokens_to_bytes('LONG IVY JULY AJAR BOND LEE')) + + assert binascii.unhexlify('bb9e6ae1979d8ff4') == ( + otp2289.OTPGenerator.tokens_to_bytes('MILT VARY MAST OK SEES WENT')) + assert binascii.unhexlify('63d936639734385b') == ( + otp2289.OTPGenerator.tokens_to_bytes('CART OTTO HIVE ODE VAT NUT')) + assert binascii.unhexlify('87fec7768b73ccf9') == ( + otp2289.OTPGenerator.tokens_to_bytes('GAFF WAIT SKID GIG SKY EYED')) + assert binascii.unhexlify('ad85f658ebe383c9') == ( + otp2289.OTPGenerator.tokens_to_bytes('LEST OR HEEL SCOT ROB SUIT')) + assert binascii.unhexlify('d07ce229b5cf119b') == ( + otp2289.OTPGenerator.tokens_to_bytes('RITE TAKE GELD COST TUNE RECK')) + assert binascii.unhexlify('27bc71035aaf3dc6') == ( + otp2289.OTPGenerator.tokens_to_bytes('MAY STAR TIN LYON VEDA STAN')) + assert binascii.unhexlify('d51f3e99bf8e6f0b') == ( + otp2289.OTPGenerator.tokens_to_bytes('RUST WELT KICK FELL TAIL FRAU')) + assert binascii.unhexlify('82aeb52d943774e4') == ( + otp2289.OTPGenerator.tokens_to_bytes('FLIT DOSE ALSO MEW DRUM DEFY')) + assert binascii.unhexlify('4f296a74fe1567ec') == ( + otp2289.OTPGenerator.tokens_to_bytes('AURA ALOE HURL WING BERG WAIT')) + + +def test_random_bytes(): + """Implement a few tests with random bytes""" + for _ in range(10): + rnd_bytes = os.urandom(8) # 64 bits + tokens = otp2289.OTPGenerator.bytes_to_tokens(rnd_bytes) + assert rnd_bytes == otp2289.OTPGenerator.tokens_to_bytes(tokens) -- cgit v1.3