summaryrefslogtreecommitdiff
path: root/otp2289
diff options
context:
space:
mode:
Diffstat (limited to 'otp2289')
-rw-r--r--otp2289/__init__.py6
-rw-r--r--otp2289/generator.py118
-rw-r--r--otp2289/server.py171
3 files changed, 271 insertions, 24 deletions
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,
29 OTPChallengeException, 29 OTPChallengeException,
30 OTPGenerator, 30 OTPGenerator,
31 OTPGeneratorException) 31 OTPGeneratorException)
32from .server import OTPInvalidResponse, OTPState, OTPStateException
32 33
33 34
34__author__ = 'Simeon Simeonov' 35__author__ = 'Simeon Simeonov'
@@ -50,4 +51,7 @@ __all__ = ['OTP_ALGO_MD5',
50 'OTP_ALGO_SHA1', 51 'OTP_ALGO_SHA1',
51 'OTPChallengeException', 52 'OTPChallengeException',
52 'OTPGenerator', 53 'OTPGenerator',
53 'OTPGeneratorException'] 54 'OTPGeneratorException',
55 'OTPInvalidResponse',
56 'OTPState',
57 '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:
320 :param hash_algo: The hash algo, defaults to OTP_ALGO_MD5 320 :param hash_algo: The hash algo, defaults to OTP_ALGO_MD5
321 :type hash_algo: int or str 321 :type hash_algo: int or str
322 322
323 :raises OTPGeneratorException: In case input does not validate 323 :raises OTPGeneratorException: In case the input does not validate
324 """ 324 """
325 # enforce the rfc2289 constraints 325 # enforce the rfc2289 constraints
326 self._seed = seed
327 if self._seed: # the seed was set here. Validate it
328 self._seed = self.validate_seed(self._seed)
329 self._hash_algo = self.validate_hash_algo(hash_algo)
326 if not isinstance(password, bytes): 330 if not isinstance(password, bytes):
327 raise OTPGeneratorException('Password must be a byte-string') 331 raise OTPGeneratorException('Password must be a byte-string')
328 if len(password) < 10: 332 if len(password) < 10:
329 raise OTPGeneratorException( 333 raise OTPGeneratorException(
330 'Password must be longer than 10 bytes') 334 'Password must be longer than 10 bytes')
331 self._password = password 335 self._password = password
332 self._seed = seed
333 if self._seed: # the seed was set here. Validate it
334 self._seed = self.validate_seed(self._seed)
335 self._hash_algo = self.validate_hash_algo(hash_algo)
336 336
337 @staticmethod 337 @staticmethod
338 def bit_pair_sum(bit_stream): 338 def bit_pair_sum(bit_stream):
@@ -355,6 +355,31 @@ class OTPGenerator:
355 return value 355 return value
356 356
357 @staticmethod 357 @staticmethod
358 def bytes_to_tokens(hash_bytes):
359 """
360 Returns a 6 words token from bytes as specified by RFC-2289.
361
362 :param hash_bytes: The input bytes
363 :type hash_bytes: bytes
364
365 :return: 6 words tokens
366 :rtype: str
367 """
368 bit_stream = ''.join(
369 ['{0:0>8b}'.format(byte) for byte in hash_bytes])
370 bit_pair_sum = OTPGenerator.bit_pair_sum(bit_stream)
371 tokens = []
372 tokens.append(RFC1760_TOKENS[int(bit_stream[:11], 2)])
373 tokens.append(RFC1760_TOKENS[int(bit_stream[11:22], 2)])
374 tokens.append(RFC1760_TOKENS[int(bit_stream[22:33], 2)])
375 tokens.append(RFC1760_TOKENS[int(bit_stream[33:44], 2)])
376 tokens.append(RFC1760_TOKENS[int(bit_stream[44:55], 2)])
377 tokens.append(
378 RFC1760_TOKENS[int(
379 bit_stream[55:64] + '{0:0>8b}'.format(bit_pair_sum)[-2:], 2)])
380 return ' '.join(tokens)
381
382 @staticmethod
358 def get_tokens_from_challenge(challenge): 383 def get_tokens_from_challenge(challenge):
359 """ 384 """
360 Returns tokens (seed, hash_algo and step) from a challenge string. 385 Returns tokens (seed, hash_algo and step) from a challenge string.
@@ -453,6 +478,50 @@ class OTPGenerator:
453 ) 478 )
454 479
455 @staticmethod 480 @staticmethod
481 def tokens_to_bytes(tokens_str):
482 """
483 Returns bytes from a 6 words token as specified by RFC-2289.
484
485 :param tokens_str: String representing 6 words tokens
486 :type tokens_str: str
487
488 :raises OTPGeneratorException: When the tokens_str is invalid
489
490 :return: 6 words tokens
491 :rtype: bytes
492 """
493 if not isinstance(tokens_str, str):
494 raise OTPGeneratorException('tokens must be a str')
495 tokens = tokens_str.split()
496 if len(tokens) != 6:
497 raise OTPGeneratorException(
498 'Tokens-string does not contain 6 tokens')
499 token_ints = []
500 try:
501 token_ints = [RFC1760_TOKENS.index(token.upper()) for token in
502 tokens]
503 except ValueError:
504 raise OTPGeneratorException(
505 'One or more words not present in RFC1760')
506 # now we build a string of bits
507 bit_stream = format(token_ints[0], '011b')
508 bit_stream += format(token_ints[1], '011b')
509 bit_stream += format(token_ints[2], '011b')
510 bit_stream += format(token_ints[3], '011b')
511 bit_stream += format(token_ints[4], '011b')
512 bit_stream += format(token_ints[5], '011b')
513 # we have 66 bits: 64 digest + 2 bit pair sum (control number)
514 # RFC-2289: All OTP generators MUST calculate this checksum and all
515 # OTP servers MUST verify this checksum explicitly as part of the
516 # operation of decoding this representation of the one-time password.
517 if (
518 '{0:0>8b}'.format(OTPGenerator.bit_pair_sum(
519 bit_stream[:64]))[-2:] != bit_stream[-2:]
520 ):
521 raise OTPGeneratorException('Invalid bit checksum')
522 return int(bit_stream[:64], 2).to_bytes(8, 'big')
523
524 @staticmethod
456 def validate_hash_algo(hash_algo): 525 def validate_hash_algo(hash_algo):
457 """ 526 """
458 Validates the provided hash-algorithm. 527 Validates the provided hash-algorithm.
@@ -503,6 +572,25 @@ class OTPGenerator:
503 'The seed MUST consist of purely alphanumeric characters') 572 'The seed MUST consist of purely alphanumeric characters')
504 return seed 573 return seed
505 574
575 @staticmethod
576 def validate_step(step):
577 """
578 Validates the provided step as defined by RFC-2289.
579
580 :param seed: The step received from the challenge
581 :type seed: int
582
583 :raises OTPGeneratorException: In case step does not validate
584
585 :return: The validated (and very same) step
586 :rtype: int
587 """
588 if not isinstance(step, int):
589 raise OTPGeneratorException('Step value MUST be an int')
590 if step < 0:
591 raise OTPGeneratorException('Step value MUST be >= 0')
592 return step
593
506 def generate_otp_hexdigest(self, step): 594 def generate_otp_hexdigest(self, step):
507 """ 595 """
508 Generates the OTP hexdigest for the given step. 596 Generates the OTP hexdigest for the given step.
@@ -549,20 +637,7 @@ class OTPGenerator:
549 :return: Six words (separated by single space) token for the given step 637 :return: Six words (separated by single space) token for the given step
550 :rtype: str 638 :rtype: str
551 """ 639 """
552 digest = self._generate_otp_bytes(step) 640 return self.bytes_to_tokens(self._generate_otp_bytes(step))
553 bit_stream = ''.join(
554 ['{0:0>8b}'.format(byte) for byte in digest])
555 bit_pair_sum = self.bit_pair_sum(bit_stream)
556 tokens = list()
557 tokens.append(RFC1760_TOKENS[int(bit_stream[:11], 2)])
558 tokens.append(RFC1760_TOKENS[int(bit_stream[11:22], 2)])
559 tokens.append(RFC1760_TOKENS[int(bit_stream[22:33], 2)])
560 tokens.append(RFC1760_TOKENS[int(bit_stream[33:44], 2)])
561 tokens.append(RFC1760_TOKENS[int(bit_stream[44:55], 2)])
562 tokens.append(
563 RFC1760_TOKENS[int(
564 bit_stream[55:64] + '{0:0>8b}'.format(bit_pair_sum)[-2:], 2)])
565 return ' '.join(tokens)
566 641
567 def generate_otp_words_from_challenge(self, challenge): 642 def generate_otp_words_from_challenge(self, challenge):
568 """ 643 """
@@ -641,10 +716,7 @@ class OTPGenerator:
641 :return: The digest bytes for the given step 716 :return: The digest bytes for the given step
642 :rtype: bytes 717 :rtype: bytes
643 """ 718 """
644 if not isinstance(step, int): 719 step = self.validate_step(step)
645 raise OTPGeneratorException('Step value MUST be an int')
646 if step < 0:
647 raise OTPGeneratorException('Step value MUST be >= 0')
648 digest = b'' 720 digest = b''
649 for _ in range(step + 1): 721 for _ in range(step + 1):
650 hash_obj = hashlib.new(self._hash_algo) 722 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 @@
24# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF 24# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
25# THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 25# THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
26"""A pure Python implementation of the RFC-2289 OTP server""" 26"""A pure Python implementation of the RFC-2289 OTP server"""
27import binascii
28import hashlib
29
30from .generator import (OTP_ALGO_MD5,
31 OTPGenerator,
32 OTPGeneratorException)
33
34
35class OTPStateException(Exception):
36 """OTPStateException class"""
37
38
39class OTPInvalidResponse(Exception):
40 """OTPInvalidResponse class"""
41
42
43class OTPState:
44 """
45 OTPState class
46
47 The OTPState class represents a single state on the server side that can:
48 - generate a challenge
49 - validate the corresponding generated response from the generator
50 """
51
52 def __init__(self, ot_hex, current_step, seed, hash_algo=OTP_ALGO_MD5):
53 """
54 Constructs an OTPState object with the given arguments.
55
56 Keyword Arguments:
57 :param ot_hex: The one-time hex from the last successful
58 authentication or the first OTP of a newly
59 initialized sequence
60 :type ot_hex: str
61
62 :param current_step: The current step that is sent with the challenge
63 :type current_step: int
64
65 :param seed: The seed that is sent with the challenge
66 :type seed: str
67
68 :param hash_algo: The hash algo, defaults to OTP_ALGO_MD5
69 :type hash_algo: int or str
70
71 :raises OTPStateException: In case the input does not validate
72 """
73 # enforce the rfc2289 constraints
74 try:
75 self._seed = OTPGenerator.validate_seed(seed)
76 self._hash_algo = OTPGenerator.validate_hash_algo(hash_algo)
77 self._step = OTPGenerator.validate_step(current_step)
78 except OTPGeneratorException as exp:
79 raise OTPStateException(exp.args[0])
80 self._current_digest = self.validate_hex(ot_hex)
81 self._new_digest_hex = None # set upon a successful validation
82
83 def __str__(self):
84 """Duplicate the challenge string"""
85 return f'otp-{self._hash_algo} {self._step} {self._seed} '
86
87 @property
88 def challenge_string(self):
89 """challenge_string-property"""
90 # RFC-2289: "...the entire challenge string MUST be
91 # terminated with either a space or a new line."
92 return f'otp-{self._hash_algo} {self._step} {self._seed} '
93
94 @property
95 def validated(self):
96 """validated-property"""
97 return bool(self._new_digest_hex)
98
99 @staticmethod
100 def response_to_bytes(response):
101 """
102 A wrapper that handles/validates the response as specified by RFC-2289.
103
104 The method first checks if response is a token and tries to convert
105 it to bytes. If that fails, the method assumes that response is a hex.
106 If neither of those attempts succeeds OTPInvalidResponse is raised.
107 It is up to the caller to run another iteration and compare the result
108 to an existing digest in this state.
109
110 :param response: The response to this state (its challenge)
111 :type response: str
112
113 :raises OTPInvalidResponse: If the response is corrupt / illegal,
114 but not if it simply does not validate
115
116 :return: The bytes representation of response (if any)
117 :rtype: bytes
118 """
119 try:
120 return OTPGenerator.tokens_to_bytes(response)
121 except OTPGeneratorException:
122 # now assume hex...
123 try:
124 return OTPState.validate_hex(response)
125 except OTPStateException:
126 raise OTPInvalidResponse(
127 'The response is neither a valid token or hex')
128
129 @staticmethod
130 def validate_hex(ot_hex):
131 """
132 Validates the provided hexidigest.
133
134 :param ot_hex: The one-time hex to validate
135 :type ot_hex: str
136
137 :raises OTPStateException: In case hex does not validate
138
139 :return: The validated hex (without leading 0x) converted to bytes
140 :rtype: bytes
141 """
142 if not isinstance(ot_hex, str):
143 raise OTPStateException('OT-hex must be a str')
144 if ot_hex.startswith('0x'):
145 ot_hex = ot_hex[2:]
146 ot_hex = ot_hex.strip().lower()
147 if len(ot_hex) != 16:
148 raise OTPStateException('The length of the hex should be 16 '
149 '(representing 64 bits digest)')
150 try:
151 return binascii.unhexlify(ot_hex)
152 except binascii.Error:
153 raise OTPStateException('Invalid OT-hex')
154
155 def response_validates(self, response, store_valid_response=True):
156 """
157 Validates the incoming response as specified by RFC-2289.
158
159 :param response: The response to this state (its challenge)
160 :type response: str
161
162 :param store_valid_response: Should a valid response be stored
163 :type store_valid_response: bool
164
165 :raises OTPInvalidResponse: If the response does not match this state
166
167 :return: Returns True if response validates, False otherwise
168 :rtype: bool
169 """
170 # self.response_to_bytes raises OTPInvalidResponse in case response
171 # is corrupt or in a wrong format
172 response_bytes = self.response_to_bytes(response)
173 if self._hash_algo == 'md5':
174 digest = hashlib.md5(response_bytes).digest()
175 if (
176 OTPGenerator.strxor(digest[0:8], digest[8:]) ==
177 self._current_digest
178 ):
179 if store_valid_response:
180 self._new_digest_hex = binascii.hexlify(
181 response_bytes).decode()
182 return True
183 return False
184 if self._hash_algo == 'sha1':
185 digest = hashlib.sha1(response_bytes).digest()
186 if (
187 OTPGenerator.sha1_digest_folding(
188 hashlib.sha1(
189 response_bytes).digest()) == self._current_digest
190 ):
191 if store_valid_response:
192 self._new_digest_hex = binascii.hexlify(
193 response_bytes).decode()
194 return True
195 return False
196 # this should not happen since the hash_algo is validated by the caller
197 raise OTPInvalidResponse(f'Ivalid hash_algo: {self._hash_algo}')