]> granicus.if.org Git - python/commitdiff
Add secrets module and tests.
authorSteven D'Aprano <steve@pearwood.info>
Thu, 14 Apr 2016 15:51:31 +0000 (01:51 +1000)
committerSteven D'Aprano <steve@pearwood.info>
Thu, 14 Apr 2016 15:51:31 +0000 (01:51 +1000)
Lib/secrets.py [new file with mode: 0644]
Lib/test/test_secrets.py [new file with mode: 0644]

diff --git a/Lib/secrets.py b/Lib/secrets.py
new file mode 100644 (file)
index 0000000..ed018ad
--- /dev/null
@@ -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 (file)
index 0000000..a3d1a8c
--- /dev/null
@@ -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()