From 95702725ff7ce93c332757fec6ba00a59f5ded94 Mon Sep 17 00:00:00 2001 From: Steven D'Aprano Date: Fri, 15 Apr 2016 01:51:31 +1000 Subject: [PATCH] Add secrets module and tests. --- Lib/secrets.py | 149 +++++++++++++++++++++++++++++++++++++++ Lib/test/test_secrets.py | 120 +++++++++++++++++++++++++++++++ 2 files changed, 269 insertions(+) create mode 100644 Lib/secrets.py create mode 100644 Lib/test/test_secrets.py diff --git a/Lib/secrets.py b/Lib/secrets.py new file mode 100644 index 00000000000..ed018ad5412 --- /dev/null +++ b/Lib/secrets.py @@ -0,0 +1,149 @@ +"""Generate cryptographically strong pseudo-random numbers suitable for +managing secrets such as account authentication, tokens, and similar. +See PEP 506 for more information. + +https://www.python.org/dev/peps/pep-0506/ + + +Random numbers +============== + +The ``secrets`` module provides the following pseudo-random functions, based +on SystemRandom, which in turn uses the most secure source of randomness your +operating system provides. + + + choice(sequence) + Choose a random element from a non-empty sequence. + + randbelow(n) + Return a random int in the range [0, n). + + randbits(k) + Generates an int with k random bits. + + SystemRandom + Class for generating random numbers using sources provided by + the operating system. See the ``random`` module for documentation. + + +Token functions +=============== + +The ``secrets`` module provides a number of functions for generating secure +tokens, suitable for applications such as password resets, hard-to-guess +URLs, and similar. All the ``token_*`` functions take an optional single +argument specifying the number of bytes of randomness to use. If that is +not given, or is ``None``, a reasonable default is used. That default is +subject to change at any time, including during maintenance releases. + + + token_bytes(nbytes=None) + Return a random byte-string containing ``nbytes`` number of bytes. + + >>> secrets.token_bytes(16) #doctest:+SKIP + b'\\xebr\\x17D*t\\xae\\xd4\\xe3S\\xb6\\xe2\\xebP1\\x8b' + + + token_hex(nbytes=None) + Return a random text-string, in hexadecimal. The string has ``nbytes`` + random bytes, each byte converted to two hex digits. + + >>> secrets.token_hex(16) #doctest:+SKIP + 'f9bf78b9a18ce6d46a0cd2b0b86df9da' + + token_urlsafe(nbytes=None) + Return a random URL-safe text-string, containing ``nbytes`` random + bytes. On average, each byte results in approximately 1.3 characters + in the final result. + + >>> secrets.token_urlsafe(16) #doctest:+SKIP + 'Drmhze6EPcv0fN_81Bj-nA' + + +(The examples above assume Python 3. In Python 2, byte-strings will display +using regular quotes ``''`` with no prefix, and text-strings will have a +``u`` prefix.) + + +Other functions +=============== + + compare_digest(a, b) + Return True if strings a and b are equal, otherwise False. + Performs the equality comparison in such a way as to reduce the + risk of timing attacks. + + See http://codahale.com/a-lesson-in-timing-attacks/ for a + discussion on how timing attacks against ``==`` can reveal + secrets from your application. + + +""" + +__all__ = ['choice', 'randbelow', 'randbits', 'SystemRandom', + 'token_bytes', 'token_hex', 'token_urlsafe', + 'compare_digest', + ] + + +import base64 +import binascii +import os + +try: + from hmac import compare_digest +except ImportError: + # Python version is too old. Fall back to a pure-Python version. + + import operator + from functools import reduce + + def compare_digest(a, b): + """Return ``a == b`` using an approach resistant to timing analysis. + + a and b must both be of the same type: either both text strings, + or both byte strings. + + Note: If a and b are of different lengths, or if an error occurs, + a timing attack could theoretically reveal information about the + types and lengths of a and b, but not their values. + """ + # For a similar approach, see + # http://codahale.com/a-lesson-in-timing-attacks/ + for T in (bytes, str): + if isinstance(a, T) and isinstance(b, T): + break + else: # for...else + raise TypeError("arguments must be both strings or both bytes") + if len(a) != len(b): + return False + # Thanks to Raymond Hettinger for this one-liner. + return reduce(operator.and_, map(operator.eq, a, b), True) + + + +from random import SystemRandom + +_sysrand = SystemRandom() + +randbits = _sysrand.getrandbits +choice = _sysrand.choice + +def randbelow(exclusive_upper_bound): + return _sysrand._randbelow(exclusive_upper_bound) + +DEFAULT_ENTROPY = 32 # number of bytes to return by default + +def token_bytes(nbytes=None): + if nbytes is None: + nbytes = DEFAULT_ENTROPY + return os.urandom(nbytes) + +def token_hex(nbytes=None): + return binascii.hexlify(token_bytes(nbytes)).decode('ascii') + +def token_urlsafe(nbytes=None): + tok = token_bytes(nbytes) + return base64.urlsafe_b64encode(tok).rstrip(b'=').decode('ascii') + diff --git a/Lib/test/test_secrets.py b/Lib/test/test_secrets.py new file mode 100644 index 00000000000..a3d1a8cc10f --- /dev/null +++ b/Lib/test/test_secrets.py @@ -0,0 +1,120 @@ +"""Test the secrets module. + +As most of the functions in secrets are thin wrappers around functions +defined elsewhere, we don't need to test them exhaustively. +""" + + +import secrets +import unittest +import string + + +# === Unit tests === + +class Compare_Digest_Tests(unittest.TestCase): + """Test secrets.compare_digest function.""" + + def test_equal(self): + # Test compare_digest functionality with equal (byte/text) strings. + for s in ("a", "bcd", "xyz123"): + a = s*100 + b = s*100 + self.assertTrue(secrets.compare_digest(a, b)) + self.assertTrue(secrets.compare_digest(a.encode('utf-8'), b.encode('utf-8'))) + + def test_unequal(self): + # Test compare_digest functionality with unequal (byte/text) strings. + self.assertFalse(secrets.compare_digest("abc", "abcd")) + self.assertFalse(secrets.compare_digest(b"abc", b"abcd")) + for s in ("x", "mn", "a1b2c3"): + a = s*100 + "q" + b = s*100 + "k" + self.assertFalse(secrets.compare_digest(a, b)) + self.assertFalse(secrets.compare_digest(a.encode('utf-8'), b.encode('utf-8'))) + + def test_bad_types(self): + # Test that compare_digest raises with mixed types. + a = 'abcde' + b = a.encode('utf-8') + assert isinstance(a, str) + assert isinstance(b, bytes) + self.assertRaises(TypeError, secrets.compare_digest, a, b) + self.assertRaises(TypeError, secrets.compare_digest, b, a) + + def test_bool(self): + # Test that compare_digest returns a bool. + self.assertTrue(isinstance(secrets.compare_digest("abc", "abc"), bool)) + self.assertTrue(isinstance(secrets.compare_digest("abc", "xyz"), bool)) + + +class Random_Tests(unittest.TestCase): + """Test wrappers around SystemRandom methods.""" + + def test_randbits(self): + # Test randbits. + errmsg = "randbits(%d) returned %d" + for numbits in (3, 12, 30): + for i in range(6): + n = secrets.randbits(numbits) + self.assertTrue(0 <= n < 2**numbits, errmsg % (numbits, n)) + + def test_choice(self): + # Test choice. + items = [1, 2, 4, 8, 16, 32, 64] + for i in range(10): + self.assertTrue(secrets.choice(items) in items) + + def test_randbelow(self): + # Test randbelow. + errmsg = "randbelow(%d) returned %d" + for i in range(2, 10): + n = secrets.randbelow(i) + self.assertTrue(n in range(i), errmsg % (i, n)) + self.assertRaises(ValueError, secrets.randbelow, 0) + + +class Token_Tests(unittest.TestCase): + """Test token functions.""" + + def test_token_defaults(self): + # Test that token_* functions handle default size correctly. + for func in (secrets.token_bytes, secrets.token_hex, + secrets.token_urlsafe): + name = func.__name__ + try: + func() + except TypeError: + self.fail("%s cannot be called with no argument" % name) + try: + func(None) + except TypeError: + self.fail("%s cannot be called with None" % name) + size = secrets.DEFAULT_ENTROPY + self.assertEqual(len(secrets.token_bytes(None)), size) + self.assertEqual(len(secrets.token_hex(None)), 2*size) + + def test_token_bytes(self): + # Test token_bytes. + self.assertTrue(isinstance(secrets.token_bytes(11), bytes)) + for n in (1, 8, 17, 100): + self.assertEqual(len(secrets.token_bytes(n)), n) + + def test_token_hex(self): + # Test token_hex. + self.assertTrue(isinstance(secrets.token_hex(7), str)) + for n in (1, 12, 25, 90): + s = secrets.token_hex(n) + self.assertEqual(len(s), 2*n) + self.assertTrue(all(c in string.hexdigits for c in s)) + + def test_token_urlsafe(self): + # Test token_urlsafe. + self.assertTrue(isinstance(secrets.token_urlsafe(9), str)) + legal = string.ascii_letters + string.digits + '-_' + for n in (1, 11, 28, 76): + self.assertTrue(all(c in legal for c in secrets.token_urlsafe(n))) + + +if __name__ == '__main__': + unittest.main()