Commit 95702725 authored by Steven D'Aprano's avatar Steven D'Aprano

Add secrets module and tests.

parent 15d2d49c
"""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')
"""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()
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment