summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSimeon Simeonov2020-03-26 15:24:34 +0100
committerSimeon Simeonov2020-03-26 15:24:34 +0100
commit0a226b690b31ef31a92aea043ee8fe082b316633 (patch)
treef3bec3676ddfd19b68b11cadd47c7fb79a336851
parent75bebab025aa03a2f1790d7d15b9c8264ab28fed (diff)
Add support for accepting a challenge string in the generator
-rw-r--r--otp2289/__init__.py2
-rw-r--r--otp2289/generator.py192
-rw-r--r--otp2289/server.py4
-rw-r--r--tests/test_generator.py71
4 files changed, 217 insertions, 52 deletions
diff --git a/otp2289/__init__.py b/otp2289/__init__.py
index d2f6386..82c1413 100644
--- a/otp2289/__init__.py
+++ b/otp2289/__init__.py
@@ -3,6 +3,7 @@
3 3
4from .generator import (OTP_ALGO_MD5, 4from .generator import (OTP_ALGO_MD5,
5 OTP_ALGO_SHA1, 5 OTP_ALGO_SHA1,
6 OTPChallengeException,
6 OTPGenerator, 7 OTPGenerator,
7 OTPGeneratorException) 8 OTPGeneratorException)
8 9
@@ -24,5 +25,6 @@ VERSION = tuple(map(int_or_str, __version__.split('.')))
24 25
25__all__ = ['OTP_ALGO_MD5', 26__all__ = ['OTP_ALGO_MD5',
26 'OTP_ALGO_SHA1', 27 'OTP_ALGO_SHA1',
28 'OTPChallengeException',
27 'OTPGenerator', 29 'OTPGenerator',
28 'OTPGeneratorException'] 30 'OTPGeneratorException']
diff --git a/otp2289/generator.py b/otp2289/generator.py
index b007d87..020d154 100644
--- a/otp2289/generator.py
+++ b/otp2289/generator.py
@@ -1,5 +1,4 @@
1# -*- coding: utf-8 -*- 1# -*- coding: utf-8 -*-
2
3""" 2"""
4A pure Python implementation of the RFC-2289 OTP generator 3A pure Python implementation of the RFC-2289 OTP generator
5""" 4"""
@@ -279,23 +278,28 @@ class OTPGeneratorException(Exception):
279 """OTPGeneratorException class""" 278 """OTPGeneratorException class"""
280 279
281 280
281class OTPChallengeException(Exception):
282 """OTPChallengeException class"""
283
284
282class OTPGenerator: 285class OTPGenerator:
283 """OTPGenerator class""" 286 """OTPGenerator class"""
284 287
285 def __init__(self, password, seed, hash_algo=OTP_ALGO_MD5): 288 def __init__(self, password, seed='', hash_algo=OTP_ALGO_MD5):
286 """ 289 """
287 Constructs an OTPGenerator object with a given password and seed 290 Constructs an OTPGenerator object with a given password and seed.
288 291
289 Keyword Arguments: 292 Keyword Arguments:
290 :param password: the password string 293 :param password: The password string
291 :type password: bytes 294 :type password: bytes
292 295
293 :param seed: the seed received from the server-challenge 296 :param seed: The seed received from the challenge, defaults to ''
294 :type seed: str 297 :type seed: str
295 298
296 :param hash_algo: the hash algo. 299 :param hash_algo: The hash algo, defaults to OTP_ALGO_MD5
297 :type hash_algo: int or str 300 :type hash_algo: int or str
298 (default OTP_ALGO_MD5) 301
302 :raises OTPGeneratorException: In case input does not validate
299 """ 303 """
300 # enforce the rfc2289 constraints 304 # enforce the rfc2289 constraints
301 if not isinstance(password, bytes): 305 if not isinstance(password, bytes):
@@ -304,37 +308,20 @@ class OTPGenerator:
304 raise OTPGeneratorException( 308 raise OTPGeneratorException(
305 'Password must be longer than 10 bytes') 309 'Password must be longer than 10 bytes')
306 self._password = password 310 self._password = password
307 if not isinstance(seed, str):
308 raise OTPGeneratorException('Seed must be a string')
309 if not seed or len(seed) > 16:
310 raise OTPGeneratorException(
311 'The seed MUST be of 1 to 16 characters in length')
312 for char in seed:
313 if char not in string.ascii_letters + string.digits:
314 raise OTPGeneratorException(
315 'The seed MUST consist of purely alphanumeric characters')
316 self._seed = seed 311 self._seed = seed
317 if isinstance(hash_algo, int): 312 if self._seed: # the seed was set here. Validate it
318 self._hash_algo = _ALGO_DICT.get(hash_algo, 'md5') 313 self._seed = self.validate_seed(self._seed)
319 elif isinstance(hash_algo, str): 314 self._hash_algo = self.validate_hash_algo(hash_algo)
320 self._hash_algo = hash_algo
321 else:
322 raise OTPGeneratorException(
323 'hash_algo must be an int or a str')
324 if self._hash_algo not in hashlib.algorithms_available:
325 raise OTPGeneratorException(
326 '{hash_algo} is not supported by this version of the '
327 'hashlib module'.format(hash_algo=self._hash_algo))
328 315
329 @staticmethod 316 @staticmethod
330 def bit_pair_sum(bit_stream): 317 def bit_pair_sum(bit_stream):
331 """ 318 """
332 Split bit_stream in bit-pairs and sum them all together 319 Split bit_stream in bit-pairs and sum them all together.
333 320
334 :param bit_stream: The bit-stream object 321 :param bit_stream: The bit-stream object
335 :type bit_stream: str 322 :type bit_stream: str
336 323
337 :return: the sum of all bit-pairs in bit_stream 324 :return: The sum of all bit-pairs in bit_stream
338 :rtype: int 325 :rtype: int
339 """ 326 """
340 if not isinstance(bit_stream, str): 327 if not isinstance(bit_stream, str):
@@ -347,15 +334,41 @@ class OTPGenerator:
347 return value 334 return value
348 335
349 @staticmethod 336 @staticmethod
337 def get_tokens_from_challenge(challenge):
338 """
339 Returns tokens (seed, hash_algo and step) from a challenge string.
340
341 N.B. The tokens are not validated here.
342
343 :param challenge: The challenge string described in RFC-2289
344 :type challenge: str
345
346 :raises OTPChallengeException: When the challenge string is invalid
347
348 :return: (seed, hash_algo, step) tuple.
349 :rtype: tuple
350 """
351 if not isinstance(challenge, str):
352 raise OTPChallengeException('Challenge must be str')
353 challenge = challenge.strip()
354 if not challenge.startswith('otp-'):
355 raise OTPChallengeException('Invalid challenge')
356 try:
357 hash_algo, step, seed = challenge[4:].split()
358 return (seed, hash_algo, int(step))
359 except ValueError:
360 raise OTPChallengeException('Invalid challenge')
361
362 @staticmethod
350 def sha1_digest_folding(sha1_digest): 363 def sha1_digest_folding(sha1_digest):
351 """ 364 """
352 Implementation of the 160bit -> 64bit folding algorithm 365 Implementation of the 160bit -> 64bit folding algorithm
353 for sha1 digest 366 for sha1 digest.
354 367
355 :param sha1_digest: The SHA1 digest 368 :param sha1_digest: The SHA1 digest
356 :type sha1_digest: bytes 369 :type sha1_digest: bytes
357 370
358 :return: the byte-string representing the folded sha1-digest 371 :return: The byte-string representing the folded sha1-digest
359 :rtype: bytes 372 :rtype: bytes
360 """ 373 """
361 if not (isinstance(sha1_digest, bytes)): 374 if not (isinstance(sha1_digest, bytes)):
@@ -396,7 +409,7 @@ class OTPGenerator:
396 @staticmethod 409 @staticmethod
397 def strxor(byte_str1, byte_str2): 410 def strxor(byte_str1, byte_str2):
398 """ 411 """
399 Implementation of strxor similar to the one provided by pycrypto 412 Implementation of strxor similar to the one provided by pycrypto.
400 413
401 :param byte_str1: Byte-string 1 414 :param byte_str1: Byte-string 1
402 :type byte_str1: bytes 415 :type byte_str1: bytes
@@ -404,7 +417,7 @@ class OTPGenerator:
404 :param byte_str2: Byte-string 2 417 :param byte_str2: Byte-string 2
405 :type byte_str2: bytes 418 :type byte_str2: bytes
406 419
407 :return: the byte-string representing the result of byte_str1^byte_str2 420 :return: The byte-string representing the result of byte_str1^byte_str2
408 :rtype: bytes 421 :rtype: bytes
409 """ 422 """
410 if not (isinstance(byte_str1, bytes) and isinstance(byte_str2, bytes)): 423 if not (isinstance(byte_str1, bytes) and isinstance(byte_str2, bytes)):
@@ -418,28 +431,101 @@ class OTPGenerator:
418 [byte_str1[i] ^ byte_str2[i] for i in range(length)] 431 [byte_str1[i] ^ byte_str2[i] for i in range(length)]
419 ) 432 )
420 433
434 @staticmethod
435 def validate_hash_algo(hash_algo):
436 """
437 Validates the provided hash-algorithm.
438
439 :param hash_algo: The hash algo, defaults to OTP_ALGO_MD5
440 :type hash_algo: int or str
441
442 :raises OTPGeneratorException: In case hash_algo does not validate
443
444 :return: The validated hash_algo in str-form
445 :rtype: str
446 """
447 if isinstance(hash_algo, int):
448 if hash_algo not in _ALGO_DICT:
449 raise OTPGeneratorException(
450 'hash_algo is not among the known algorithms')
451 hash_algo = _ALGO_DICT.get(hash_algo)
452 if not isinstance(hash_algo, str):
453 raise OTPGeneratorException(
454 'hash_algo must be an int or a str')
455 if hash_algo not in hashlib.algorithms_available:
456 raise OTPGeneratorException(
457 f'{hash_algo} is not supported by this version of the '
458 'hashlib module')
459 return hash_algo
460
461 @staticmethod
462 def validate_seed(seed):
463 """
464 Validates the provided seed as defined by RFC-2289.
465
466 :param seed: The seed received from the challenge, defaults to ''
467 :type seed: str
468
469 :raises OTPGeneratorException: In case seed does not validate
470
471 :return: The validated (and very same) seed
472 :rtype: str
473 """
474 if not isinstance(seed, str):
475 raise OTPGeneratorException('Seed must be a string')
476 if not seed or len(seed) > 16:
477 raise OTPGeneratorException(
478 'The seed MUST be of 1 to 16 characters in length')
479 for char in seed:
480 if char not in string.ascii_letters + string.digits:
481 raise OTPGeneratorException(
482 'The seed MUST consist of purely alphanumeric characters')
483 return seed
484
421 def generate_otp_hexdigest(self, step): 485 def generate_otp_hexdigest(self, step):
422 """ 486 """
423 Generates the OTP hexdigest for the given step 487 Generates the OTP hexdigest for the given step.
424 488
425 Keyword Arguments: 489 Keyword Arguments:
426 :param step: the step to generate OTP for 490 :param step: The step to generate OTP for
427 :type step: int 491 :type step: int
428 492
429 :return: hexdigest for the given step 493 :return: Hexdigest for the given step
430 :rtype: str 494 :rtype: str
431 """ 495 """
432 return '0x' + binascii.hexlify(self._generate_otp_bytes(step)).decode() 496 return '0x' + binascii.hexlify(self._generate_otp_bytes(step)).decode()
433 497
498 def generate_otp_hexdigest_from_challenge(self, challenge):
499 """
500 Same as generate_otp_hexdigest, but it generates hex. from a challenge.
501
502 RFC-2289 states:
503 The challenge MUST be in a standard syntax so
504 that automated generators can recognize the challenge in context and
505 extract these parameters. The syntax of the challenge is:
506 otp-<algorithm identifier> <sequence integer> <seed>
507
508 Keyword Arguments:
509 :param challenge: The challenge string
510 :type challenge: str
511
512 :return: Hexdigest for the given challenge
513 :rtype: str
514 """
515 seed, hash_algo, step = self.get_tokens_from_challenge(challenge)
516 self._seed = self.validate_seed(seed)
517 self._hash_algo = self.validate_hash_algo(hash_algo)
518 return self.generate_otp_hexdigest(step)
519
434 def generate_otp_words(self, step): 520 def generate_otp_words(self, step):
435 """ 521 """
436 Generates the OTP six words token for the given step 522 Generates the OTP six words token for the given step.
437 523
438 Keyword Arguments: 524 Keyword Arguments:
439 :param step: the step to generate OTP for 525 :param step: The step to generate OTP for
440 :type step: int 526 :type step: int
441 527
442 :return: six words (separated by single space) token for the given step 528 :return: Six words (separated by single space) token for the given step
443 :rtype: str 529 :rtype: str
444 """ 530 """
445 digest = self._generate_otp_bytes(step) 531 digest = self._generate_otp_bytes(step)
@@ -457,6 +543,28 @@ class OTPGenerator:
457 bit_stream[55:64] + '{0:0>8b}'.format(bit_pair_sum)[-2:], 2)]) 543 bit_stream[55:64] + '{0:0>8b}'.format(bit_pair_sum)[-2:], 2)])
458 return ' '.join(tokens) 544 return ' '.join(tokens)
459 545
546 def generate_otp_words_from_challenge(self, challenge):
547 """
548 Same as generate_otp_words, but it generates words from a challenge.
549
550 RFC-2289 states:
551 The challenge MUST be in a standard syntax so
552 that automated generators can recognize the challenge in context and
553 extract these parameters. The syntax of the challenge is:
554 otp-<algorithm identifier> <sequence integer> <seed>
555
556 Keyword Arguments:
557 :param challenge: The challenge string
558 :type challenge: str
559
560 :return: Six words token for the given challenge
561 :rtype: str
562 """
563 seed, hash_algo, step = self.get_tokens_from_challenge(challenge)
564 self._seed = self.validate_seed(seed)
565 self._hash_algo = self.validate_hash_algo(hash_algo)
566 return self.generate_otp_words(step)
567
460 def hexdigest_range(self, start=499, stop=0): 568 def hexdigest_range(self, start=499, stop=0):
461 """ 569 """
462 Returns an iterator that providing hexdigests corresponding to steps 570 Returns an iterator that providing hexdigests corresponding to steps
@@ -503,13 +611,13 @@ class OTPGenerator:
503 611
504 def _generate_otp_bytes(self, step): 612 def _generate_otp_bytes(self, step):
505 """ 613 """
506 Generates the OTP bytes for the given step 614 Generates the OTP bytes for the given step.
507 615
508 Keyword Arguments: 616 Keyword Arguments:
509 :param step: the step to generate OTP for 617 :param step: The step to generate OTP for
510 :type step: int 618 :type step: int
511 619
512 :return: the digest bytes for the given step 620 :return: The digest bytes for the given step
513 :rtype: bytes 621 :rtype: bytes
514 """ 622 """
515 if not isinstance(step, int): 623 if not isinstance(step, int):
diff --git a/otp2289/server.py b/otp2289/server.py
new file mode 100644
index 0000000..86c4fa5
--- /dev/null
+++ b/otp2289/server.py
@@ -0,0 +1,4 @@
1# -*- coding: utf-8 -*-
2"""
3A pure Python implementation of the RFC-2289 OTP server
4"""
diff --git a/tests/test_generator.py b/tests/test_generator.py
index 14258da..a4af3ba 100644
--- a/tests/test_generator.py
+++ b/tests/test_generator.py
@@ -5,9 +5,38 @@ import pytest
5import otp2289 5import otp2289
6 6
7 7
8def test_exceptions_and_constraints(): 8def test_caller_exceptions():
9 """Tests general exceptions and constraints""" 9 """Tests the exceptions when calling an initialized object"""
10 # test the otp2289.OTPGenerator __init__ 10 gen = otp2289.OTPGenerator('This is a test.'.encode(),
11 'TeSt',
12 otp2289.OTP_ALGO_MD5)
13 with pytest.raises(otp2289.OTPGeneratorException) as exc_info:
14 gen.generate_otp_words('3')
15 assert exc_info.type is otp2289.OTPGeneratorException
16 assert exc_info.value.args[0] == 'Step value MUST be an int'
17 with pytest.raises(otp2289.OTPGeneratorException) as exc_info:
18 gen.generate_otp_hexdigest(-1)
19 assert exc_info.type is otp2289.OTPGeneratorException
20 assert exc_info.value.args[0] == 'Step value MUST be >= 0'
21 with pytest.raises(otp2289.OTPChallengeException) as exc_info:
22 gen.generate_otp_hexdigest_from_challenge(b'md5 fbd TeSt')
23 assert exc_info.type is otp2289.OTPChallengeException
24 assert exc_info.value.args[0] == 'Challenge must be str'
25 with pytest.raises(otp2289.OTPChallengeException) as exc_info:
26 gen.generate_otp_hexdigest_from_challenge('md5 fbd TeSt')
27 assert exc_info.type is otp2289.OTPChallengeException
28 assert exc_info.value.args[0] == 'Invalid challenge'
29 with pytest.raises(otp2289.generator.OTPChallengeException) as exc_info:
30 gen.generate_otp_hexdigest_from_challenge('otp-md5 fbd TeSt')
31 assert exc_info.type is otp2289.generator.OTPChallengeException
32 assert exc_info.value.args[0] == 'Invalid challenge'
33
34
35def test_constructor_exceptions():
36 """
37 Tests the exceptions when initializing a new object (in the constructor)
38 """
39 # test the otp2289.OTPGenerator __init__ and validators
11 with pytest.raises(otp2289.OTPGeneratorException) as exc_info: 40 with pytest.raises(otp2289.OTPGeneratorException) as exc_info:
12 otp2289.OTPGenerator('1234567', 'TeStø'.encode(), otp2289.OTP_ALGO_MD5) 41 otp2289.OTPGenerator('1234567', 'TeStø'.encode(), otp2289.OTP_ALGO_MD5)
13 assert exc_info.type is otp2289.OTPGeneratorException 42 assert exc_info.type is otp2289.OTPGeneratorException
@@ -38,17 +67,23 @@ def test_exceptions_and_constraints():
38 assert exc_info.type is otp2289.OTPGeneratorException 67 assert exc_info.type is otp2289.OTPGeneratorException
39 assert exc_info.value.args[0] == ('The seed MUST consist of purely ' 68 assert exc_info.value.args[0] == ('The seed MUST consist of purely '
40 'alphanumeric characters') 69 'alphanumeric characters')
41 gen = otp2289.OTPGenerator('This is a test.'.encode(),
42 'TeSt',
43 otp2289.OTP_ALGO_MD5)
44 with pytest.raises(otp2289.OTPGeneratorException) as exc_info: 70 with pytest.raises(otp2289.OTPGeneratorException) as exc_info:
45 gen.generate_otp_words('3') 71 otp2289.OTPGenerator('This is a test.'.encode(), 'TeSt', 9)
46 assert exc_info.type is otp2289.OTPGeneratorException 72 assert exc_info.type is otp2289.OTPGeneratorException
47 assert exc_info.value.args[0] == 'Step value MUST be an int' 73 assert exc_info.value.args[0] == (
74 'hash_algo is not among the known algorithms')
48 with pytest.raises(otp2289.OTPGeneratorException) as exc_info: 75 with pytest.raises(otp2289.OTPGeneratorException) as exc_info:
49 gen.generate_otp_hexdigest(-1) 76 otp2289.OTPGenerator('This is a test.'.encode(), 'TeSt', b'md5')
50 assert exc_info.type is otp2289.OTPGeneratorException 77 assert exc_info.type is otp2289.OTPGeneratorException
51 assert exc_info.value.args[0] == 'Step value MUST be >= 0' 78 assert exc_info.value.args[0] == 'hash_algo must be an int or a str'
79 # test the package structure as well
80 with pytest.raises(otp2289.generator.OTPGeneratorException) as exc_info:
81 otp2289.generator.OTPGenerator('This is a test.'.encode(),
82 'TeSt',
83 'foo')
84 assert exc_info.type is otp2289.generator.OTPGeneratorException
85 assert exc_info.value.args[0] == ('foo is not supported by this version '
86 'of the hashlib module')
52 87
53 88
54def test_md5(): 89def test_md5():
@@ -71,9 +106,17 @@ def test_md5():
71 # step 1 106 # step 1
72 assert gen.generate_otp_hexdigest(1) == '0x7965e05436f5029f' 107 assert gen.generate_otp_hexdigest(1) == '0x7965e05436f5029f'
73 assert gen.generate_otp_words(1) == 'EASE OIL FUM CURE AWRY AVIS' 108 assert gen.generate_otp_words(1) == 'EASE OIL FUM CURE AWRY AVIS'
109 assert gen.generate_otp_hexdigest_from_challenge('otp-md5 1 TeSt') == (
110 '0x7965e05436f5029f')
111 assert gen.generate_otp_words_from_challenge('otp-md5 1 TeSt') == (
112 'EASE OIL FUM CURE AWRY AVIS')
74 # step 99 113 # step 99
75 assert gen.generate_otp_hexdigest(99) == '0x50fe1962c4965880' 114 assert gen.generate_otp_hexdigest(99) == '0x50fe1962c4965880'
76 assert gen.generate_otp_words(99) == 'BAIL TUFT BITS GANG CHEF THY' 115 assert gen.generate_otp_words(99) == 'BAIL TUFT BITS GANG CHEF THY'
116 assert gen.generate_otp_hexdigest_from_challenge('otp-md5 99 TeSt') == (
117 '0x50fe1962c4965880')
118 assert gen.generate_otp_words_from_challenge('otp-md5 99 TeSt') == (
119 'BAIL TUFT BITS GANG CHEF THY')
77 # iterator test 120 # iterator test
78 hexdigests = list(gen.hexdigest_range(105)) # testing the range itself 121 hexdigests = list(gen.hexdigest_range(105)) # testing the range itself
79 words = list(gen.words_range(99)) 122 words = list(gen.words_range(99))
@@ -126,8 +169,16 @@ def test_sha1():
126 assert res_words == 'MILT VARY MAST OK SEES WENT' 169 assert res_words == 'MILT VARY MAST OK SEES WENT'
127 assert gen.generate_otp_hexdigest(1) == '0x63d936639734385b' 170 assert gen.generate_otp_hexdigest(1) == '0x63d936639734385b'
128 assert gen.generate_otp_words(1) == 'CART OTTO HIVE ODE VAT NUT' 171 assert gen.generate_otp_words(1) == 'CART OTTO HIVE ODE VAT NUT'
172 assert gen.generate_otp_hexdigest_from_challenge('otp-sha1 1 TeSt') == (
173 '0x63d936639734385b')
174 assert gen.generate_otp_words_from_challenge('otp-sha1 1 TeSt') == (
175 'CART OTTO HIVE ODE VAT NUT')
129 assert gen.generate_otp_hexdigest(99) == '0x87fec7768b73ccf9' 176 assert gen.generate_otp_hexdigest(99) == '0x87fec7768b73ccf9'
130 assert gen.generate_otp_words(99) == 'GAFF WAIT SKID GIG SKY EYED' 177 assert gen.generate_otp_words(99) == 'GAFF WAIT SKID GIG SKY EYED'
178 assert gen.generate_otp_hexdigest_from_challenge('otp-sha1 99 TeSt') == (
179 '0x87fec7768b73ccf9')
180 assert gen.generate_otp_words_from_challenge('otp-sha1 99 TeSt') == (
181 'GAFF WAIT SKID GIG SKY EYED')
131 # iterator test 182 # iterator test
132 hexdigests = list(gen.hexdigest_range(105)) 183 hexdigests = list(gen.hexdigest_range(105))
133 words = list(gen.words_range(99)) 184 words = list(gen.words_range(99))