You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

1603 regels
64 KiB

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