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