test_totp.py 64 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604
  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. # TODO: rework timing test here to inject mock pbkdf2_hmac() function instead;
  299. # and test that it's being invoked w/ proper options.
  300. self.assertAlmostEqual(delta2, delta*8, delta=(delta*8)*0.5)
  301. #=============================================================================
  302. # eoc
  303. #=============================================================================
  304. #=============================================================================
  305. # common OTP code
  306. #=============================================================================
  307. #: used as base value for RFC test vector keys
  308. RFC_KEY_BYTES_20 = "12345678901234567890".encode("ascii")
  309. RFC_KEY_BYTES_32 = (RFC_KEY_BYTES_20*2)[:32]
  310. RFC_KEY_BYTES_64 = (RFC_KEY_BYTES_20*4)[:64]
  311. # TODO: this class is separate from TotpTest due to historical issue,
  312. # when there was a base class, and a separate HOTP class.
  313. # these test case classes should probably be combined.
  314. class TotpTest(TestCase):
  315. """
  316. common code shared by TotpTest & HotpTest
  317. """
  318. #=============================================================================
  319. # class attrs
  320. #=============================================================================
  321. descriptionPrefix = "passlib.totp.TOTP"
  322. #=============================================================================
  323. # setup
  324. #=============================================================================
  325. def setUp(self):
  326. super(TotpTest, self).setUp()
  327. # clear norm_hash_name() cache so 'unknown hash' warnings get emitted each time
  328. from passlib.crypto.digest import lookup_hash
  329. lookup_hash.clear_cache()
  330. # monkeypatch module's rng to be deterministic
  331. self.patchAttr(totp_module, "rng", self.getRandom())
  332. #=============================================================================
  333. # general helpers
  334. #=============================================================================
  335. def randtime(self):
  336. """
  337. helper to generate random epoch time
  338. :returns float: epoch time
  339. """
  340. return self.getRandom().random() * max_time_t
  341. def randotp(self, cls=None, **kwds):
  342. """
  343. helper which generates a random TOTP instance.
  344. """
  345. rng = self.getRandom()
  346. if "key" not in kwds:
  347. kwds['new'] = True
  348. kwds.setdefault("digits", rng.randint(6, 10))
  349. kwds.setdefault("alg", rng.choice(["sha1", "sha256", "sha512"]))
  350. kwds.setdefault("period", rng.randint(10, 120))
  351. return (cls or TOTP)(**kwds)
  352. def test_randotp(self):
  353. """
  354. internal test -- randotp()
  355. """
  356. otp1 = self.randotp()
  357. otp2 = self.randotp()
  358. self.assertNotEqual(otp1.key, otp2.key, "key not randomized:")
  359. # NOTE: has (1/5)**10 odds of failure
  360. for _ in range(10):
  361. if otp1.digits != otp2.digits:
  362. break
  363. otp2 = self.randotp()
  364. else:
  365. self.fail("digits not randomized")
  366. # NOTE: has (1/3)**10 odds of failure
  367. for _ in range(10):
  368. if otp1.alg != otp2.alg:
  369. break
  370. otp2 = self.randotp()
  371. else:
  372. self.fail("alg not randomized")
  373. #=============================================================================
  374. # reference vector helpers
  375. #=============================================================================
  376. #: default options used by test vectors (unless otherwise stated)
  377. vector_defaults = dict(format="base32", alg="sha1", period=30, digits=8)
  378. #: various TOTP test vectors,
  379. #: each element in list has format [options, (time, token <, int(expires)>), ...]
  380. vectors = [
  381. #-------------------------------------------------------------------------
  382. # passlib test vectors
  383. #-------------------------------------------------------------------------
  384. # 10 byte key, 6 digits
  385. [dict(key="ACDEFGHJKL234567", digits=6),
  386. # test fencepost to make sure we're rounding right
  387. (1412873399, '221105'), # == 29 mod 30
  388. (1412873400, '178491'), # == 0 mod 30
  389. (1412873401, '178491'), # == 1 mod 30
  390. (1412873429, '178491'), # == 29 mod 30
  391. (1412873430, '915114'), # == 0 mod 30
  392. ],
  393. # 10 byte key, 8 digits
  394. [dict(key="ACDEFGHJKL234567", digits=8),
  395. # should be same as 6 digits (above), but w/ 2 more digits on left side of token.
  396. (1412873399, '20221105'), # == 29 mod 30
  397. (1412873400, '86178491'), # == 0 mod 30
  398. (1412873401, '86178491'), # == 1 mod 30
  399. (1412873429, '86178491'), # == 29 mod 30
  400. (1412873430, '03915114'), # == 0 mod 30
  401. ],
  402. # sanity check on key used in docstrings
  403. [dict(key="S3JD-VB7Q-D2R7-JPXX", digits=6),
  404. (1419622709, '000492'),
  405. (1419622739, '897212'),
  406. ],
  407. #-------------------------------------------------------------------------
  408. # reference vectors taken from http://tools.ietf.org/html/rfc6238, appendix B
  409. # NOTE: while appendix B states same key used for all tests, the reference
  410. # code in the appendix repeats the key up to the alg's block size,
  411. # and uses *that* as the secret... so that's what we're doing here.
  412. #-------------------------------------------------------------------------
  413. # sha1 test vectors
  414. [dict(key=RFC_KEY_BYTES_20, format="raw", alg="sha1"),
  415. (59, '94287082'),
  416. (1111111109, '07081804'),
  417. (1111111111, '14050471'),
  418. (1234567890, '89005924'),
  419. (2000000000, '69279037'),
  420. (20000000000, '65353130'),
  421. ],
  422. # sha256 test vectors
  423. [dict(key=RFC_KEY_BYTES_32, format="raw", alg="sha256"),
  424. (59, '46119246'),
  425. (1111111109, '68084774'),
  426. (1111111111, '67062674'),
  427. (1234567890, '91819424'),
  428. (2000000000, '90698825'),
  429. (20000000000, '77737706'),
  430. ],
  431. # sha512 test vectors
  432. [dict(key=RFC_KEY_BYTES_64, format="raw", alg="sha512"),
  433. (59, '90693936'),
  434. (1111111109, '25091201'),
  435. (1111111111, '99943326'),
  436. (1234567890, '93441116'),
  437. (2000000000, '38618901'),
  438. (20000000000, '47863826'),
  439. ],
  440. #-------------------------------------------------------------------------
  441. # other test vectors
  442. #-------------------------------------------------------------------------
  443. # generated at http://blog.tinisles.com/2011/10/google-authenticator-one-time-password-algorithm-in-javascript
  444. [dict(key="JBSWY3DPEHPK3PXP", digits=6), (1409192430, '727248'), (1419890990, '122419')],
  445. [dict(key="JBSWY3DPEHPK3PXP", digits=9, period=41), (1419891152, '662331049')],
  446. # found in https://github.com/eloquent/otis/blob/develop/test/suite/Totp/Value/TotpValueGeneratorTest.php, line 45
  447. [dict(key=RFC_KEY_BYTES_20, format="raw", period=60), (1111111111, '19360094')],
  448. [dict(key=RFC_KEY_BYTES_32, format="raw", alg="sha256", period=60), (1111111111, '40857319')],
  449. [dict(key=RFC_KEY_BYTES_64, format="raw", alg="sha512", period=60), (1111111111, '37023009')],
  450. ]
  451. def iter_test_vectors(self):
  452. """
  453. helper to iterate over test vectors.
  454. yields ``(totp, time, token, expires, prefix)`` tuples.
  455. """
  456. from passlib.totp import TOTP
  457. for row in self.vectors:
  458. kwds = self.vector_defaults.copy()
  459. kwds.update(row[0])
  460. for entry in row[1:]:
  461. if len(entry) == 3:
  462. time, token, expires = entry
  463. else:
  464. time, token = entry
  465. expires = None
  466. # NOTE: not re-using otp between calls so that stateful methods
  467. # (like .match) don't have problems.
  468. log.debug("test vector: %r time=%r token=%r expires=%r", kwds, time, token, expires)
  469. otp = TOTP(**kwds)
  470. prefix = "alg=%r time=%r token=%r: " % (otp.alg, time, token)
  471. yield otp, time, token, expires, prefix
  472. #=============================================================================
  473. # constructor tests
  474. #=============================================================================
  475. def test_ctor_w_new(self):
  476. """constructor -- 'new' parameter"""
  477. # exactly one of 'key' or 'new' is required
  478. self.assertRaises(TypeError, TOTP)
  479. self.assertRaises(TypeError, TOTP, key='4aoggdbbqsyhntuz', new=True)
  480. # generates new key
  481. otp = TOTP(new=True)
  482. otp2 = TOTP(new=True)
  483. self.assertNotEqual(otp.key, otp2.key)
  484. def test_ctor_w_size(self):
  485. """constructor -- 'size' parameter"""
  486. # should default to digest size, per RFC
  487. self.assertEqual(len(TOTP(new=True, alg="sha1").key), 20)
  488. self.assertEqual(len(TOTP(new=True, alg="sha256").key), 32)
  489. self.assertEqual(len(TOTP(new=True, alg="sha512").key), 64)
  490. # explicit key size
  491. self.assertEqual(len(TOTP(new=True, size=10).key), 10)
  492. self.assertEqual(len(TOTP(new=True, size=16).key), 16)
  493. # for new=True, maximum size enforced (based on alg)
  494. self.assertRaises(ValueError, TOTP, new=True, size=21, alg="sha1")
  495. # for new=True, minimum size enforced
  496. self.assertRaises(ValueError, TOTP, new=True, size=9)
  497. # for existing key, minimum size is only warned about
  498. with self.assertWarningList([
  499. dict(category=exc.PasslibSecurityWarning, message_re=".*for security purposes, secret key must be.*")
  500. ]):
  501. _ = TOTP('0A'*9, 'hex')
  502. def test_ctor_w_key_and_format(self):
  503. """constructor -- 'key' and 'format' parameters"""
  504. # handle base32 encoding (the default)
  505. self.assertEqual(TOTP(KEY1).key, KEY1_RAW)
  506. # .. w/ lower case
  507. self.assertEqual(TOTP(KEY1.lower()).key, KEY1_RAW)
  508. # .. w/ spaces (e.g. user-entered data)
  509. self.assertEqual(TOTP(' 4aog gdbb qsyh ntuz ').key, KEY1_RAW)
  510. # .. w/ invalid char
  511. self.assertRaises(Base32DecodeError, TOTP, 'ao!ggdbbqsyhntuz')
  512. # handle hex encoding
  513. self.assertEqual(TOTP('e01c630c2184b076ce99', 'hex').key, KEY1_RAW)
  514. # .. w/ invalid char
  515. self.assertRaises(Base16DecodeError, TOTP, 'X01c630c2184b076ce99', 'hex')
  516. # handle raw bytes
  517. self.assertEqual(TOTP(KEY1_RAW, "raw").key, KEY1_RAW)
  518. def test_ctor_w_alg(self):
  519. """constructor -- 'alg' parameter"""
  520. # normalize hash names
  521. self.assertEqual(TOTP(KEY1, alg="SHA-256").alg, "sha256")
  522. self.assertEqual(TOTP(KEY1, alg="SHA256").alg, "sha256")
  523. # invalid alg
  524. self.assertRaises(ValueError, TOTP, KEY1, alg="SHA-333")
  525. def test_ctor_w_digits(self):
  526. """constructor -- 'digits' parameter"""
  527. self.assertRaises(ValueError, TOTP, KEY1, digits=5)
  528. self.assertEqual(TOTP(KEY1, digits=6).digits, 6) # min value
  529. self.assertEqual(TOTP(KEY1, digits=10).digits, 10) # max value
  530. self.assertRaises(ValueError, TOTP, KEY1, digits=11)
  531. def test_ctor_w_period(self):
  532. """constructor -- 'period' parameter"""
  533. # default
  534. self.assertEqual(TOTP(KEY1).period, 30)
  535. # explicit value
  536. self.assertEqual(TOTP(KEY1, period=63).period, 63)
  537. # reject wrong type
  538. self.assertRaises(TypeError, TOTP, KEY1, period=1.5)
  539. self.assertRaises(TypeError, TOTP, KEY1, period='abc')
  540. # reject non-positive values
  541. self.assertRaises(ValueError, TOTP, KEY1, period=0)
  542. self.assertRaises(ValueError, TOTP, KEY1, period=-1)
  543. def test_ctor_w_label(self):
  544. """constructor -- 'label' parameter"""
  545. self.assertEqual(TOTP(KEY1).label, None)
  546. self.assertEqual(TOTP(KEY1, label="foo@bar").label, "foo@bar")
  547. self.assertRaises(ValueError, TOTP, KEY1, label="foo:bar")
  548. def test_ctor_w_issuer(self):
  549. """constructor -- 'issuer' parameter"""
  550. self.assertEqual(TOTP(KEY1).issuer, None)
  551. self.assertEqual(TOTP(KEY1, issuer="foo.com").issuer, "foo.com")
  552. self.assertRaises(ValueError, TOTP, KEY1, issuer="foo.com:bar")
  553. #=============================================================================
  554. # using() tests
  555. #=============================================================================
  556. # TODO: test using() w/ 'digits', 'alg', 'issue', 'wallet', **wallet_kwds
  557. def test_using_w_period(self):
  558. """using() -- 'period' parameter"""
  559. # default
  560. self.assertEqual(TOTP(KEY1).period, 30)
  561. # explicit value
  562. self.assertEqual(TOTP.using(period=63)(KEY1).period, 63)
  563. # reject wrong type
  564. self.assertRaises(TypeError, TOTP.using, period=1.5)
  565. self.assertRaises(TypeError, TOTP.using, period='abc')
  566. # reject non-positive values
  567. self.assertRaises(ValueError, TOTP.using, period=0)
  568. self.assertRaises(ValueError, TOTP.using, period=-1)
  569. def test_using_w_now(self):
  570. """using -- 'now' parameter"""
  571. # NOTE: reading time w/ normalize_time() to make sure custom .now actually has effect.
  572. # default -- time.time
  573. otp = self.randotp()
  574. self.assertIs(otp.now, _time.time)
  575. self.assertAlmostEqual(otp.normalize_time(None), int(_time.time()))
  576. # custom function
  577. counter = [123.12]
  578. def now():
  579. counter[0] += 1
  580. return counter[0]
  581. otp = self.randotp(cls=TOTP.using(now=now))
  582. # NOTE: TOTP() constructor invokes this as part of test, using up counter values 124 & 125
  583. self.assertEqual(otp.normalize_time(None), 126)
  584. self.assertEqual(otp.normalize_time(None), 127)
  585. # require callable
  586. self.assertRaises(TypeError, TOTP.using, now=123)
  587. # require returns int/float
  588. msg_re = r"now\(\) function must return non-negative"
  589. self.assertRaisesRegex(AssertionError, msg_re, TOTP.using, now=lambda: 'abc')
  590. # require returns non-negative value
  591. self.assertRaisesRegex(AssertionError, msg_re, TOTP.using, now=lambda: -1)
  592. #=============================================================================
  593. # internal method tests
  594. #=============================================================================
  595. def test_normalize_token_instance(self, otp=None):
  596. """normalize_token() -- instance method"""
  597. if otp is None:
  598. otp = self.randotp(digits=7)
  599. # unicode & bytes
  600. self.assertEqual(otp.normalize_token(u('1234567')), '1234567')
  601. self.assertEqual(otp.normalize_token(b'1234567'), '1234567')
  602. # int
  603. self.assertEqual(otp.normalize_token(1234567), '1234567')
  604. # int which needs 0 padding
  605. self.assertEqual(otp.normalize_token(234567), '0234567')
  606. # reject wrong types (float, None)
  607. self.assertRaises(TypeError, otp.normalize_token, 1234567.0)
  608. self.assertRaises(TypeError, otp.normalize_token, None)
  609. # too few digits
  610. self.assertRaises(exc.MalformedTokenError, otp.normalize_token, '123456')
  611. # too many digits
  612. self.assertRaises(exc.MalformedTokenError, otp.normalize_token, '01234567')
  613. self.assertRaises(exc.MalformedTokenError, otp.normalize_token, 12345678)
  614. def test_normalize_token_class(self):
  615. """normalize_token() -- class method"""
  616. self.test_normalize_token_instance(otp=TOTP.using(digits=7))
  617. def test_normalize_time(self):
  618. """normalize_time()"""
  619. TotpFactory = TOTP.using()
  620. otp = self.randotp(TotpFactory)
  621. for _ in range(10):
  622. time = self.randtime()
  623. tint = int(time)
  624. self.assertEqual(otp.normalize_time(time), tint)
  625. self.assertEqual(otp.normalize_time(tint + 0.5), tint)
  626. self.assertEqual(otp.normalize_time(tint), tint)
  627. dt = datetime.datetime.utcfromtimestamp(time)
  628. self.assertEqual(otp.normalize_time(dt), tint)
  629. orig = TotpFactory.now
  630. try:
  631. TotpFactory.now = staticmethod(lambda: time)
  632. self.assertEqual(otp.normalize_time(None), tint)
  633. finally:
  634. TotpFactory.now = orig
  635. self.assertRaises(TypeError, otp.normalize_time, '1234')
  636. #=============================================================================
  637. # key attr tests
  638. #=============================================================================
  639. def test_key_attrs(self):
  640. """pretty_key() and .key attributes"""
  641. rng = self.getRandom()
  642. # test key attrs
  643. otp = TOTP(KEY1_RAW, "raw")
  644. self.assertEqual(otp.key, KEY1_RAW)
  645. self.assertEqual(otp.hex_key, 'e01c630c2184b076ce99')
  646. self.assertEqual(otp.base32_key, KEY1)
  647. # test pretty_key()
  648. self.assertEqual(otp.pretty_key(), '4AOG-GDBB-QSYH-NTUZ')
  649. self.assertEqual(otp.pretty_key(sep=" "), '4AOG GDBB QSYH NTUZ')
  650. self.assertEqual(otp.pretty_key(sep=False), KEY1)
  651. self.assertEqual(otp.pretty_key(format="hex"), 'e01c-630c-2184-b076-ce99')
  652. # quick fuzz test: make attr access works for random key & random size
  653. otp = TOTP(new=True, size=rng.randint(10, 20))
  654. _ = otp.hex_key
  655. _ = otp.base32_key
  656. _ = otp.pretty_key()
  657. #=============================================================================
  658. # generate() tests
  659. #=============================================================================
  660. def test_totp_token(self):
  661. """generate() -- TotpToken() class"""
  662. from passlib.totp import TOTP, TotpToken
  663. # test known set of values
  664. otp = TOTP('s3jdvb7qd2r7jpxx')
  665. result = otp.generate(1419622739)
  666. self.assertIsInstance(result, TotpToken)
  667. self.assertEqual(result.token, '897212')
  668. self.assertEqual(result.counter, 47320757)
  669. ##self.assertEqual(result.start_time, 1419622710)
  670. self.assertEqual(result.expire_time, 1419622740)
  671. self.assertEqual(result, ('897212', 1419622740))
  672. self.assertEqual(len(result), 2)
  673. self.assertEqual(result[0], '897212')
  674. self.assertEqual(result[1], 1419622740)
  675. self.assertRaises(IndexError, result.__getitem__, -3)
  676. self.assertRaises(IndexError, result.__getitem__, 2)
  677. self.assertTrue(result)
  678. # time dependant bits...
  679. otp.now = lambda : 1419622739.5
  680. self.assertEqual(result.remaining, 0.5)
  681. self.assertTrue(result.valid)
  682. otp.now = lambda : 1419622741
  683. self.assertEqual(result.remaining, 0)
  684. self.assertFalse(result.valid)
  685. # same time -- shouldn't return same object, but should be equal
  686. result2 = otp.generate(1419622739)
  687. self.assertIsNot(result2, result)
  688. self.assertEqual(result2, result)
  689. # diff time in period -- shouldn't return same object, but should be equal
  690. result3 = otp.generate(1419622711)
  691. self.assertIsNot(result3, result)
  692. self.assertEqual(result3, result)
  693. # shouldn't be equal
  694. result4 = otp.generate(1419622999)
  695. self.assertNotEqual(result4, result)
  696. def test_generate(self):
  697. """generate()"""
  698. from passlib.totp import TOTP
  699. # generate token
  700. otp = TOTP(new=True)
  701. time = self.randtime()
  702. result = otp.generate(time)
  703. token = result.token
  704. self.assertIsInstance(token, unicode)
  705. start_time = result.counter * 30
  706. # should generate same token for next 29s
  707. self.assertEqual(otp.generate(start_time + 29).token, token)
  708. # and new one at 30s
  709. self.assertNotEqual(otp.generate(start_time + 30).token, token)
  710. # verify round-trip conversion of datetime
  711. dt = datetime.datetime.utcfromtimestamp(time)
  712. self.assertEqual(int(otp.normalize_time(dt)), int(time))
  713. # handle datetime object
  714. self.assertEqual(otp.generate(dt).token, token)
  715. # omitting value should use current time
  716. otp2 = TOTP.using(now=lambda: time)(key=otp.base32_key)
  717. self.assertEqual(otp2.generate().token, token)
  718. # reject invalid time
  719. self.assertRaises(ValueError, otp.generate, -1)
  720. def test_generate_w_reference_vectors(self):
  721. """generate() -- reference vectors"""
  722. for otp, time, token, expires, prefix in self.iter_test_vectors():
  723. # should output correct token for specified time
  724. result = otp.generate(time)
  725. self.assertEqual(result.token, token, msg=prefix)
  726. self.assertEqual(result.counter, time // otp.period, msg=prefix)
  727. if expires:
  728. self.assertEqual(result.expire_time, expires)
  729. #=============================================================================
  730. # TotpMatch() tests
  731. #=============================================================================
  732. def assertTotpMatch(self, match, time, skipped=0, period=30, window=30, msg=''):
  733. from passlib.totp import TotpMatch
  734. # test type
  735. self.assertIsInstance(match, TotpMatch)
  736. # totp sanity check
  737. self.assertIsInstance(match.totp, TOTP)
  738. self.assertEqual(match.totp.period, period)
  739. # test attrs
  740. self.assertEqual(match.time, time, msg=msg + " matched time:")
  741. expected = time // period
  742. counter = expected + skipped
  743. self.assertEqual(match.counter, counter, msg=msg + " matched counter:")
  744. self.assertEqual(match.expected_counter, expected, msg=msg + " expected counter:")
  745. self.assertEqual(match.skipped, skipped, msg=msg + " skipped:")
  746. self.assertEqual(match.cache_seconds, period + window)
  747. expire_time = (counter + 1) * period
  748. self.assertEqual(match.expire_time, expire_time)
  749. self.assertEqual(match.cache_time, expire_time + window)
  750. # test tuple
  751. self.assertEqual(len(match), 2)
  752. self.assertEqual(match, (counter, time))
  753. self.assertRaises(IndexError, match.__getitem__, -3)
  754. self.assertEqual(match[0], counter)
  755. self.assertEqual(match[1], time)
  756. self.assertRaises(IndexError, match.__getitem__, 2)
  757. # test bool
  758. self.assertTrue(match)
  759. def test_totp_match_w_valid_token(self):
  760. """match() -- valid TotpMatch object"""
  761. time = 141230981
  762. token = '781501'
  763. otp = TOTP.using(now=lambda: time + 24 * 3600)(KEY3)
  764. result = otp.match(token, time)
  765. self.assertTotpMatch(result, time=time, skipped=0)
  766. def test_totp_match_w_older_token(self):
  767. """match() -- valid TotpMatch object with future token"""
  768. from passlib.totp import TotpMatch
  769. time = 141230981
  770. token = '781501'
  771. otp = TOTP.using(now=lambda: time + 24 * 3600)(KEY3)
  772. result = otp.match(token, time - 30)
  773. self.assertTotpMatch(result, time=time - 30, skipped=1)
  774. def test_totp_match_w_new_token(self):
  775. """match() -- valid TotpMatch object with past token"""
  776. time = 141230981
  777. token = '781501'
  778. otp = TOTP.using(now=lambda: time + 24 * 3600)(KEY3)
  779. result = otp.match(token, time + 30)
  780. self.assertTotpMatch(result, time=time + 30, skipped=-1)
  781. def test_totp_match_w_invalid_token(self):
  782. """match() -- invalid TotpMatch object"""
  783. time = 141230981
  784. token = '781501'
  785. otp = TOTP.using(now=lambda: time + 24 * 3600)(KEY3)
  786. self.assertRaises(exc.InvalidTokenError, otp.match, token, time + 60)
  787. #=============================================================================
  788. # match() tests
  789. #=============================================================================
  790. def assertVerifyMatches(self, expect_skipped, token, time, # *
  791. otp, gen_time=None, **kwds):
  792. """helper to test otp.match() output is correct"""
  793. # NOTE: TotpMatch return type tested more throughly above ^^^
  794. msg = "key=%r alg=%r period=%r token=%r gen_time=%r time=%r:" % \
  795. (otp.base32_key, otp.alg, otp.period, token, gen_time, time)
  796. result = otp.match(token, time, **kwds)
  797. self.assertTotpMatch(result,
  798. time=otp.normalize_time(time),
  799. period=otp.period,
  800. window=kwds.get("window", 30),
  801. skipped=expect_skipped,
  802. msg=msg)
  803. def assertVerifyRaises(self, exc_class, token, time, # *
  804. otp, gen_time=None,
  805. **kwds):
  806. """helper to test otp.match() throws correct error"""
  807. # NOTE: TotpMatch return type tested more throughly above ^^^
  808. msg = "key=%r alg=%r period=%r token=%r gen_time=%r time=%r:" % \
  809. (otp.base32_key, otp.alg, otp.period, token, gen_time, time)
  810. return self.assertRaises(exc_class, otp.match, token, time,
  811. __msg__=msg, **kwds)
  812. def test_match_w_window(self):
  813. """match() -- 'time' and 'window' parameters"""
  814. # init generator & helper
  815. otp = self.randotp()
  816. period = otp.period
  817. time = self.randtime()
  818. token = otp.generate(time).token
  819. common = dict(otp=otp, gen_time=time)
  820. assertMatches = partial(self.assertVerifyMatches, **common)
  821. assertRaises = partial(self.assertVerifyRaises, **common)
  822. #-------------------------------
  823. # basic validation, and 'window' parameter
  824. #-------------------------------
  825. # validate against previous counter (passes if window >= period)
  826. assertRaises(exc.InvalidTokenError, token, time - period, window=0)
  827. assertMatches(+1, token, time - period, window=period)
  828. assertMatches(+1, token, time - period, window=2 * period)
  829. # validate against current counter
  830. assertMatches(0, token, time, window=0)
  831. # validate against next counter (passes if window >= period)
  832. assertRaises(exc.InvalidTokenError, token, time + period, window=0)
  833. assertMatches(-1, token, time + period, window=period)
  834. assertMatches(-1, token, time + period, window=2 * period)
  835. # validate against two time steps later (should never pass)
  836. assertRaises(exc.InvalidTokenError, token, time + 2 * period, window=0)
  837. assertRaises(exc.InvalidTokenError, token, time + 2 * period, window=period)
  838. assertMatches(-2, token, time + 2 * period, window=2 * period)
  839. # TODO: test window values that aren't multiples of period
  840. # (esp ensure counter rounding works correctly)
  841. #-------------------------------
  842. # time normalization
  843. #-------------------------------
  844. # handle datetimes
  845. dt = datetime.datetime.utcfromtimestamp(time)
  846. assertMatches(0, token, dt, window=0)
  847. # reject invalid time
  848. assertRaises(ValueError, token, -1)
  849. def test_match_w_skew(self):
  850. """match() -- 'skew' parameters"""
  851. # init generator & helper
  852. otp = self.randotp()
  853. period = otp.period
  854. time = self.randtime()
  855. common = dict(otp=otp, gen_time=time)
  856. assertMatches = partial(self.assertVerifyMatches, **common)
  857. assertRaises = partial(self.assertVerifyRaises, **common)
  858. # assume client is running far behind server / has excessive transmission delay
  859. skew = 3 * period
  860. behind_token = otp.generate(time - skew).token
  861. assertRaises(exc.InvalidTokenError, behind_token, time, window=0)
  862. assertMatches(-3, behind_token, time, window=0, skew=-skew)
  863. # assume client is running far ahead of server
  864. ahead_token = otp.generate(time + skew).token
  865. assertRaises(exc.InvalidTokenError, ahead_token, time, window=0)
  866. assertMatches(+3, ahead_token, time, window=0, skew=skew)
  867. # TODO: test skew + larger window
  868. def test_match_w_reuse(self):
  869. """match() -- 'reuse' and 'last_counter' parameters"""
  870. # init generator & helper
  871. otp = self.randotp()
  872. period = otp.period
  873. time = self.randtime()
  874. tdata = otp.generate(time)
  875. token = tdata.token
  876. counter = tdata.counter
  877. expire_time = tdata.expire_time
  878. common = dict(otp=otp, gen_time=time)
  879. assertMatches = partial(self.assertVerifyMatches, **common)
  880. assertRaises = partial(self.assertVerifyRaises, **common)
  881. # last counter unset --
  882. # previous period's token should count as valid
  883. assertMatches(-1, token, time + period, window=period)
  884. # last counter set 2 periods ago --
  885. # previous period's token should count as valid
  886. assertMatches(-1, token, time + period, last_counter=counter-1,
  887. window=period)
  888. # last counter set 2 periods ago --
  889. # 2 periods ago's token should NOT count as valid
  890. assertRaises(exc.InvalidTokenError, token, time + 2 * period,
  891. last_counter=counter, window=period)
  892. # last counter set 1 period ago --
  893. # previous period's token should now be rejected as 'used'
  894. err = assertRaises(exc.UsedTokenError, token, time + period,
  895. last_counter=counter, window=period)
  896. self.assertEqual(err.expire_time, expire_time)
  897. # last counter set to current period --
  898. # current period's token should be rejected
  899. err = assertRaises(exc.UsedTokenError, token, time,
  900. last_counter=counter, window=0)
  901. self.assertEqual(err.expire_time, expire_time)
  902. def test_match_w_token_normalization(self):
  903. """match() -- token normalization"""
  904. # setup test helper
  905. otp = TOTP('otxl2f5cctbprpzx')
  906. match = otp.match
  907. time = 1412889861
  908. # separators / spaces should be stripped (orig token '332136')
  909. self.assertTrue(match(' 3 32-136 ', time))
  910. # ascii bytes
  911. self.assertTrue(match(b'332136', time))
  912. # too few digits
  913. self.assertRaises(exc.MalformedTokenError, match, '12345', time)
  914. # invalid char
  915. self.assertRaises(exc.MalformedTokenError, match, '12345X', time)
  916. # leading zeros count towards size
  917. self.assertRaises(exc.MalformedTokenError, match, '0123456', time)
  918. def test_match_w_reference_vectors(self):
  919. """match() -- reference vectors"""
  920. for otp, time, token, expires, msg in self.iter_test_vectors():
  921. # create wrapper
  922. match = otp.match
  923. # token should match against time
  924. result = match(token, time)
  925. self.assertTrue(result)
  926. self.assertEqual(result.counter, time // otp.period, msg=msg)
  927. # should NOT match against another time
  928. self.assertRaises(exc.InvalidTokenError, match, token, time + 100, window=0)
  929. #=============================================================================
  930. # verify() tests
  931. #=============================================================================
  932. def test_verify(self):
  933. """verify()"""
  934. # NOTE: since this is thin wrapper around .from_source() and .match(),
  935. # just testing basic behavior here.
  936. from passlib.totp import TOTP
  937. time = 1412889861
  938. TotpFactory = TOTP.using(now=lambda: time)
  939. # successful match
  940. source1 = dict(v=1, type="totp", key='otxl2f5cctbprpzx')
  941. match = TotpFactory.verify('332136', source1)
  942. self.assertTotpMatch(match, time=time)
  943. # failed match
  944. source1 = dict(v=1, type="totp", key='otxl2f5cctbprpzx')
  945. self.assertRaises(exc.InvalidTokenError, TotpFactory.verify, '332155', source1)
  946. # bad source
  947. source1 = dict(v=1, type="totp")
  948. self.assertRaises(ValueError, TotpFactory.verify, '332155', source1)
  949. # successful match -- json source
  950. source1json = '{"v": 1, "type": "totp", "key": "otxl2f5cctbprpzx"}'
  951. match = TotpFactory.verify('332136', source1json)
  952. self.assertTotpMatch(match, time=time)
  953. # successful match -- URI
  954. source1uri = 'otpauth://totp/Label?secret=otxl2f5cctbprpzx'
  955. match = TotpFactory.verify('332136', source1uri)
  956. self.assertTotpMatch(match, time=time)
  957. #=============================================================================
  958. # serialization frontend tests
  959. #=============================================================================
  960. def test_from_source(self):
  961. """from_source()"""
  962. from passlib.totp import TOTP
  963. from_source = TOTP.from_source
  964. # uri (unicode)
  965. otp = from_source(u("otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&"
  966. "issuer=Example"))
  967. self.assertEqual(otp.key, KEY4_RAW)
  968. # uri (bytes)
  969. otp = from_source(b"otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&"
  970. b"issuer=Example")
  971. self.assertEqual(otp.key, KEY4_RAW)
  972. # dict
  973. otp = from_source(dict(v=1, type="totp", key=KEY4))
  974. self.assertEqual(otp.key, KEY4_RAW)
  975. # json (unicode)
  976. otp = from_source(u('{"v": 1, "type": "totp", "key": "JBSWY3DPEHPK3PXP"}'))
  977. self.assertEqual(otp.key, KEY4_RAW)
  978. # json (bytes)
  979. otp = from_source(b'{"v": 1, "type": "totp", "key": "JBSWY3DPEHPK3PXP"}')
  980. self.assertEqual(otp.key, KEY4_RAW)
  981. # TOTP object -- return unchanged
  982. self.assertIs(from_source(otp), otp)
  983. # TOTP object w/ different wallet -- return new one.
  984. wallet1 = AppWallet()
  985. otp1 = TOTP.using(wallet=wallet1).from_source(otp)
  986. self.assertIsNot(otp1, otp)
  987. self.assertEqual(otp1.to_dict(), otp.to_dict())
  988. # TOTP object w/ same wallet -- return original
  989. otp2 = TOTP.using(wallet=wallet1).from_source(otp1)
  990. self.assertIs(otp2, otp1)
  991. # random string
  992. self.assertRaises(ValueError, from_source, u("foo"))
  993. self.assertRaises(ValueError, from_source, b"foo")
  994. #=============================================================================
  995. # uri serialization tests
  996. #=============================================================================
  997. def test_from_uri(self):
  998. """from_uri()"""
  999. from passlib.totp import TOTP
  1000. from_uri = TOTP.from_uri
  1001. # URIs from https://code.google.com/p/google-authenticator/wiki/KeyUriFormat
  1002. #--------------------------------------------------------------------------------
  1003. # canonical uri
  1004. #--------------------------------------------------------------------------------
  1005. otp = from_uri("otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&"
  1006. "issuer=Example")
  1007. self.assertIsInstance(otp, TOTP)
  1008. self.assertEqual(otp.key, KEY4_RAW)
  1009. self.assertEqual(otp.label, "alice@google.com")
  1010. self.assertEqual(otp.issuer, "Example")
  1011. self.assertEqual(otp.alg, "sha1") # default
  1012. self.assertEqual(otp.period, 30) # default
  1013. self.assertEqual(otp.digits, 6) # default
  1014. #--------------------------------------------------------------------------------
  1015. # secret param
  1016. #--------------------------------------------------------------------------------
  1017. # secret case insensitive
  1018. otp = from_uri("otpauth://totp/Example:alice@google.com?secret=jbswy3dpehpk3pxp&"
  1019. "issuer=Example")
  1020. self.assertEqual(otp.key, KEY4_RAW)
  1021. # missing secret
  1022. self.assertRaises(ValueError, from_uri, "otpauth://totp/Example:alice@google.com?digits=6")
  1023. # undecodable secret
  1024. self.assertRaises(Base32DecodeError, from_uri, "otpauth://totp/Example:alice@google.com?"
  1025. "secret=JBSWY3DPEHP@3PXP")
  1026. #--------------------------------------------------------------------------------
  1027. # label param
  1028. #--------------------------------------------------------------------------------
  1029. # w/ encoded space
  1030. otp = from_uri("otpauth://totp/Provider1:Alice%20Smith?secret=JBSWY3DPEHPK3PXP&"
  1031. "issuer=Provider1")
  1032. self.assertEqual(otp.label, "Alice Smith")
  1033. self.assertEqual(otp.issuer, "Provider1")
  1034. # w/ encoded space and colon
  1035. # (note url has leading space before 'alice') -- taken from KeyURI spec
  1036. otp = from_uri("otpauth://totp/Big%20Corporation%3A%20alice@bigco.com?"
  1037. "secret=JBSWY3DPEHPK3PXP")
  1038. self.assertEqual(otp.label, "alice@bigco.com")
  1039. self.assertEqual(otp.issuer, "Big Corporation")
  1040. #--------------------------------------------------------------------------------
  1041. # issuer param / prefix
  1042. #--------------------------------------------------------------------------------
  1043. # 'new style' issuer only
  1044. otp = from_uri("otpauth://totp/alice@bigco.com?secret=JBSWY3DPEHPK3PXP&issuer=Big%20Corporation")
  1045. self.assertEqual(otp.label, "alice@bigco.com")
  1046. self.assertEqual(otp.issuer, "Big Corporation")
  1047. # new-vs-old issuer mismatch
  1048. self.assertRaises(ValueError, TOTP.from_uri,
  1049. "otpauth://totp/Provider1:alice?secret=JBSWY3DPEHPK3PXP&issuer=Provider2")
  1050. #--------------------------------------------------------------------------------
  1051. # algorithm param
  1052. #--------------------------------------------------------------------------------
  1053. # custom alg
  1054. otp = from_uri("otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&algorithm=SHA256")
  1055. self.assertEqual(otp.alg, "sha256")
  1056. # unknown alg
  1057. self.assertRaises(ValueError, from_uri, "otpauth://totp/Example:alice@google.com?"
  1058. "secret=JBSWY3DPEHPK3PXP&algorithm=SHA333")
  1059. #--------------------------------------------------------------------------------
  1060. # digit param
  1061. #--------------------------------------------------------------------------------
  1062. # custom digits
  1063. otp = from_uri("otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&digits=8")
  1064. self.assertEqual(otp.digits, 8)
  1065. # digits out of range / invalid
  1066. self.assertRaises(ValueError, from_uri, "otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&digits=A")
  1067. self.assertRaises(ValueError, from_uri, "otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&digits=%20")
  1068. self.assertRaises(ValueError, from_uri, "otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&digits=15")
  1069. #--------------------------------------------------------------------------------
  1070. # period param
  1071. #--------------------------------------------------------------------------------
  1072. # custom period
  1073. otp = from_uri("otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&period=63")
  1074. self.assertEqual(otp.period, 63)
  1075. # reject period < 1
  1076. self.assertRaises(ValueError, from_uri, "otpauth://totp/Example:alice@google.com?"
  1077. "secret=JBSWY3DPEHPK3PXP&period=0")
  1078. self.assertRaises(ValueError, from_uri, "otpauth://totp/Example:alice@google.com?"
  1079. "secret=JBSWY3DPEHPK3PXP&period=-1")
  1080. #--------------------------------------------------------------------------------
  1081. # unrecognized param
  1082. #--------------------------------------------------------------------------------
  1083. # should issue warning, but otherwise ignore extra param
  1084. with self.assertWarningList([
  1085. dict(category=exc.PasslibRuntimeWarning, message_re="unexpected parameters encountered")
  1086. ]):
  1087. otp = from_uri("otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&"
  1088. "foo=bar&period=63")
  1089. self.assertEqual(otp.base32_key, KEY4)
  1090. self.assertEqual(otp.period, 63)
  1091. def test_to_uri(self):
  1092. """to_uri()"""
  1093. #-------------------------------------------------------------------------
  1094. # label & issuer parameters
  1095. #-------------------------------------------------------------------------
  1096. # with label & issuer
  1097. otp = TOTP(KEY4, alg="sha1", digits=6, period=30)
  1098. self.assertEqual(otp.to_uri("alice@google.com", "Example Org"),
  1099. "otpauth://totp/Example%20Org:alice@google.com?secret=JBSWY3DPEHPK3PXP&"
  1100. "issuer=Example%20Org")
  1101. # label is required
  1102. self.assertRaises(ValueError, otp.to_uri, None, "Example Org")
  1103. # with label only
  1104. self.assertEqual(otp.to_uri("alice@google.com"),
  1105. "otpauth://totp/alice@google.com?secret=JBSWY3DPEHPK3PXP")
  1106. # with default label from constructor
  1107. otp.label = "alice@google.com"
  1108. self.assertEqual(otp.to_uri(),
  1109. "otpauth://totp/alice@google.com?secret=JBSWY3DPEHPK3PXP")
  1110. # with default label & default issuer from constructor
  1111. otp.issuer = "Example Org"
  1112. self.assertEqual(otp.to_uri(),
  1113. "otpauth://totp/Example%20Org:alice@google.com?secret=JBSWY3DPEHPK3PXP"
  1114. "&issuer=Example%20Org")
  1115. # reject invalid label
  1116. self.assertRaises(ValueError, otp.to_uri, "label:with:semicolons")
  1117. # reject invalid issuer
  1118. self.assertRaises(ValueError, otp.to_uri, "alice@google.com", "issuer:with:semicolons")
  1119. #-------------------------------------------------------------------------
  1120. # algorithm parameter
  1121. #-------------------------------------------------------------------------
  1122. self.assertEqual(TOTP(KEY4, alg="sha256").to_uri("alice@google.com"),
  1123. "otpauth://totp/alice@google.com?secret=JBSWY3DPEHPK3PXP&"
  1124. "algorithm=SHA256")
  1125. #-------------------------------------------------------------------------
  1126. # digits parameter
  1127. #-------------------------------------------------------------------------
  1128. self.assertEqual(TOTP(KEY4, digits=8).to_uri("alice@google.com"),
  1129. "otpauth://totp/alice@google.com?secret=JBSWY3DPEHPK3PXP&"
  1130. "digits=8")
  1131. #-------------------------------------------------------------------------
  1132. # period parameter
  1133. #-------------------------------------------------------------------------
  1134. self.assertEqual(TOTP(KEY4, period=63).to_uri("alice@google.com"),
  1135. "otpauth://totp/alice@google.com?secret=JBSWY3DPEHPK3PXP&"
  1136. "period=63")
  1137. #=============================================================================
  1138. # dict serialization tests
  1139. #=============================================================================
  1140. def test_from_dict(self):
  1141. """from_dict()"""
  1142. from passlib.totp import TOTP
  1143. from_dict = TOTP.from_dict
  1144. #--------------------------------------------------------------------------------
  1145. # canonical simple example
  1146. #--------------------------------------------------------------------------------
  1147. otp = from_dict(dict(v=1, type="totp", key=KEY4, label="alice@google.com", issuer="Example"))
  1148. self.assertIsInstance(otp, TOTP)
  1149. self.assertEqual(otp.key, KEY4_RAW)
  1150. self.assertEqual(otp.label, "alice@google.com")
  1151. self.assertEqual(otp.issuer, "Example")
  1152. self.assertEqual(otp.alg, "sha1") # default
  1153. self.assertEqual(otp.period, 30) # default
  1154. self.assertEqual(otp.digits, 6) # default
  1155. #--------------------------------------------------------------------------------
  1156. # metadata
  1157. #--------------------------------------------------------------------------------
  1158. # missing version
  1159. self.assertRaises(ValueError, from_dict, dict(type="totp", key=KEY4))
  1160. # invalid version
  1161. self.assertRaises(ValueError, from_dict, dict(v=0, type="totp", key=KEY4))
  1162. self.assertRaises(ValueError, from_dict, dict(v=999, type="totp", key=KEY4))
  1163. # missing type
  1164. self.assertRaises(ValueError, from_dict, dict(v=1, key=KEY4))
  1165. #--------------------------------------------------------------------------------
  1166. # secret param
  1167. #--------------------------------------------------------------------------------
  1168. # secret case insensitive
  1169. otp = from_dict(dict(v=1, type="totp", key=KEY4.lower(), label="alice@google.com", issuer="Example"))
  1170. self.assertEqual(otp.key, KEY4_RAW)
  1171. # missing secret
  1172. self.assertRaises(ValueError, from_dict, dict(v=1, type="totp"))
  1173. # undecodable secret
  1174. self.assertRaises(Base32DecodeError, from_dict,
  1175. dict(v=1, type="totp", key="JBSWY3DPEHP@3PXP"))
  1176. #--------------------------------------------------------------------------------
  1177. # label & issuer params
  1178. #--------------------------------------------------------------------------------
  1179. otp = from_dict(dict(v=1, type="totp", key=KEY4, label="Alice Smith", issuer="Provider1"))
  1180. self.assertEqual(otp.label, "Alice Smith")
  1181. self.assertEqual(otp.issuer, "Provider1")
  1182. #--------------------------------------------------------------------------------
  1183. # algorithm param
  1184. #--------------------------------------------------------------------------------
  1185. # custom alg
  1186. otp = from_dict(dict(v=1, type="totp", key=KEY4, alg="sha256"))
  1187. self.assertEqual(otp.alg, "sha256")
  1188. # unknown alg
  1189. self.assertRaises(ValueError, from_dict, dict(v=1, type="totp", key=KEY4, alg="sha333"))
  1190. #--------------------------------------------------------------------------------
  1191. # digit param
  1192. #--------------------------------------------------------------------------------
  1193. # custom digits
  1194. otp = from_dict(dict(v=1, type="totp", key=KEY4, digits=8))
  1195. self.assertEqual(otp.digits, 8)
  1196. # digits out of range / invalid
  1197. self.assertRaises(TypeError, from_dict, dict(v=1, type="totp", key=KEY4, digits="A"))
  1198. self.assertRaises(ValueError, from_dict, dict(v=1, type="totp", key=KEY4, digits=15))
  1199. #--------------------------------------------------------------------------------
  1200. # period param
  1201. #--------------------------------------------------------------------------------
  1202. # custom period
  1203. otp = from_dict(dict(v=1, type="totp", key=KEY4, period=63))
  1204. self.assertEqual(otp.period, 63)
  1205. # reject period < 1
  1206. self.assertRaises(ValueError, from_dict, dict(v=1, type="totp", key=KEY4, period=0))
  1207. self.assertRaises(ValueError, from_dict, dict(v=1, type="totp", key=KEY4, period=-1))
  1208. #--------------------------------------------------------------------------------
  1209. # unrecognized param
  1210. #--------------------------------------------------------------------------------
  1211. self.assertRaises(TypeError, from_dict, dict(v=1, type="totp", key=KEY4, INVALID=123))
  1212. def test_to_dict(self):
  1213. """to_dict()"""
  1214. #-------------------------------------------------------------------------
  1215. # label & issuer parameters
  1216. #-------------------------------------------------------------------------
  1217. # without label or issuer
  1218. otp = TOTP(KEY4, alg="sha1", digits=6, period=30)
  1219. self.assertEqual(otp.to_dict(), dict(v=1, type="totp", key=KEY4))
  1220. # with label & issuer from constructor
  1221. otp = TOTP(KEY4, alg="sha1", digits=6, period=30,
  1222. label="alice@google.com", issuer="Example Org")
  1223. self.assertEqual(otp.to_dict(),
  1224. dict(v=1, type="totp", key=KEY4,
  1225. label="alice@google.com", issuer="Example Org"))
  1226. # with label only
  1227. otp = TOTP(KEY4, alg="sha1", digits=6, period=30,
  1228. label="alice@google.com")
  1229. self.assertEqual(otp.to_dict(),
  1230. dict(v=1, type="totp", key=KEY4,
  1231. label="alice@google.com"))
  1232. # with issuer only
  1233. otp = TOTP(KEY4, alg="sha1", digits=6, period=30,
  1234. issuer="Example Org")
  1235. self.assertEqual(otp.to_dict(),
  1236. dict(v=1, type="totp", key=KEY4,
  1237. issuer="Example Org"))
  1238. # don't serialize default issuer
  1239. TotpFactory = TOTP.using(issuer="Example Org")
  1240. otp = TotpFactory(KEY4)
  1241. self.assertEqual(otp.to_dict(), dict(v=1, type="totp", key=KEY4))
  1242. # don't serialize default issuer *even if explicitly set*
  1243. otp = TotpFactory(KEY4, issuer="Example Org")
  1244. self.assertEqual(otp.to_dict(), dict(v=1, type="totp", key=KEY4))
  1245. #-------------------------------------------------------------------------
  1246. # algorithm parameter
  1247. #-------------------------------------------------------------------------
  1248. self.assertEqual(TOTP(KEY4, alg="sha256").to_dict(),
  1249. dict(v=1, type="totp", key=KEY4, alg="sha256"))
  1250. #-------------------------------------------------------------------------
  1251. # digits parameter
  1252. #-------------------------------------------------------------------------
  1253. self.assertEqual(TOTP(KEY4, digits=8).to_dict(),
  1254. dict(v=1, type="totp", key=KEY4, digits=8))
  1255. #-------------------------------------------------------------------------
  1256. # period parameter
  1257. #-------------------------------------------------------------------------
  1258. self.assertEqual(TOTP(KEY4, period=63).to_dict(),
  1259. dict(v=1, type="totp", key=KEY4, period=63))
  1260. # TODO: to_dict()
  1261. # with encrypt=False
  1262. # with encrypt="auto" + wallet + secrets
  1263. # with encrypt="auto" + wallet + no secrets
  1264. # with encrypt="auto" + no wallet
  1265. # with encrypt=True + wallet + secrets
  1266. # with encrypt=True + wallet + no secrets
  1267. # with encrypt=True + no wallet
  1268. # that 'changed' is set for old versions, and old encryption tags.
  1269. #=============================================================================
  1270. # json serialization tests
  1271. #=============================================================================
  1272. # TODO: from_json() / to_json().
  1273. # (skipped for right now cause just wrapper for from_dict/to_dict)
  1274. #=============================================================================
  1275. # eoc
  1276. #=============================================================================
  1277. #=============================================================================
  1278. # eof
  1279. #=============================================================================