1"""passlib.tests -- test passlib.totp""" 2#============================================================================= 3# imports 4#============================================================================= 5# core 6import datetime 7from functools import partial 8import logging; log = logging.getLogger(__name__) 9import sys 10import time as _time 11# site 12# pkg 13from passlib import exc 14from passlib.utils.compat import unicode, u 15from passlib.tests.utils import TestCase, time_call 16# subject 17from passlib import totp as totp_module 18from passlib.totp import TOTP, AppWallet, AES_SUPPORT 19# local 20__all__ = [ 21 "EngineTest", 22] 23 24#============================================================================= 25# helpers 26#============================================================================= 27 28# XXX: python 3 changed what error base64.b16decode() throws, from TypeError to base64.Error(). 29# it wasn't until 3.3 that base32decode() also got changed. 30# really should normalize this in the code to a single BinaryDecodeError, 31# predicting this cross-version is getting unmanagable. 32Base32DecodeError = Base16DecodeError = TypeError 33if sys.version_info >= (3,0): 34 from binascii import Error as Base16DecodeError 35if sys.version_info >= (3,3): 36 from binascii import Error as Base32DecodeError 37 38PASS1 = "abcdef" 39PASS2 = b"\x00\xFF" 40KEY1 = '4AOGGDBBQSYHNTUZ' 41KEY1_RAW = b'\xe0\x1cc\x0c!\x84\xb0v\xce\x99' 42KEY2_RAW = b'\xee]\xcb9\x870\x06 D\xc8y/\xa54&\xe4\x9c\x13\xc2\x18' 43KEY3 = 'S3JDVB7QD2R7JPXX' # used in docstrings 44KEY4 = 'JBSWY3DPEHPK3PXP' # from google keyuri spec 45KEY4_RAW = b'Hello!\xde\xad\xbe\xef' 46 47# NOTE: for randtime() below, 48# * want at least 7 bits on fractional side, to test fractional times to at least 0.01s precision 49# * want at least 32 bits on integer side, to test for 32-bit epoch issues. 50# most systems *should* have 53 bit mantissa, leaving plenty of room on both ends, 51# so using (1<<37) as scale, to allocate 16 bits on fractional side, but generate reasonable # of > 1<<32 times. 52# sanity check that we're above 44 ensures minimum requirements (44 - 37 int = 7 frac) 53assert sys.float_info.radix == 2, "unexpected float_info.radix" 54assert sys.float_info.mant_dig >= 44, "double precision unexpectedly small" 55 56def _get_max_time_t(): 57 """ 58 helper to calc max_time_t constant (see below) 59 """ 60 value = 1 << 30 # even for 32 bit systems will handle this 61 year = 0 62 while True: 63 next_value = value << 1 64 try: 65 next_year = datetime.datetime.utcfromtimestamp(next_value-1).year 66 except (ValueError, OSError, OverflowError): 67 # utcfromtimestamp() may throw any of the following: 68 # 69 # * year out of range for datetime: 70 # py < 3.6 throws ValueError. 71 # (py 3.6.0 returns odd value instead, see workaround below) 72 # 73 # * int out of range for host's gmtime/localtime: 74 # py2 throws ValueError, py3 throws OSError. 75 # 76 # * int out of range for host's time_t: 77 # py2 throws ValueError, py3 throws OverflowError. 78 # 79 break 80 81 # Workaround for python 3.6.0 issue -- 82 # Instead of throwing ValueError if year out of range for datetime, 83 # Python 3.6 will do some weird behavior that masks high bits 84 # e.g. (1<<40) -> year 36812, but (1<<41) -> year 6118. 85 # (Appears to be bug http://bugs.python.org/issue29100) 86 # This check stops at largest non-wrapping bit size. 87 if next_year < year: 88 break 89 90 value = next_value 91 92 # 'value-1' is maximum. 93 value -= 1 94 95 # check for crazy case where we're beyond what datetime supports 96 # (caused by bug 29100 again). compare to max value that datetime 97 # module supports -- datetime.datetime(9999, 12, 31, 23, 59, 59, 999999) 98 max_datetime_timestamp = 253402318800 99 return min(value, max_datetime_timestamp) 100 101#: Rough approximation of max value acceptable by hosts's time_t. 102#: This is frequently ~2**37 on 64 bit, and ~2**31 on 32 bit systems. 103max_time_t = _get_max_time_t() 104 105def to_b32_size(raw_size): 106 return (raw_size * 8 + 4) // 5 107 108#============================================================================= 109# wallet 110#============================================================================= 111class AppWalletTest(TestCase): 112 descriptionPrefix = "passlib.totp.AppWallet" 113 114 #============================================================================= 115 # constructor 116 #============================================================================= 117 118 def test_secrets_types(self): 119 """constructor -- 'secrets' param -- input types""" 120 121 # no secrets 122 wallet = AppWallet() 123 self.assertEqual(wallet._secrets, {}) 124 self.assertFalse(wallet.has_secrets) 125 126 # dict 127 ref = {"1": b"aaa", "2": b"bbb"} 128 wallet = AppWallet(ref) 129 self.assertEqual(wallet._secrets, ref) 130 self.assertTrue(wallet.has_secrets) 131 132 # # list 133 # wallet = AppWallet(list(ref.items())) 134 # self.assertEqual(wallet._secrets, ref) 135 136 # # iter 137 # wallet = AppWallet(iter(ref.items())) 138 # self.assertEqual(wallet._secrets, ref) 139 140 # "tag:value" string 141 wallet = AppWallet("\n 1: aaa\n# comment\n \n2: bbb ") 142 self.assertEqual(wallet._secrets, ref) 143 144 # ensure ":" allowed in secret 145 wallet = AppWallet("1: aaa: bbb \n# comment\n \n2: bbb ") 146 self.assertEqual(wallet._secrets, {"1": b"aaa: bbb", "2": b"bbb"}) 147 148 # json dict 149 wallet = AppWallet('{"1":"aaa","2":"bbb"}') 150 self.assertEqual(wallet._secrets, ref) 151 152 # # json list 153 # wallet = AppWallet('[["1","aaa"],["2","bbb"]]') 154 # self.assertEqual(wallet._secrets, ref) 155 156 # invalid type 157 self.assertRaises(TypeError, AppWallet, 123) 158 159 # invalid json obj 160 self.assertRaises(TypeError, AppWallet, "[123]") 161 162 # # invalid list items 163 # self.assertRaises(ValueError, AppWallet, ["1", b"aaa"]) 164 165 # forbid empty secret 166 self.assertRaises(ValueError, AppWallet, {"1": "aaa", "2": ""}) 167 168 def test_secrets_tags(self): 169 """constructor -- 'secrets' param -- tag/value normalization""" 170 171 # test reference 172 ref = {"1": b"aaa", "02": b"bbb", "C": b"ccc"} 173 wallet = AppWallet(ref) 174 self.assertEqual(wallet._secrets, ref) 175 176 # accept unicode 177 wallet = AppWallet({u("1"): b"aaa", u("02"): b"bbb", u("C"): b"ccc"}) 178 self.assertEqual(wallet._secrets, ref) 179 180 # normalize int tags 181 wallet = AppWallet({1: b"aaa", "02": b"bbb", "C": b"ccc"}) 182 self.assertEqual(wallet._secrets, ref) 183 184 # forbid non-str/int tags 185 self.assertRaises(TypeError, AppWallet, {(1,): "aaa"}) 186 187 # accept valid tags 188 wallet = AppWallet({"1-2_3.4": b"aaa"}) 189 190 # forbid invalid tags 191 self.assertRaises(ValueError, AppWallet, {"-abc": "aaa"}) 192 self.assertRaises(ValueError, AppWallet, {"ab*$": "aaa"}) 193 194 # coerce value to bytes 195 wallet = AppWallet({"1": u("aaa"), "02": "bbb", "C": b"ccc"}) 196 self.assertEqual(wallet._secrets, ref) 197 198 # forbid invalid value types 199 self.assertRaises(TypeError, AppWallet, {"1": 123}) 200 self.assertRaises(TypeError, AppWallet, {"1": None}) 201 self.assertRaises(TypeError, AppWallet, {"1": []}) 202 203 # TODO: test secrets_path 204 205 def test_default_tag(self): 206 """constructor -- 'default_tag' param""" 207 208 # should sort numerically 209 wallet = AppWallet({"1": "one", "02": "two"}) 210 self.assertEqual(wallet.default_tag, "02") 211 self.assertEqual(wallet.get_secret(wallet.default_tag), b"two") 212 213 # should sort alphabetically if non-digit present 214 wallet = AppWallet({"1": "one", "02": "two", "A": "aaa"}) 215 self.assertEqual(wallet.default_tag, "A") 216 self.assertEqual(wallet.get_secret(wallet.default_tag), b"aaa") 217 218 # should use honor custom tag 219 wallet = AppWallet({"1": "one", "02": "two", "A": "aaa"}, default_tag="1") 220 self.assertEqual(wallet.default_tag, "1") 221 self.assertEqual(wallet.get_secret(wallet.default_tag), b"one") 222 223 # throw error on unknown value 224 self.assertRaises(KeyError, AppWallet, {"1": "one", "02": "two", "A": "aaa"}, 225 default_tag="B") 226 227 # should be empty 228 wallet = AppWallet() 229 self.assertEqual(wallet.default_tag, None) 230 self.assertRaises(KeyError, wallet.get_secret, None) 231 232 # TODO: test 'cost' param 233 234 #============================================================================= 235 # encrypt_key() & decrypt_key() helpers 236 #============================================================================= 237 def require_aes_support(self, canary=None): 238 if AES_SUPPORT: 239 canary and canary() 240 else: 241 canary and self.assertRaises(RuntimeError, canary) 242 raise self.skipTest("'cryptography' package not installed") 243 244 def test_decrypt_key(self): 245 """.decrypt_key()""" 246 247 wallet = AppWallet({"1": PASS1, "2": PASS2}) 248 249 # check for support 250 CIPHER1 = dict(v=1, c=13, s='6D7N7W53O7HHS37NLUFQ', 251 k='MHCTEGSNPFN5CGBJ', t='1') 252 self.require_aes_support(canary=partial(wallet.decrypt_key, CIPHER1)) 253 254 # reference key 255 self.assertEqual(wallet.decrypt_key(CIPHER1)[0], KEY1_RAW) 256 257 # different salt used to encrypt same raw key 258 CIPHER2 = dict(v=1, c=13, s='SPZJ54Y6IPUD2BYA4C6A', 259 k='ZGDXXTVQOWYLC2AU', t='1') 260 self.assertEqual(wallet.decrypt_key(CIPHER2)[0], KEY1_RAW) 261 262 # different sized key, password, and cost 263 CIPHER3 = dict(v=1, c=8, s='FCCTARTIJWE7CPQHUDKA', 264 k='D2DRS32YESGHHINWFFCELKN7Z6NAHM4M', t='2') 265 self.assertEqual(wallet.decrypt_key(CIPHER3)[0], KEY2_RAW) 266 267 # wrong password should silently result in wrong key 268 temp = CIPHER1.copy() 269 temp.update(t='2') 270 self.assertEqual(wallet.decrypt_key(temp)[0], b'\xafD6.F7\xeb\x19\x05Q') 271 272 # missing tag should throw error 273 temp = CIPHER1.copy() 274 temp.update(t='3') 275 self.assertRaises(KeyError, wallet.decrypt_key, temp) 276 277 # unknown version should throw error 278 temp = CIPHER1.copy() 279 temp.update(v=999) 280 self.assertRaises(ValueError, wallet.decrypt_key, temp) 281 282 def test_decrypt_key_needs_recrypt(self): 283 """.decrypt_key() -- needs_recrypt flag""" 284 self.require_aes_support() 285 286 wallet = AppWallet({"1": PASS1, "2": PASS2}, encrypt_cost=13) 287 288 # ref should be accepted 289 ref = dict(v=1, c=13, s='AAAA', k='AAAA', t='2') 290 self.assertFalse(wallet.decrypt_key(ref)[1]) 291 292 # wrong cost 293 temp = ref.copy() 294 temp.update(c=8) 295 self.assertTrue(wallet.decrypt_key(temp)[1]) 296 297 # wrong tag 298 temp = ref.copy() 299 temp.update(t="1") 300 self.assertTrue(wallet.decrypt_key(temp)[1]) 301 302 # XXX: should this check salt_size? 303 304 def assertSaneResult(self, result, wallet, key, tag="1", 305 needs_recrypt=False): 306 """check encrypt_key() result has expected format""" 307 308 self.assertEqual(set(result), set(["v", "t", "c", "s", "k"])) 309 310 self.assertEqual(result['v'], 1) 311 self.assertEqual(result['t'], tag) 312 self.assertEqual(result['c'], wallet.encrypt_cost) 313 314 self.assertEqual(len(result['s']), to_b32_size(wallet.salt_size)) 315 self.assertEqual(len(result['k']), to_b32_size(len(key))) 316 317 result_key, result_needs_recrypt = wallet.decrypt_key(result) 318 self.assertEqual(result_key, key) 319 self.assertEqual(result_needs_recrypt, needs_recrypt) 320 321 def test_encrypt_key(self): 322 """.encrypt_key()""" 323 324 # check for support 325 wallet = AppWallet({"1": PASS1}, encrypt_cost=5) 326 self.require_aes_support(canary=partial(wallet.encrypt_key, KEY1_RAW)) 327 328 # basic behavior 329 result = wallet.encrypt_key(KEY1_RAW) 330 self.assertSaneResult(result, wallet, KEY1_RAW) 331 332 # creates new salt each time 333 other = wallet.encrypt_key(KEY1_RAW) 334 self.assertSaneResult(result, wallet, KEY1_RAW) 335 self.assertNotEqual(other['s'], result['s']) 336 self.assertNotEqual(other['k'], result['k']) 337 338 # honors custom cost 339 wallet2 = AppWallet({"1": PASS1}, encrypt_cost=6) 340 result = wallet2.encrypt_key(KEY1_RAW) 341 self.assertSaneResult(result, wallet2, KEY1_RAW) 342 343 # honors default tag 344 wallet2 = AppWallet({"1": PASS1, "2": PASS2}) 345 result = wallet2.encrypt_key(KEY1_RAW) 346 self.assertSaneResult(result, wallet2, KEY1_RAW, tag="2") 347 348 # honor salt size 349 wallet2 = AppWallet({"1": PASS1}) 350 wallet2.salt_size = 64 351 result = wallet2.encrypt_key(KEY1_RAW) 352 self.assertSaneResult(result, wallet2, KEY1_RAW) 353 354 # larger key 355 result = wallet.encrypt_key(KEY2_RAW) 356 self.assertSaneResult(result, wallet, KEY2_RAW) 357 358 # border case: empty key 359 # XXX: might want to allow this, but documenting behavior for now 360 self.assertRaises(ValueError, wallet.encrypt_key, b"") 361 362 def test_encrypt_cost_timing(self): 363 """verify cost parameter via timing""" 364 self.require_aes_support() 365 366 # time default cost 367 wallet = AppWallet({"1": "aaa"}) 368 wallet.encrypt_cost -= 2 369 delta, _ = time_call(partial(wallet.encrypt_key, KEY1_RAW), maxtime=0) 370 371 # this should take (2**3=8) times as long 372 wallet.encrypt_cost += 3 373 delta2, _ = time_call(partial(wallet.encrypt_key, KEY1_RAW), maxtime=0) 374 375 # TODO: rework timing test here to inject mock pbkdf2_hmac() function instead; 376 # and test that it's being invoked w/ proper options. 377 self.assertAlmostEqual(delta2, delta*8, delta=(delta*8)*0.5) 378 379 #============================================================================= 380 # eoc 381 #============================================================================= 382 383#============================================================================= 384# common OTP code 385#============================================================================= 386 387#: used as base value for RFC test vector keys 388RFC_KEY_BYTES_20 = "12345678901234567890".encode("ascii") 389RFC_KEY_BYTES_32 = (RFC_KEY_BYTES_20*2)[:32] 390RFC_KEY_BYTES_64 = (RFC_KEY_BYTES_20*4)[:64] 391 392# TODO: this class is separate from TotpTest due to historical issue, 393# when there was a base class, and a separate HOTP class. 394# these test case classes should probably be combined. 395class TotpTest(TestCase): 396 """ 397 common code shared by TotpTest & HotpTest 398 """ 399 #============================================================================= 400 # class attrs 401 #============================================================================= 402 403 descriptionPrefix = "passlib.totp.TOTP" 404 405 #============================================================================= 406 # setup 407 #============================================================================= 408 def setUp(self): 409 super(TotpTest, self).setUp() 410 411 # clear norm_hash_name() cache so 'unknown hash' warnings get emitted each time 412 from passlib.crypto.digest import lookup_hash 413 lookup_hash.clear_cache() 414 415 # monkeypatch module's rng to be deterministic 416 self.patchAttr(totp_module, "rng", self.getRandom()) 417 418 #============================================================================= 419 # general helpers 420 #============================================================================= 421 def randtime(self): 422 """ 423 helper to generate random epoch time 424 :returns float: epoch time 425 """ 426 return self.getRandom().random() * max_time_t 427 428 def randotp(self, cls=None, **kwds): 429 """ 430 helper which generates a random TOTP instance. 431 """ 432 rng = self.getRandom() 433 if "key" not in kwds: 434 kwds['new'] = True 435 kwds.setdefault("digits", rng.randint(6, 10)) 436 kwds.setdefault("alg", rng.choice(["sha1", "sha256", "sha512"])) 437 kwds.setdefault("period", rng.randint(10, 120)) 438 return (cls or TOTP)(**kwds) 439 440 def test_randotp(self): 441 """ 442 internal test -- randotp() 443 """ 444 otp1 = self.randotp() 445 otp2 = self.randotp() 446 447 self.assertNotEqual(otp1.key, otp2.key, "key not randomized:") 448 449 # NOTE: has (1/5)**10 odds of failure 450 for _ in range(10): 451 if otp1.digits != otp2.digits: 452 break 453 otp2 = self.randotp() 454 else: 455 self.fail("digits not randomized") 456 457 # NOTE: has (1/3)**10 odds of failure 458 for _ in range(10): 459 if otp1.alg != otp2.alg: 460 break 461 otp2 = self.randotp() 462 else: 463 self.fail("alg not randomized") 464 465 #============================================================================= 466 # reference vector helpers 467 #============================================================================= 468 469 #: default options used by test vectors (unless otherwise stated) 470 vector_defaults = dict(format="base32", alg="sha1", period=30, digits=8) 471 472 #: various TOTP test vectors, 473 #: each element in list has format [options, (time, token <, int(expires)>), ...] 474 vectors = [ 475 476 #------------------------------------------------------------------------- 477 # passlib test vectors 478 #------------------------------------------------------------------------- 479 480 # 10 byte key, 6 digits 481 [dict(key="ACDEFGHJKL234567", digits=6), 482 # test fencepost to make sure we're rounding right 483 (1412873399, '221105'), # == 29 mod 30 484 (1412873400, '178491'), # == 0 mod 30 485 (1412873401, '178491'), # == 1 mod 30 486 (1412873429, '178491'), # == 29 mod 30 487 (1412873430, '915114'), # == 0 mod 30 488 ], 489 490 # 10 byte key, 8 digits 491 [dict(key="ACDEFGHJKL234567", digits=8), 492 # should be same as 6 digits (above), but w/ 2 more digits on left side of token. 493 (1412873399, '20221105'), # == 29 mod 30 494 (1412873400, '86178491'), # == 0 mod 30 495 (1412873401, '86178491'), # == 1 mod 30 496 (1412873429, '86178491'), # == 29 mod 30 497 (1412873430, '03915114'), # == 0 mod 30 498 ], 499 500 # sanity check on key used in docstrings 501 [dict(key="S3JD-VB7Q-D2R7-JPXX", digits=6), 502 (1419622709, '000492'), 503 (1419622739, '897212'), 504 ], 505 506 #------------------------------------------------------------------------- 507 # reference vectors taken from http://tools.ietf.org/html/rfc6238, appendix B 508 # NOTE: while appendix B states same key used for all tests, the reference 509 # code in the appendix repeats the key up to the alg's block size, 510 # and uses *that* as the secret... so that's what we're doing here. 511 #------------------------------------------------------------------------- 512 513 # sha1 test vectors 514 [dict(key=RFC_KEY_BYTES_20, format="raw", alg="sha1"), 515 (59, '94287082'), 516 (1111111109, '07081804'), 517 (1111111111, '14050471'), 518 (1234567890, '89005924'), 519 (2000000000, '69279037'), 520 (20000000000, '65353130'), 521 ], 522 523 # sha256 test vectors 524 [dict(key=RFC_KEY_BYTES_32, format="raw", alg="sha256"), 525 (59, '46119246'), 526 (1111111109, '68084774'), 527 (1111111111, '67062674'), 528 (1234567890, '91819424'), 529 (2000000000, '90698825'), 530 (20000000000, '77737706'), 531 ], 532 533 # sha512 test vectors 534 [dict(key=RFC_KEY_BYTES_64, format="raw", alg="sha512"), 535 (59, '90693936'), 536 (1111111109, '25091201'), 537 (1111111111, '99943326'), 538 (1234567890, '93441116'), 539 (2000000000, '38618901'), 540 (20000000000, '47863826'), 541 ], 542 543 #------------------------------------------------------------------------- 544 # other test vectors 545 #------------------------------------------------------------------------- 546 547 # generated at http://blog.tinisles.com/2011/10/google-authenticator-one-time-password-algorithm-in-javascript 548 [dict(key="JBSWY3DPEHPK3PXP", digits=6), (1409192430, '727248'), (1419890990, '122419')], 549 [dict(key="JBSWY3DPEHPK3PXP", digits=9, period=41), (1419891152, '662331049')], 550 551 # found in https://github.com/eloquent/otis/blob/develop/test/suite/Totp/Value/TotpValueGeneratorTest.php, line 45 552 [dict(key=RFC_KEY_BYTES_20, format="raw", period=60), (1111111111, '19360094')], 553 [dict(key=RFC_KEY_BYTES_32, format="raw", alg="sha256", period=60), (1111111111, '40857319')], 554 [dict(key=RFC_KEY_BYTES_64, format="raw", alg="sha512", period=60), (1111111111, '37023009')], 555 556 ] 557 558 def iter_test_vectors(self): 559 """ 560 helper to iterate over test vectors. 561 yields ``(totp, time, token, expires, prefix)`` tuples. 562 """ 563 from passlib.totp import TOTP 564 for row in self.vectors: 565 kwds = self.vector_defaults.copy() 566 kwds.update(row[0]) 567 for entry in row[1:]: 568 if len(entry) == 3: 569 time, token, expires = entry 570 else: 571 time, token = entry 572 expires = None 573 # NOTE: not re-using otp between calls so that stateful methods 574 # (like .match) don't have problems. 575 log.debug("test vector: %r time=%r token=%r expires=%r", kwds, time, token, expires) 576 otp = TOTP(**kwds) 577 prefix = "alg=%r time=%r token=%r: " % (otp.alg, time, token) 578 yield otp, time, token, expires, prefix 579 580 #============================================================================= 581 # constructor tests 582 #============================================================================= 583 def test_ctor_w_new(self): 584 """constructor -- 'new' parameter""" 585 586 # exactly one of 'key' or 'new' is required 587 self.assertRaises(TypeError, TOTP) 588 self.assertRaises(TypeError, TOTP, key='4aoggdbbqsyhntuz', new=True) 589 590 # generates new key 591 otp = TOTP(new=True) 592 otp2 = TOTP(new=True) 593 self.assertNotEqual(otp.key, otp2.key) 594 595 def test_ctor_w_size(self): 596 """constructor -- 'size' parameter""" 597 598 # should default to digest size, per RFC 599 self.assertEqual(len(TOTP(new=True, alg="sha1").key), 20) 600 self.assertEqual(len(TOTP(new=True, alg="sha256").key), 32) 601 self.assertEqual(len(TOTP(new=True, alg="sha512").key), 64) 602 603 # explicit key size 604 self.assertEqual(len(TOTP(new=True, size=10).key), 10) 605 self.assertEqual(len(TOTP(new=True, size=16).key), 16) 606 607 # for new=True, maximum size enforced (based on alg) 608 self.assertRaises(ValueError, TOTP, new=True, size=21, alg="sha1") 609 610 # for new=True, minimum size enforced 611 self.assertRaises(ValueError, TOTP, new=True, size=9) 612 613 # for existing key, minimum size is only warned about 614 with self.assertWarningList([ 615 dict(category=exc.PasslibSecurityWarning, message_re=".*for security purposes, secret key must be.*") 616 ]): 617 _ = TOTP('0A'*9, 'hex') 618 619 def test_ctor_w_key_and_format(self): 620 """constructor -- 'key' and 'format' parameters""" 621 622 # handle base32 encoding (the default) 623 self.assertEqual(TOTP(KEY1).key, KEY1_RAW) 624 625 # .. w/ lower case 626 self.assertEqual(TOTP(KEY1.lower()).key, KEY1_RAW) 627 628 # .. w/ spaces (e.g. user-entered data) 629 self.assertEqual(TOTP(' 4aog gdbb qsyh ntuz ').key, KEY1_RAW) 630 631 # .. w/ invalid char 632 self.assertRaises(Base32DecodeError, TOTP, 'ao!ggdbbqsyhntuz') 633 634 # handle hex encoding 635 self.assertEqual(TOTP('e01c630c2184b076ce99', 'hex').key, KEY1_RAW) 636 637 # .. w/ invalid char 638 self.assertRaises(Base16DecodeError, TOTP, 'X01c630c2184b076ce99', 'hex') 639 640 # handle raw bytes 641 self.assertEqual(TOTP(KEY1_RAW, "raw").key, KEY1_RAW) 642 643 def test_ctor_w_alg(self): 644 """constructor -- 'alg' parameter""" 645 646 # normalize hash names 647 self.assertEqual(TOTP(KEY1, alg="SHA-256").alg, "sha256") 648 self.assertEqual(TOTP(KEY1, alg="SHA256").alg, "sha256") 649 650 # invalid alg 651 self.assertRaises(ValueError, TOTP, KEY1, alg="SHA-333") 652 653 def test_ctor_w_digits(self): 654 """constructor -- 'digits' parameter""" 655 self.assertRaises(ValueError, TOTP, KEY1, digits=5) 656 self.assertEqual(TOTP(KEY1, digits=6).digits, 6) # min value 657 self.assertEqual(TOTP(KEY1, digits=10).digits, 10) # max value 658 self.assertRaises(ValueError, TOTP, KEY1, digits=11) 659 660 def test_ctor_w_period(self): 661 """constructor -- 'period' parameter""" 662 663 # default 664 self.assertEqual(TOTP(KEY1).period, 30) 665 666 # explicit value 667 self.assertEqual(TOTP(KEY1, period=63).period, 63) 668 669 # reject wrong type 670 self.assertRaises(TypeError, TOTP, KEY1, period=1.5) 671 self.assertRaises(TypeError, TOTP, KEY1, period='abc') 672 673 # reject non-positive values 674 self.assertRaises(ValueError, TOTP, KEY1, period=0) 675 self.assertRaises(ValueError, TOTP, KEY1, period=-1) 676 677 def test_ctor_w_label(self): 678 """constructor -- 'label' parameter""" 679 self.assertEqual(TOTP(KEY1).label, None) 680 self.assertEqual(TOTP(KEY1, label="foo@bar").label, "foo@bar") 681 self.assertRaises(ValueError, TOTP, KEY1, label="foo:bar") 682 683 def test_ctor_w_issuer(self): 684 """constructor -- 'issuer' parameter""" 685 self.assertEqual(TOTP(KEY1).issuer, None) 686 self.assertEqual(TOTP(KEY1, issuer="foo.com").issuer, "foo.com") 687 self.assertRaises(ValueError, TOTP, KEY1, issuer="foo.com:bar") 688 689 #============================================================================= 690 # using() tests 691 #============================================================================= 692 693 # TODO: test using() w/ 'digits', 'alg', 'issue', 'wallet', **wallet_kwds 694 695 def test_using_w_period(self): 696 """using() -- 'period' parameter""" 697 698 # default 699 self.assertEqual(TOTP(KEY1).period, 30) 700 701 # explicit value 702 self.assertEqual(TOTP.using(period=63)(KEY1).period, 63) 703 704 # reject wrong type 705 self.assertRaises(TypeError, TOTP.using, period=1.5) 706 self.assertRaises(TypeError, TOTP.using, period='abc') 707 708 # reject non-positive values 709 self.assertRaises(ValueError, TOTP.using, period=0) 710 self.assertRaises(ValueError, TOTP.using, period=-1) 711 712 def test_using_w_now(self): 713 """using -- 'now' parameter""" 714 715 # NOTE: reading time w/ normalize_time() to make sure custom .now actually has effect. 716 717 # default -- time.time 718 otp = self.randotp() 719 self.assertIs(otp.now, _time.time) 720 self.assertAlmostEqual(otp.normalize_time(None), int(_time.time())) 721 722 # custom function 723 counter = [123.12] 724 def now(): 725 counter[0] += 1 726 return counter[0] 727 otp = self.randotp(cls=TOTP.using(now=now)) 728 # NOTE: TOTP() constructor invokes this as part of test, using up counter values 124 & 125 729 self.assertEqual(otp.normalize_time(None), 126) 730 self.assertEqual(otp.normalize_time(None), 127) 731 732 # require callable 733 self.assertRaises(TypeError, TOTP.using, now=123) 734 735 # require returns int/float 736 msg_re = r"now\(\) function must return non-negative" 737 self.assertRaisesRegex(AssertionError, msg_re, TOTP.using, now=lambda: 'abc') 738 739 # require returns non-negative value 740 self.assertRaisesRegex(AssertionError, msg_re, TOTP.using, now=lambda: -1) 741 742 #============================================================================= 743 # internal method tests 744 #============================================================================= 745 746 def test_normalize_token_instance(self, otp=None): 747 """normalize_token() -- instance method""" 748 if otp is None: 749 otp = self.randotp(digits=7) 750 751 # unicode & bytes 752 self.assertEqual(otp.normalize_token(u('1234567')), '1234567') 753 self.assertEqual(otp.normalize_token(b'1234567'), '1234567') 754 755 # int 756 self.assertEqual(otp.normalize_token(1234567), '1234567') 757 758 # int which needs 0 padding 759 self.assertEqual(otp.normalize_token(234567), '0234567') 760 761 # reject wrong types (float, None) 762 self.assertRaises(TypeError, otp.normalize_token, 1234567.0) 763 self.assertRaises(TypeError, otp.normalize_token, None) 764 765 # too few digits 766 self.assertRaises(exc.MalformedTokenError, otp.normalize_token, '123456') 767 768 # too many digits 769 self.assertRaises(exc.MalformedTokenError, otp.normalize_token, '01234567') 770 self.assertRaises(exc.MalformedTokenError, otp.normalize_token, 12345678) 771 772 def test_normalize_token_class(self): 773 """normalize_token() -- class method""" 774 self.test_normalize_token_instance(otp=TOTP.using(digits=7)) 775 776 def test_normalize_time(self): 777 """normalize_time()""" 778 TotpFactory = TOTP.using() 779 otp = self.randotp(TotpFactory) 780 781 for _ in range(10): 782 time = self.randtime() 783 tint = int(time) 784 785 self.assertEqual(otp.normalize_time(time), tint) 786 self.assertEqual(otp.normalize_time(tint + 0.5), tint) 787 788 self.assertEqual(otp.normalize_time(tint), tint) 789 790 dt = datetime.datetime.utcfromtimestamp(time) 791 self.assertEqual(otp.normalize_time(dt), tint) 792 793 orig = TotpFactory.now 794 try: 795 TotpFactory.now = staticmethod(lambda: time) 796 self.assertEqual(otp.normalize_time(None), tint) 797 finally: 798 TotpFactory.now = orig 799 800 self.assertRaises(TypeError, otp.normalize_time, '1234') 801 802 #============================================================================= 803 # key attr tests 804 #============================================================================= 805 806 def test_key_attrs(self): 807 """pretty_key() and .key attributes""" 808 rng = self.getRandom() 809 810 # test key attrs 811 otp = TOTP(KEY1_RAW, "raw") 812 self.assertEqual(otp.key, KEY1_RAW) 813 self.assertEqual(otp.hex_key, 'e01c630c2184b076ce99') 814 self.assertEqual(otp.base32_key, KEY1) 815 816 # test pretty_key() 817 self.assertEqual(otp.pretty_key(), '4AOG-GDBB-QSYH-NTUZ') 818 self.assertEqual(otp.pretty_key(sep=" "), '4AOG GDBB QSYH NTUZ') 819 self.assertEqual(otp.pretty_key(sep=False), KEY1) 820 self.assertEqual(otp.pretty_key(format="hex"), 'e01c-630c-2184-b076-ce99') 821 822 # quick fuzz test: make attr access works for random key & random size 823 otp = TOTP(new=True, size=rng.randint(10, 20)) 824 _ = otp.hex_key 825 _ = otp.base32_key 826 _ = otp.pretty_key() 827 828 #============================================================================= 829 # generate() tests 830 #============================================================================= 831 def test_totp_token(self): 832 """generate() -- TotpToken() class""" 833 from passlib.totp import TOTP, TotpToken 834 835 # test known set of values 836 otp = TOTP('s3jdvb7qd2r7jpxx') 837 result = otp.generate(1419622739) 838 self.assertIsInstance(result, TotpToken) 839 self.assertEqual(result.token, '897212') 840 self.assertEqual(result.counter, 47320757) 841 ##self.assertEqual(result.start_time, 1419622710) 842 self.assertEqual(result.expire_time, 1419622740) 843 self.assertEqual(result, ('897212', 1419622740)) 844 self.assertEqual(len(result), 2) 845 self.assertEqual(result[0], '897212') 846 self.assertEqual(result[1], 1419622740) 847 self.assertRaises(IndexError, result.__getitem__, -3) 848 self.assertRaises(IndexError, result.__getitem__, 2) 849 self.assertTrue(result) 850 851 # time dependant bits... 852 otp.now = lambda : 1419622739.5 853 self.assertEqual(result.remaining, 0.5) 854 self.assertTrue(result.valid) 855 856 otp.now = lambda : 1419622741 857 self.assertEqual(result.remaining, 0) 858 self.assertFalse(result.valid) 859 860 # same time -- shouldn't return same object, but should be equal 861 result2 = otp.generate(1419622739) 862 self.assertIsNot(result2, result) 863 self.assertEqual(result2, result) 864 865 # diff time in period -- shouldn't return same object, but should be equal 866 result3 = otp.generate(1419622711) 867 self.assertIsNot(result3, result) 868 self.assertEqual(result3, result) 869 870 # shouldn't be equal 871 result4 = otp.generate(1419622999) 872 self.assertNotEqual(result4, result) 873 874 def test_generate(self): 875 """generate()""" 876 from passlib.totp import TOTP 877 878 # generate token 879 otp = TOTP(new=True) 880 time = self.randtime() 881 result = otp.generate(time) 882 token = result.token 883 self.assertIsInstance(token, unicode) 884 start_time = result.counter * 30 885 886 # should generate same token for next 29s 887 self.assertEqual(otp.generate(start_time + 29).token, token) 888 889 # and new one at 30s 890 self.assertNotEqual(otp.generate(start_time + 30).token, token) 891 892 # verify round-trip conversion of datetime 893 dt = datetime.datetime.utcfromtimestamp(time) 894 self.assertEqual(int(otp.normalize_time(dt)), int(time)) 895 896 # handle datetime object 897 self.assertEqual(otp.generate(dt).token, token) 898 899 # omitting value should use current time 900 otp2 = TOTP.using(now=lambda: time)(key=otp.base32_key) 901 self.assertEqual(otp2.generate().token, token) 902 903 # reject invalid time 904 self.assertRaises(ValueError, otp.generate, -1) 905 906 def test_generate_w_reference_vectors(self): 907 """generate() -- reference vectors""" 908 for otp, time, token, expires, prefix in self.iter_test_vectors(): 909 # should output correct token for specified time 910 result = otp.generate(time) 911 self.assertEqual(result.token, token, msg=prefix) 912 self.assertEqual(result.counter, time // otp.period, msg=prefix) 913 if expires: 914 self.assertEqual(result.expire_time, expires) 915 916 #============================================================================= 917 # TotpMatch() tests 918 #============================================================================= 919 920 def assertTotpMatch(self, match, time, skipped=0, period=30, window=30, msg=''): 921 from passlib.totp import TotpMatch 922 923 # test type 924 self.assertIsInstance(match, TotpMatch) 925 926 # totp sanity check 927 self.assertIsInstance(match.totp, TOTP) 928 self.assertEqual(match.totp.period, period) 929 930 # test attrs 931 self.assertEqual(match.time, time, msg=msg + " matched time:") 932 expected = time // period 933 counter = expected + skipped 934 self.assertEqual(match.counter, counter, msg=msg + " matched counter:") 935 self.assertEqual(match.expected_counter, expected, msg=msg + " expected counter:") 936 self.assertEqual(match.skipped, skipped, msg=msg + " skipped:") 937 self.assertEqual(match.cache_seconds, period + window) 938 expire_time = (counter + 1) * period 939 self.assertEqual(match.expire_time, expire_time) 940 self.assertEqual(match.cache_time, expire_time + window) 941 942 # test tuple 943 self.assertEqual(len(match), 2) 944 self.assertEqual(match, (counter, time)) 945 self.assertRaises(IndexError, match.__getitem__, -3) 946 self.assertEqual(match[0], counter) 947 self.assertEqual(match[1], time) 948 self.assertRaises(IndexError, match.__getitem__, 2) 949 950 # test bool 951 self.assertTrue(match) 952 953 def test_totp_match_w_valid_token(self): 954 """match() -- valid TotpMatch object""" 955 time = 141230981 956 token = '781501' 957 otp = TOTP.using(now=lambda: time + 24 * 3600)(KEY3) 958 result = otp.match(token, time) 959 self.assertTotpMatch(result, time=time, skipped=0) 960 961 def test_totp_match_w_older_token(self): 962 """match() -- valid TotpMatch object with future token""" 963 from passlib.totp import TotpMatch 964 965 time = 141230981 966 token = '781501' 967 otp = TOTP.using(now=lambda: time + 24 * 3600)(KEY3) 968 result = otp.match(token, time - 30) 969 self.assertTotpMatch(result, time=time - 30, skipped=1) 970 971 def test_totp_match_w_new_token(self): 972 """match() -- valid TotpMatch object with past token""" 973 time = 141230981 974 token = '781501' 975 otp = TOTP.using(now=lambda: time + 24 * 3600)(KEY3) 976 result = otp.match(token, time + 30) 977 self.assertTotpMatch(result, time=time + 30, skipped=-1) 978 979 def test_totp_match_w_invalid_token(self): 980 """match() -- invalid TotpMatch object""" 981 time = 141230981 982 token = '781501' 983 otp = TOTP.using(now=lambda: time + 24 * 3600)(KEY3) 984 self.assertRaises(exc.InvalidTokenError, otp.match, token, time + 60) 985 986 #============================================================================= 987 # match() tests 988 #============================================================================= 989 990 def assertVerifyMatches(self, expect_skipped, token, time, # * 991 otp, gen_time=None, **kwds): 992 """helper to test otp.match() output is correct""" 993 # NOTE: TotpMatch return type tested more throughly above ^^^ 994 msg = "key=%r alg=%r period=%r token=%r gen_time=%r time=%r:" % \ 995 (otp.base32_key, otp.alg, otp.period, token, gen_time, time) 996 result = otp.match(token, time, **kwds) 997 self.assertTotpMatch(result, 998 time=otp.normalize_time(time), 999 period=otp.period, 1000 window=kwds.get("window", 30), 1001 skipped=expect_skipped, 1002 msg=msg) 1003 1004 def assertVerifyRaises(self, exc_class, token, time, # * 1005 otp, gen_time=None, 1006 **kwds): 1007 """helper to test otp.match() throws correct error""" 1008 # NOTE: TotpMatch return type tested more throughly above ^^^ 1009 msg = "key=%r alg=%r period=%r token=%r gen_time=%r time=%r:" % \ 1010 (otp.base32_key, otp.alg, otp.period, token, gen_time, time) 1011 return self.assertRaises(exc_class, otp.match, token, time, 1012 __msg__=msg, **kwds) 1013 1014 def test_match_w_window(self): 1015 """match() -- 'time' and 'window' parameters""" 1016 1017 # init generator & helper 1018 otp = self.randotp() 1019 period = otp.period 1020 time = self.randtime() 1021 token = otp.generate(time).token 1022 common = dict(otp=otp, gen_time=time) 1023 assertMatches = partial(self.assertVerifyMatches, **common) 1024 assertRaises = partial(self.assertVerifyRaises, **common) 1025 1026 #------------------------------- 1027 # basic validation, and 'window' parameter 1028 #------------------------------- 1029 1030 # validate against previous counter (passes if window >= period) 1031 assertRaises(exc.InvalidTokenError, token, time - period, window=0) 1032 assertMatches(+1, token, time - period, window=period) 1033 assertMatches(+1, token, time - period, window=2 * period) 1034 1035 # validate against current counter 1036 assertMatches(0, token, time, window=0) 1037 1038 # validate against next counter (passes if window >= period) 1039 assertRaises(exc.InvalidTokenError, token, time + period, window=0) 1040 assertMatches(-1, token, time + period, window=period) 1041 assertMatches(-1, token, time + period, window=2 * period) 1042 1043 # validate against two time steps later (should never pass) 1044 assertRaises(exc.InvalidTokenError, token, time + 2 * period, window=0) 1045 assertRaises(exc.InvalidTokenError, token, time + 2 * period, window=period) 1046 assertMatches(-2, token, time + 2 * period, window=2 * period) 1047 1048 # TODO: test window values that aren't multiples of period 1049 # (esp ensure counter rounding works correctly) 1050 1051 #------------------------------- 1052 # time normalization 1053 #------------------------------- 1054 1055 # handle datetimes 1056 dt = datetime.datetime.utcfromtimestamp(time) 1057 assertMatches(0, token, dt, window=0) 1058 1059 # reject invalid time 1060 assertRaises(ValueError, token, -1) 1061 1062 def test_match_w_skew(self): 1063 """match() -- 'skew' parameters""" 1064 # init generator & helper 1065 otp = self.randotp() 1066 period = otp.period 1067 time = self.randtime() 1068 common = dict(otp=otp, gen_time=time) 1069 assertMatches = partial(self.assertVerifyMatches, **common) 1070 assertRaises = partial(self.assertVerifyRaises, **common) 1071 1072 # assume client is running far behind server / has excessive transmission delay 1073 skew = 3 * period 1074 behind_token = otp.generate(time - skew).token 1075 assertRaises(exc.InvalidTokenError, behind_token, time, window=0) 1076 assertMatches(-3, behind_token, time, window=0, skew=-skew) 1077 1078 # assume client is running far ahead of server 1079 ahead_token = otp.generate(time + skew).token 1080 assertRaises(exc.InvalidTokenError, ahead_token, time, window=0) 1081 assertMatches(+3, ahead_token, time, window=0, skew=skew) 1082 1083 # TODO: test skew + larger window 1084 1085 def test_match_w_reuse(self): 1086 """match() -- 'reuse' and 'last_counter' parameters""" 1087 1088 # init generator & helper 1089 otp = self.randotp() 1090 period = otp.period 1091 time = self.randtime() 1092 tdata = otp.generate(time) 1093 token = tdata.token 1094 counter = tdata.counter 1095 expire_time = tdata.expire_time 1096 common = dict(otp=otp, gen_time=time) 1097 assertMatches = partial(self.assertVerifyMatches, **common) 1098 assertRaises = partial(self.assertVerifyRaises, **common) 1099 1100 # last counter unset -- 1101 # previous period's token should count as valid 1102 assertMatches(-1, token, time + period, window=period) 1103 1104 # last counter set 2 periods ago -- 1105 # previous period's token should count as valid 1106 assertMatches(-1, token, time + period, last_counter=counter-1, 1107 window=period) 1108 1109 # last counter set 2 periods ago -- 1110 # 2 periods ago's token should NOT count as valid 1111 assertRaises(exc.InvalidTokenError, token, time + 2 * period, 1112 last_counter=counter, window=period) 1113 1114 # last counter set 1 period ago -- 1115 # previous period's token should now be rejected as 'used' 1116 err = assertRaises(exc.UsedTokenError, token, time + period, 1117 last_counter=counter, window=period) 1118 self.assertEqual(err.expire_time, expire_time) 1119 1120 # last counter set to current period -- 1121 # current period's token should be rejected 1122 err = assertRaises(exc.UsedTokenError, token, time, 1123 last_counter=counter, window=0) 1124 self.assertEqual(err.expire_time, expire_time) 1125 1126 def test_match_w_token_normalization(self): 1127 """match() -- token normalization""" 1128 # setup test helper 1129 otp = TOTP('otxl2f5cctbprpzx') 1130 match = otp.match 1131 time = 1412889861 1132 1133 # separators / spaces should be stripped (orig token '332136') 1134 self.assertTrue(match(' 3 32-136 ', time)) 1135 1136 # ascii bytes 1137 self.assertTrue(match(b'332136', time)) 1138 1139 # too few digits 1140 self.assertRaises(exc.MalformedTokenError, match, '12345', time) 1141 1142 # invalid char 1143 self.assertRaises(exc.MalformedTokenError, match, '12345X', time) 1144 1145 # leading zeros count towards size 1146 self.assertRaises(exc.MalformedTokenError, match, '0123456', time) 1147 1148 def test_match_w_reference_vectors(self): 1149 """match() -- reference vectors""" 1150 for otp, time, token, expires, msg in self.iter_test_vectors(): 1151 # create wrapper 1152 match = otp.match 1153 1154 # token should match against time 1155 result = match(token, time) 1156 self.assertTrue(result) 1157 self.assertEqual(result.counter, time // otp.period, msg=msg) 1158 1159 # should NOT match against another time 1160 self.assertRaises(exc.InvalidTokenError, match, token, time + 100, window=0) 1161 1162 #============================================================================= 1163 # verify() tests 1164 #============================================================================= 1165 def test_verify(self): 1166 """verify()""" 1167 # NOTE: since this is thin wrapper around .from_source() and .match(), 1168 # just testing basic behavior here. 1169 1170 from passlib.totp import TOTP 1171 1172 time = 1412889861 1173 TotpFactory = TOTP.using(now=lambda: time) 1174 1175 # successful match 1176 source1 = dict(v=1, type="totp", key='otxl2f5cctbprpzx') 1177 match = TotpFactory.verify('332136', source1) 1178 self.assertTotpMatch(match, time=time) 1179 1180 # failed match 1181 source1 = dict(v=1, type="totp", key='otxl2f5cctbprpzx') 1182 self.assertRaises(exc.InvalidTokenError, TotpFactory.verify, '332155', source1) 1183 1184 # bad source 1185 source1 = dict(v=1, type="totp") 1186 self.assertRaises(ValueError, TotpFactory.verify, '332155', source1) 1187 1188 # successful match -- json source 1189 source1json = '{"v": 1, "type": "totp", "key": "otxl2f5cctbprpzx"}' 1190 match = TotpFactory.verify('332136', source1json) 1191 self.assertTotpMatch(match, time=time) 1192 1193 # successful match -- URI 1194 source1uri = 'otpauth://totp/Label?secret=otxl2f5cctbprpzx' 1195 match = TotpFactory.verify('332136', source1uri) 1196 self.assertTotpMatch(match, time=time) 1197 1198 #============================================================================= 1199 # serialization frontend tests 1200 #============================================================================= 1201 def test_from_source(self): 1202 """from_source()""" 1203 from passlib.totp import TOTP 1204 from_source = TOTP.from_source 1205 1206 # uri (unicode) 1207 otp = from_source(u("otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&" 1208 "issuer=Example")) 1209 self.assertEqual(otp.key, KEY4_RAW) 1210 1211 # uri (bytes) 1212 otp = from_source(b"otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&" 1213 b"issuer=Example") 1214 self.assertEqual(otp.key, KEY4_RAW) 1215 1216 # dict 1217 otp = from_source(dict(v=1, type="totp", key=KEY4)) 1218 self.assertEqual(otp.key, KEY4_RAW) 1219 1220 # json (unicode) 1221 otp = from_source(u('{"v": 1, "type": "totp", "key": "JBSWY3DPEHPK3PXP"}')) 1222 self.assertEqual(otp.key, KEY4_RAW) 1223 1224 # json (bytes) 1225 otp = from_source(b'{"v": 1, "type": "totp", "key": "JBSWY3DPEHPK3PXP"}') 1226 self.assertEqual(otp.key, KEY4_RAW) 1227 1228 # TOTP object -- return unchanged 1229 self.assertIs(from_source(otp), otp) 1230 1231 # TOTP object w/ different wallet -- return new one. 1232 wallet1 = AppWallet() 1233 otp1 = TOTP.using(wallet=wallet1).from_source(otp) 1234 self.assertIsNot(otp1, otp) 1235 self.assertEqual(otp1.to_dict(), otp.to_dict()) 1236 1237 # TOTP object w/ same wallet -- return original 1238 otp2 = TOTP.using(wallet=wallet1).from_source(otp1) 1239 self.assertIs(otp2, otp1) 1240 1241 # random string 1242 self.assertRaises(ValueError, from_source, u("foo")) 1243 self.assertRaises(ValueError, from_source, b"foo") 1244 1245 #============================================================================= 1246 # uri serialization tests 1247 #============================================================================= 1248 def test_from_uri(self): 1249 """from_uri()""" 1250 from passlib.totp import TOTP 1251 from_uri = TOTP.from_uri 1252 1253 # URIs from https://code.google.com/p/google-authenticator/wiki/KeyUriFormat 1254 1255 #-------------------------------------------------------------------------------- 1256 # canonical uri 1257 #-------------------------------------------------------------------------------- 1258 otp = from_uri("otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&" 1259 "issuer=Example") 1260 self.assertIsInstance(otp, TOTP) 1261 self.assertEqual(otp.key, KEY4_RAW) 1262 self.assertEqual(otp.label, "alice@google.com") 1263 self.assertEqual(otp.issuer, "Example") 1264 self.assertEqual(otp.alg, "sha1") # default 1265 self.assertEqual(otp.period, 30) # default 1266 self.assertEqual(otp.digits, 6) # default 1267 1268 #-------------------------------------------------------------------------------- 1269 # secret param 1270 #-------------------------------------------------------------------------------- 1271 1272 # secret case insensitive 1273 otp = from_uri("otpauth://totp/Example:alice@google.com?secret=jbswy3dpehpk3pxp&" 1274 "issuer=Example") 1275 self.assertEqual(otp.key, KEY4_RAW) 1276 1277 # missing secret 1278 self.assertRaises(ValueError, from_uri, "otpauth://totp/Example:alice@google.com?digits=6") 1279 1280 # undecodable secret 1281 self.assertRaises(Base32DecodeError, from_uri, "otpauth://totp/Example:alice@google.com?" 1282 "secret=JBSWY3DPEHP@3PXP") 1283 1284 #-------------------------------------------------------------------------------- 1285 # label param 1286 #-------------------------------------------------------------------------------- 1287 1288 # w/ encoded space 1289 otp = from_uri("otpauth://totp/Provider1:Alice%20Smith?secret=JBSWY3DPEHPK3PXP&" 1290 "issuer=Provider1") 1291 self.assertEqual(otp.label, "Alice Smith") 1292 self.assertEqual(otp.issuer, "Provider1") 1293 1294 # w/ encoded space and colon 1295 # (note url has leading space before 'alice') -- taken from KeyURI spec 1296 otp = from_uri("otpauth://totp/Big%20Corporation%3A%20alice@bigco.com?" 1297 "secret=JBSWY3DPEHPK3PXP") 1298 self.assertEqual(otp.label, "alice@bigco.com") 1299 self.assertEqual(otp.issuer, "Big Corporation") 1300 1301 #-------------------------------------------------------------------------------- 1302 # issuer param / prefix 1303 #-------------------------------------------------------------------------------- 1304 1305 # 'new style' issuer only 1306 otp = from_uri("otpauth://totp/alice@bigco.com?secret=JBSWY3DPEHPK3PXP&issuer=Big%20Corporation") 1307 self.assertEqual(otp.label, "alice@bigco.com") 1308 self.assertEqual(otp.issuer, "Big Corporation") 1309 1310 # new-vs-old issuer mismatch 1311 self.assertRaises(ValueError, TOTP.from_uri, 1312 "otpauth://totp/Provider1:alice?secret=JBSWY3DPEHPK3PXP&issuer=Provider2") 1313 1314 #-------------------------------------------------------------------------------- 1315 # algorithm param 1316 #-------------------------------------------------------------------------------- 1317 1318 # custom alg 1319 otp = from_uri("otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&algorithm=SHA256") 1320 self.assertEqual(otp.alg, "sha256") 1321 1322 # unknown alg 1323 self.assertRaises(ValueError, from_uri, "otpauth://totp/Example:alice@google.com?" 1324 "secret=JBSWY3DPEHPK3PXP&algorithm=SHA333") 1325 1326 #-------------------------------------------------------------------------------- 1327 # digit param 1328 #-------------------------------------------------------------------------------- 1329 1330 # custom digits 1331 otp = from_uri("otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&digits=8") 1332 self.assertEqual(otp.digits, 8) 1333 1334 # digits out of range / invalid 1335 self.assertRaises(ValueError, from_uri, "otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&digits=A") 1336 self.assertRaises(ValueError, from_uri, "otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&digits=%20") 1337 self.assertRaises(ValueError, from_uri, "otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&digits=15") 1338 1339 #-------------------------------------------------------------------------------- 1340 # period param 1341 #-------------------------------------------------------------------------------- 1342 1343 # custom period 1344 otp = from_uri("otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&period=63") 1345 self.assertEqual(otp.period, 63) 1346 1347 # reject period < 1 1348 self.assertRaises(ValueError, from_uri, "otpauth://totp/Example:alice@google.com?" 1349 "secret=JBSWY3DPEHPK3PXP&period=0") 1350 1351 self.assertRaises(ValueError, from_uri, "otpauth://totp/Example:alice@google.com?" 1352 "secret=JBSWY3DPEHPK3PXP&period=-1") 1353 1354 #-------------------------------------------------------------------------------- 1355 # unrecognized param 1356 #-------------------------------------------------------------------------------- 1357 1358 # should issue warning, but otherwise ignore extra param 1359 with self.assertWarningList([ 1360 dict(category=exc.PasslibRuntimeWarning, message_re="unexpected parameters encountered") 1361 ]): 1362 otp = from_uri("otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&" 1363 "foo=bar&period=63") 1364 self.assertEqual(otp.base32_key, KEY4) 1365 self.assertEqual(otp.period, 63) 1366 1367 def test_to_uri(self): 1368 """to_uri()""" 1369 1370 #------------------------------------------------------------------------- 1371 # label & issuer parameters 1372 #------------------------------------------------------------------------- 1373 1374 # with label & issuer 1375 otp = TOTP(KEY4, alg="sha1", digits=6, period=30) 1376 self.assertEqual(otp.to_uri("alice@google.com", "Example Org"), 1377 "otpauth://totp/Example%20Org:alice@google.com?secret=JBSWY3DPEHPK3PXP&" 1378 "issuer=Example%20Org") 1379 1380 # label is required 1381 self.assertRaises(ValueError, otp.to_uri, None, "Example Org") 1382 1383 # with label only 1384 self.assertEqual(otp.to_uri("alice@google.com"), 1385 "otpauth://totp/alice@google.com?secret=JBSWY3DPEHPK3PXP") 1386 1387 # with default label from constructor 1388 otp.label = "alice@google.com" 1389 self.assertEqual(otp.to_uri(), 1390 "otpauth://totp/alice@google.com?secret=JBSWY3DPEHPK3PXP") 1391 1392 # with default label & default issuer from constructor 1393 otp.issuer = "Example Org" 1394 self.assertEqual(otp.to_uri(), 1395 "otpauth://totp/Example%20Org:alice@google.com?secret=JBSWY3DPEHPK3PXP" 1396 "&issuer=Example%20Org") 1397 1398 # reject invalid label 1399 self.assertRaises(ValueError, otp.to_uri, "label:with:semicolons") 1400 1401 # reject invalid issuer 1402 self.assertRaises(ValueError, otp.to_uri, "alice@google.com", "issuer:with:semicolons") 1403 1404 #------------------------------------------------------------------------- 1405 # algorithm parameter 1406 #------------------------------------------------------------------------- 1407 self.assertEqual(TOTP(KEY4, alg="sha256").to_uri("alice@google.com"), 1408 "otpauth://totp/alice@google.com?secret=JBSWY3DPEHPK3PXP&" 1409 "algorithm=SHA256") 1410 1411 #------------------------------------------------------------------------- 1412 # digits parameter 1413 #------------------------------------------------------------------------- 1414 self.assertEqual(TOTP(KEY4, digits=8).to_uri("alice@google.com"), 1415 "otpauth://totp/alice@google.com?secret=JBSWY3DPEHPK3PXP&" 1416 "digits=8") 1417 1418 #------------------------------------------------------------------------- 1419 # period parameter 1420 #------------------------------------------------------------------------- 1421 self.assertEqual(TOTP(KEY4, period=63).to_uri("alice@google.com"), 1422 "otpauth://totp/alice@google.com?secret=JBSWY3DPEHPK3PXP&" 1423 "period=63") 1424 1425 #============================================================================= 1426 # dict serialization tests 1427 #============================================================================= 1428 def test_from_dict(self): 1429 """from_dict()""" 1430 from passlib.totp import TOTP 1431 from_dict = TOTP.from_dict 1432 1433 #-------------------------------------------------------------------------------- 1434 # canonical simple example 1435 #-------------------------------------------------------------------------------- 1436 otp = from_dict(dict(v=1, type="totp", key=KEY4, label="alice@google.com", issuer="Example")) 1437 self.assertIsInstance(otp, TOTP) 1438 self.assertEqual(otp.key, KEY4_RAW) 1439 self.assertEqual(otp.label, "alice@google.com") 1440 self.assertEqual(otp.issuer, "Example") 1441 self.assertEqual(otp.alg, "sha1") # default 1442 self.assertEqual(otp.period, 30) # default 1443 self.assertEqual(otp.digits, 6) # default 1444 1445 #-------------------------------------------------------------------------------- 1446 # metadata 1447 #-------------------------------------------------------------------------------- 1448 1449 # missing version 1450 self.assertRaises(ValueError, from_dict, dict(type="totp", key=KEY4)) 1451 1452 # invalid version 1453 self.assertRaises(ValueError, from_dict, dict(v=0, type="totp", key=KEY4)) 1454 self.assertRaises(ValueError, from_dict, dict(v=999, type="totp", key=KEY4)) 1455 1456 # missing type 1457 self.assertRaises(ValueError, from_dict, dict(v=1, key=KEY4)) 1458 1459 #-------------------------------------------------------------------------------- 1460 # secret param 1461 #-------------------------------------------------------------------------------- 1462 1463 # secret case insensitive 1464 otp = from_dict(dict(v=1, type="totp", key=KEY4.lower(), label="alice@google.com", issuer="Example")) 1465 self.assertEqual(otp.key, KEY4_RAW) 1466 1467 # missing secret 1468 self.assertRaises(ValueError, from_dict, dict(v=1, type="totp")) 1469 1470 # undecodable secret 1471 self.assertRaises(Base32DecodeError, from_dict, 1472 dict(v=1, type="totp", key="JBSWY3DPEHP@3PXP")) 1473 1474 #-------------------------------------------------------------------------------- 1475 # label & issuer params 1476 #-------------------------------------------------------------------------------- 1477 1478 otp = from_dict(dict(v=1, type="totp", key=KEY4, label="Alice Smith", issuer="Provider1")) 1479 self.assertEqual(otp.label, "Alice Smith") 1480 self.assertEqual(otp.issuer, "Provider1") 1481 1482 #-------------------------------------------------------------------------------- 1483 # algorithm param 1484 #-------------------------------------------------------------------------------- 1485 1486 # custom alg 1487 otp = from_dict(dict(v=1, type="totp", key=KEY4, alg="sha256")) 1488 self.assertEqual(otp.alg, "sha256") 1489 1490 # unknown alg 1491 self.assertRaises(ValueError, from_dict, dict(v=1, type="totp", key=KEY4, alg="sha333")) 1492 1493 #-------------------------------------------------------------------------------- 1494 # digit param 1495 #-------------------------------------------------------------------------------- 1496 1497 # custom digits 1498 otp = from_dict(dict(v=1, type="totp", key=KEY4, digits=8)) 1499 self.assertEqual(otp.digits, 8) 1500 1501 # digits out of range / invalid 1502 self.assertRaises(TypeError, from_dict, dict(v=1, type="totp", key=KEY4, digits="A")) 1503 self.assertRaises(ValueError, from_dict, dict(v=1, type="totp", key=KEY4, digits=15)) 1504 1505 #-------------------------------------------------------------------------------- 1506 # period param 1507 #-------------------------------------------------------------------------------- 1508 1509 # custom period 1510 otp = from_dict(dict(v=1, type="totp", key=KEY4, period=63)) 1511 self.assertEqual(otp.period, 63) 1512 1513 # reject period < 1 1514 self.assertRaises(ValueError, from_dict, dict(v=1, type="totp", key=KEY4, period=0)) 1515 self.assertRaises(ValueError, from_dict, dict(v=1, type="totp", key=KEY4, period=-1)) 1516 1517 #-------------------------------------------------------------------------------- 1518 # unrecognized param 1519 #-------------------------------------------------------------------------------- 1520 self.assertRaises(TypeError, from_dict, dict(v=1, type="totp", key=KEY4, INVALID=123)) 1521 1522 def test_to_dict(self): 1523 """to_dict()""" 1524 1525 #------------------------------------------------------------------------- 1526 # label & issuer parameters 1527 #------------------------------------------------------------------------- 1528 1529 # without label or issuer 1530 otp = TOTP(KEY4, alg="sha1", digits=6, period=30) 1531 self.assertEqual(otp.to_dict(), dict(v=1, type="totp", key=KEY4)) 1532 1533 # with label & issuer from constructor 1534 otp = TOTP(KEY4, alg="sha1", digits=6, period=30, 1535 label="alice@google.com", issuer="Example Org") 1536 self.assertEqual(otp.to_dict(), 1537 dict(v=1, type="totp", key=KEY4, 1538 label="alice@google.com", issuer="Example Org")) 1539 1540 # with label only 1541 otp = TOTP(KEY4, alg="sha1", digits=6, period=30, 1542 label="alice@google.com") 1543 self.assertEqual(otp.to_dict(), 1544 dict(v=1, type="totp", key=KEY4, 1545 label="alice@google.com")) 1546 1547 # with issuer only 1548 otp = TOTP(KEY4, alg="sha1", digits=6, period=30, 1549 issuer="Example Org") 1550 self.assertEqual(otp.to_dict(), 1551 dict(v=1, type="totp", key=KEY4, 1552 issuer="Example Org")) 1553 1554 # don't serialize default issuer 1555 TotpFactory = TOTP.using(issuer="Example Org") 1556 otp = TotpFactory(KEY4) 1557 self.assertEqual(otp.to_dict(), dict(v=1, type="totp", key=KEY4)) 1558 1559 # don't serialize default issuer *even if explicitly set* 1560 otp = TotpFactory(KEY4, issuer="Example Org") 1561 self.assertEqual(otp.to_dict(), dict(v=1, type="totp", key=KEY4)) 1562 1563 #------------------------------------------------------------------------- 1564 # algorithm parameter 1565 #------------------------------------------------------------------------- 1566 self.assertEqual(TOTP(KEY4, alg="sha256").to_dict(), 1567 dict(v=1, type="totp", key=KEY4, alg="sha256")) 1568 1569 #------------------------------------------------------------------------- 1570 # digits parameter 1571 #------------------------------------------------------------------------- 1572 self.assertEqual(TOTP(KEY4, digits=8).to_dict(), 1573 dict(v=1, type="totp", key=KEY4, digits=8)) 1574 1575 #------------------------------------------------------------------------- 1576 # period parameter 1577 #------------------------------------------------------------------------- 1578 self.assertEqual(TOTP(KEY4, period=63).to_dict(), 1579 dict(v=1, type="totp", key=KEY4, period=63)) 1580 1581 # TODO: to_dict() 1582 # with encrypt=False 1583 # with encrypt="auto" + wallet + secrets 1584 # with encrypt="auto" + wallet + no secrets 1585 # with encrypt="auto" + no wallet 1586 # with encrypt=True + wallet + secrets 1587 # with encrypt=True + wallet + no secrets 1588 # with encrypt=True + no wallet 1589 # that 'changed' is set for old versions, and old encryption tags. 1590 1591 #============================================================================= 1592 # json serialization tests 1593 #============================================================================= 1594 1595 # TODO: from_json() / to_json(). 1596 # (skipped for right now cause just wrapper for from_dict/to_dict) 1597 1598 #============================================================================= 1599 # eoc 1600 #============================================================================= 1601 1602#============================================================================= 1603# eof 1604#============================================================================= 1605