Code

Ticket #12417: ticket12417.diff

File ticket12417.diff, 28.1 KB (added by simon, 5 years ago)
Line 
1diff --git a/django/conf/global_settings.py b/django/conf/global_settings.py
2index 828aef5..87a721b 100644
3--- a/django/conf/global_settings.py
4+++ b/django/conf/global_settings.py
5@@ -383,6 +383,12 @@ LOGIN_REDIRECT_URL = '/accounts/profile/'
6 # The number of days a password reset link is valid for
7 PASSWORD_RESET_TIMEOUT_DAYS = 3
8 
9+##################
10+# SIGNED COOKIES #
11+##################
12+
13+COOKIE_SIGNER_BACKEND = 'django.utils.signed.TimestampSigner'
14+
15 ########
16 # CSRF #
17 ########
18diff --git a/django/http/__init__.py b/django/http/__init__.py
19index 7b0c469..351d1a0 100644
20--- a/django/http/__init__.py
21+++ b/django/http/__init__.py
22@@ -11,11 +11,13 @@ except ImportError:
23     from cgi import parse_qsl
24 
25 from django.utils.datastructures import MultiValueDict, ImmutableList
26+from django.utils.importlib import import_module
27 from django.utils.encoding import smart_str, iri_to_uri, force_unicode
28 from django.http.multipartparser import MultiPartParser
29 from django.conf import settings
30 from django.core.files import uploadhandler
31 from utils import *
32+from django.utils import signed
33 
34 RESERVED_CHARS="!*'();:@&=+$,/?%#[]"
35 
36@@ -24,6 +26,25 @@ absolute_http_url_re = re.compile(r"^https?://", re.I)
37 class Http404(Exception):
38     pass
39 
40+def get_cookie_signer():
41+    modpath = settings.COOKIE_SIGNER_BACKEND
42+    module, attr = modpath.rsplit('.', 1)
43+    try:
44+        mod = import_module(module)
45+    except ImportError, e:
46+        raise ImproperlyConfigured(
47+            'Error importing cookie signer %s: "%s"' % (modpath, e)
48+        )
49+    try:
50+        Signer = getattr(mod, attr)
51+    except AttributeError, e:
52+        raise ImproperlyConfigured(
53+            'Error importing cookie signer %s: "%s"' % (modpath, e)
54+        )
55+    return Signer(settings.SECRET_KEY)
56+
57+raise_error = object()
58+
59 class HttpRequest(object):
60     """A basic HTTP request."""
61 
62@@ -60,6 +81,31 @@ class HttpRequest(object):
63     def get_full_path(self):
64         return ''
65 
66+    def get_signed_cookie(self, key, default=raise_error, salt = '',
67+            max_age=None):
68+        """
69+        Attempts to return a signed cookie. If the signature fails or the
70+        cookie has expired, raises an exception... unless you provide the
71+        default argument in which case that value will be returned instead.
72+        """
73+        try:
74+            cookie_value = self.COOKIES[key].encode('utf-8')
75+        except KeyError:
76+            if default is not raise_error:
77+                return default
78+            else:
79+                raise
80+        try:
81+            value = get_cookie_signer().unsign(
82+                cookie_value, salt = salt, max_age = max_age
83+            )
84+        except signed.BadSignature:
85+            if default is not raise_error:
86+                return default
87+            else:
88+                raise
89+        return value
90+
91     def build_absolute_uri(self, location=None):
92         """
93         Builds an absolute URI from the location and the variables available in
94@@ -357,6 +403,10 @@ class HttpResponse(object):
95         if secure:
96             self.cookies[key]['secure'] = True
97 
98+    def set_signed_cookie(self, key, value, salt = '', **kwargs):
99+        value = get_cookie_signer().sign(value, salt=salt)
100+        return self.set_cookie(key, value, **kwargs)
101+
102     def delete_cookie(self, key, path='/', domain=None):
103         self.set_cookie(key, max_age=0, path=path, domain=domain,
104                         expires='Thu, 01-Jan-1970 00:00:00 GMT')
105diff --git a/django/utils/baseconv.py b/django/utils/baseconv.py
106new file mode 100644
107index 0000000..f1d5aa0
108--- /dev/null
109+++ b/django/utils/baseconv.py
110@@ -0,0 +1,57 @@
111+"""
112+Convert numbers from base 10 integers to base X strings and back again.
113+
114+Sample usage:
115+
116+>>> base20 = BaseConverter('0123456789abcdefghij')
117+>>> base20.from_int(1234)
118+'31e'
119+>>> base20.to_int('31e')
120+1234
121+"""
122+
123+class BaseConverter(object):
124+    decimal_digits = "0123456789"
125+   
126+    def __init__(self, digits):
127+        self.digits = digits
128+   
129+    def from_int(self, i):
130+        return self.convert(i, self.decimal_digits, self.digits)
131+   
132+    def to_int(self, s):
133+        return int(self.convert(s, self.digits, self.decimal_digits))
134+   
135+    def convert(number, fromdigits, todigits):
136+        # Based on http://code.activestate.com/recipes/111286/
137+        if str(number)[0] == '-':
138+            number = str(number)[1:]
139+            neg = 1
140+        else:
141+            neg = 0
142+
143+        # make an integer out of the number
144+        x = 0
145+        for digit in str(number):
146+           x = x * len(fromdigits) + fromdigits.index(digit)
147+   
148+        # create the result in base 'len(todigits)'
149+        if x == 0:
150+            res = todigits[0]
151+        else:
152+            res = ""
153+            while x > 0:
154+                digit = x % len(todigits)
155+                res = todigits[digit] + res
156+                x = int(x / len(todigits))
157+            if neg:
158+                res = '-' + res
159+        return res
160+    convert = staticmethod(convert)
161+
162+base2 = BaseConverter('01')
163+base16 = BaseConverter('0123456789ABCDEF')
164+base36 = BaseConverter('0123456789abcdefghijklmnopqrstuvwxyz')
165+base62 = BaseConverter(
166+    '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
167+)
168diff --git a/django/utils/signed.py b/django/utils/signed.py
169new file mode 100644
170index 0000000..b31c0df
171--- /dev/null
172+++ b/django/utils/signed.py
173@@ -0,0 +1,159 @@
174+"""
175+Functions for creating and restoring url-safe signed JSON objects.
176+
177+The format used looks like this:
178+
179+>>> signed.dumps("hello")
180+'ImhlbGxvIg.RjVSUCt6S64WBilMYxG89-l0OA8'
181+
182+There are two components here, separatad by a '.'. The first component is a
183+URLsafe base64 encoded JSON of the object passed to dumps(). The second
184+component is a base64 encoded hmac/SHA1 hash of "$first_component.$secret"
185+
186+signed.loads(s) checks the signature and returns the deserialised object.
187+If the signature fails, a BadSignature exception is raised.
188+
189+>>> signed.loads("ImhlbGxvIg.RjVSUCt6S64WBilMYxG89-l0OA8")
190+u'hello'
191+>>> signed.loads("ImhlbGxvIg.RjVSUCt6S64WBilMYxG89-l0OA8-modified")
192+...
193+BadSignature: Signature failed: RjVSUCt6S64WBilMYxG89-l0OA8-modified
194+
195+You can optionally compress the JSON prior to base64 encoding it to save
196+space, using the compress=True argument. This checks if compression actually
197+helps and only applies compression if the result is a shorter string:
198+
199+>>> signed.dumps(range(1, 20), compress=True)
200+'.eJwFwcERACAIwLCF-rCiILN47r-GyZVJsNgkxaFxoDgxcOHGxMKD_T7vhAml.oFq6lAAEbkHXBHfGnVX7Qx6NlZ8'
201+
202+The fact that the string is compressed is signalled by the prefixed '.' at the
203+start of the base64 JSON.
204+
205+There are 65 url-safe characters: the 64 used by url-safe base64 and the '.'.
206+These functions make use of all of them.
207+"""
208+
209+from django.conf import settings
210+from django.utils.hashcompat import sha_constructor
211+from django.utils import baseconv, simplejson
212+from django.utils.encoding import force_unicode, smart_str
213+import hmac, base64, time
214+
215+def dumps(obj, key = None, compress = False, salt = ''):
216+    """
217+    Returns URL-safe, sha1 signed base64 compressed JSON string. If key is
218+    None, settings.SECRET_KEY is used instead.
219+   
220+    If compress is True (not the default) checks if compressing using zlib can
221+    save some space. Prepends a '.' to signify compression. This is included
222+    in the signature, to protect against zip bombs.
223+   
224+    salt can be used to further salt the hash, in case you're worried
225+    that the NSA might try to brute-force your SHA-1 protected secret.
226+    """
227+    json = simplejson.dumps(obj, separators=(',', ':'))
228+    is_compressed = False # Flag for if it's been compressed or not
229+    if compress:
230+        import zlib # Avoid zlib dependency unless compress is being used
231+        compressed = zlib.compress(json)
232+        if len(compressed) < (len(json) - 1):
233+            json = compressed
234+            is_compressed = True
235+    base64d = b64_encode(json).strip('=')
236+    if is_compressed:
237+        base64d = '.' + base64d
238+    return TimestampSigner(key).sign(base64d, salt=salt)
239+
240+def loads(s, key = None, salt = '', max_age=None):
241+    "Reverse of dumps(), raises BadSignature if signature fails"
242+    try:
243+        base64d = smart_str(TimestampSigner(key).unsign(
244+            s, salt=salt, max_age=max_age
245+        ))
246+    except BadSignature:
247+        raise
248+    decompress = False
249+    if base64d[0] == '.':
250+        # It's compressed; uncompress it first
251+        base64d = base64d[1:]
252+        decompress = True
253+    json = b64_decode(base64d)
254+    if decompress:
255+        import zlib
256+        jsond = zlib.decompress(json)
257+    return simplejson.loads(json)
258+
259+def b64_encode(s):
260+    return base64.urlsafe_b64encode(s).strip('=')
261+
262+def b64_decode(s):
263+    return base64.urlsafe_b64decode(s + '=' * (len(s) % 4))
264+
265+def base64_hmac(value, key):
266+    return b64_encode(
267+        (hmac.new(key, value, sha_constructor).digest())
268+    )
269+
270+class BadSignature(Exception):
271+    "Signature does not match"
272+    pass
273+
274+class SignatureExpired(BadSignature):
275+    "Signature timestamp is older than required max_age"
276+    pass
277+
278+class Signer(object):
279+    def __init__(self, key=None):
280+        self.key = key or settings.SECRET_KEY
281+
282+    def signature(self, value, salt=''):
283+        if salt:
284+            # sha1 the appended salt, to avoid hash extension attacks
285+            key = sha_constructor(self.key + salt).hexdigest()
286+        else:
287+            key = self.key
288+        return base64_hmac(value, key)
289+
290+    def sign(self, value, salt='', sep=':'):
291+        value = smart_str(value)
292+        return '%s%s%s' % (
293+            value, sep, self.signature(value, salt=salt)
294+        )
295+
296+    def unsign(self, signed_value, salt='', sep=':'):
297+        signed_value = smart_str(signed_value)
298+        if not sep in signed_value:
299+            raise BadSignature, "No '%s' found in value" % sep
300+        value, sig = signed_value.rsplit(sep, 1)
301+        expected = self.signature(value, salt=salt)
302+        if sig != expected:
303+            # Important: do NOT include the expected sig in the exception
304+            # message, since it might leak up to an attacker! Can we enforce
305+            # this in the Django debug templates?
306+            raise BadSignature, 'Signature "%s" does not match' % sig
307+        else:
308+            return force_unicode(value)
309+
310+class TimestampSigner(Signer):
311+    def timestamp(self):
312+        return baseconv.base62.from_int(int(time.time()))
313+
314+    def sign(self, value, salt='', sep=':'):
315+        value = smart_str('%s%s%s' % (value, sep, self.timestamp()))
316+        return '%s%s%s' % (
317+            value, sep, self.signature(value, salt=salt)
318+        )
319+
320+    def unsign(self, value, salt='', sep=':', max_age=None):
321+        value, timestamp = super(TimestampSigner, self).unsign(
322+            value, salt=salt, sep=sep
323+        ).rsplit(sep, 1)
324+        timestamp = baseconv.base62.to_int(timestamp)
325+        if max_age is not None:
326+            # Check timestamp is not older than max_age
327+            age = time.time() - timestamp
328+            if age > max_age:
329+                raise SignatureExpired, 'Signature age %s > %s seconds' % (
330+                    age, max_age
331+                )
332+        return value
333diff --git a/docs/index.txt b/docs/index.txt
334index cec1d76..41ec97e 100644
335--- a/docs/index.txt
336+++ b/docs/index.txt
337@@ -163,6 +163,7 @@ Other batteries included
338     * :ref:`Comments <ref-contrib-comments-index>` | :ref:`Moderation <ref-contrib-comments-moderation>` | :ref:`Custom comments <ref-contrib-comments-custom>`
339     * :ref:`Content types <ref-contrib-contenttypes>`
340     * :ref:`Cross Site Request Forgery protection <ref-contrib-csrf>`
341+    * :ref:`Cryptographic signing <topics-cryptographic-signing>`
342     * :ref:`Databrowse <ref-contrib-databrowse>`
343     * :ref:`E-mail (sending) <topics-email>`
344     * :ref:`Flatpages <ref-contrib-flatpages>`
345diff --git a/docs/ref/request-response.txt b/docs/ref/request-response.txt
346index 77d991b..28b4eaa 100644
347--- a/docs/ref/request-response.txt
348+++ b/docs/ref/request-response.txt
349@@ -221,6 +221,42 @@ Methods
350 
351    Example: ``"http://example.com/music/bands/the_beatles/?print=true"``
352 
353+.. method:: HttpRequest.get_signed_cookie(key, default=raise_error, salt = '', max_age=None)
354+
355+   .. versionadded:: 1.2
356+
357+   Returns a cookie value for a signed cookie, or raises a
358+   ``utils.BadSignature`` exception if the signature is no longer valid. If
359+   you provide the ``default`` argument the exception will be suppressed and
360+   that default value will be returned instead.
361+   
362+   The optional salt argument can be used to provide extra protection against
363+   brute force attacks on your secret key. If supplied, the max_age argument
364+   will be checked against the signed timestamp attached to the cookie value
365+   to ensure the cookie is no older than max_age seconds.
366+   
367+   For example::
368+   
369+          >>> request.get_signed_cookie('name')
370+          'Tony'
371+          >>> request.get_signed_cookie('name', salt='name-salt')
372+          'Tony' # assuming cookie was set using the same salt
373+          >>> request.get_signed_cookie('non-existing-cookie')
374+          ...
375+          KeyError: 'non-existing-cookie'
376+          >>> request.get_signed_cookie('non-existing-cookie', default=False)
377+          False
378+          >>> request.get_signed_cookie('cookie-that-was-tampered-with')
379+          ...
380+          BadSignature: ...
381+          >>> request.get_signed_cookie('name', max_age=60)
382+          ...
383+          SignatureExpired: Signature age 1677.3839159 > 60 seconds
384+          >>> request.get_signed_cookie('name', max_age=60, default=False)
385+          False
386+   
387+   See :ref:`cryptographic signing <topics-signing>` for more information.
388+
389 .. method:: HttpRequest.is_secure()
390 
391    Returns ``True`` if the request is secure; that is, if it was made with
392@@ -522,6 +558,14 @@ Methods
393 
394     .. _`cookie Morsel`: http://docs.python.org/library/cookie.html#Cookie.Morsel
395 
396+.. method:: HttpResponse.set_signed_cookie(key, value='', salt='', max_age=None, expires=None, path='/', domain=None, secure=None)
397+
398+    Like ``set_cookie()``, but :ref:`cryptographically signs <topics-signing>`
399+    the cookie before setting it. Use in conjunction with
400+    ``HttpRequest.get_signed_cookie``. You can use the optional ``salt``
401+    argument for added key strength, but you will need to remember to pass it
402+    to the corresponding ``get_signed_cookie`` call.
403+
404 .. method:: HttpResponse.delete_cookie(key, path='/', domain=None)
405 
406     Deletes the cookie with the given key. Fails silently if the key doesn't
407diff --git a/docs/topics/index.txt b/docs/topics/index.txt
408index 7fa283a..b80c101 100644
409--- a/docs/topics/index.txt
410+++ b/docs/topics/index.txt
411@@ -20,6 +20,7 @@ Introductions to all the key parts of Django you'll need to know:
412    auth
413    cache
414    conditional-view-processing
415+   signing
416    email
417    i18n
418    pagination
419diff --git a/docs/topics/signing.txt b/docs/topics/signing.txt
420new file mode 100644
421index 0000000..68d4a30
422--- /dev/null
423+++ b/docs/topics/signing.txt
424@@ -0,0 +1,128 @@
425+.. _topics-signing:
426+
427+=====================
428+Cryptographic signing
429+=====================
430+
431+.. versionadded:: 1.2
432+
433+The golden rule of web application security is to never trust data from
434+untrusted sources. Sometimes it can be useful to pass data through an
435+untrusted medium. Cryptographically signed values can be passed through an
436+untrusted channel safe in the knowledge that any tampering will be detected.
437+
438+Django provides both a low-level API for signing values and a high-level API
439+for setting and reading signed cookies, one of the most common uses of
440+signing in web applications.
441+
442+You may also find signing useful for the following:
443+
444+    * Generating "recover my account" URLs for sending to users who have
445+      lost their password.
446+
447+    * Ensuring data stored in hidden form fields has not been tampered with.
448+
449+    * Generating one-time secret URLs for allowing temporary access to a
450+      protected resource, for example a downloadable file that a user has
451+      paid for.
452+
453+Protecting the SECRET_KEY
454+=========================
455+
456+When you create a new Django project using ``django-admin.py startproject``,
457+the ``settings.py`` file it generates automatically gets a random
458+``SECRET_KEY`` value. This value is the key to securing signed data - it is
459+vital you keep this secure, or attackers could use it to generate their own
460+signed values.
461+
462+Using the low-level API
463+=======================
464+
465+Django's signing methods live in the ``django.utils.signed module``. To
466+sign a value, first instantiate a ``Signer`` instance::
467+
468+    >>> from django.utils.signed import Signer
469+    >>> signer = Signer()
470+    >>> value = signer.sign('My string')
471+    >>> value
472+    'My string:GdMGD6HNQ_qdgxYP8yBZAdAIV1w'
473+
474+The signature is appended to the end of the string, following the colon. You
475+can retrieve the original value using the ``unsign`` method::
476+
477+    >>> original = signer.unsign(value)
478+    >>> original
479+    u'My string'
480+
481+If the signature or value have been altered in any way, a
482+``signed.BadSigature`` exception will be raised::
483+
484+    >>> value += 'm'
485+    >>> try:
486+    ...    original = signer.unsign(value)
487+    ... except signed.BadSignature:
488+    ...    print "Tampering detected!"
489+
490+By default, the ``Signer`` class uses your project's ``SECRET_KEY`` setting to
491+generate signatures. You can use a different secret by passing it to the
492+``Signer`` constructor::
493+
494+    >>> signer = Signer('my-other-secret')
495+    >>> value = signer.sign('My string')
496+    >>> value
497+    'My string:EkfQJafvGyiofrdGnuthdxImIJw'
498+
499+Using the salt argument
500+-----------------------
501+
502+If you do not wish to use the same key for every signing operation in your
503+application, you can use the optional ``salt`` argument to the ``sign`` and
504+``unsign`` methods to further strengthen your ``SECRET_KEY`` against brute
505+force attacks. Using a salt will cause a new key to be derived from both the
506+salt and your ``SECRET_KEY``::
507+
508+    >>> signer = Signer()
509+    >>> signer.sign('My string')
510+    'My string:GdMGD6HNQ_qdgxYP8yBZAdAIV1w'
511+    >>> signer.sign('My string', salt='extra')
512+    'My string:Ee7vGi-ING6n02gkcJ-QLHg6vFw'
513+    >>> signer.unsign('My string:Ee7vGi-ING6n02gkcJ-QLHg6vFw', salt='extra')
514+    u'My string'
515+
516+Unlike your ``SECRET_KEY``, your salt argument does not need to stay secret.
517+
518+Verifying timestamped values
519+----------------------------
520+
521+``TimestampSigner`` is a subclass of ``Signer`` that appends a signed
522+timestamp to the value. This allows you to confirm that a signed value was
523+created within a specified period of time::
524+
525+    >>> from django.utils.signed import TimestampSigner
526+    >>> signer = TimestampSigner()
527+    >>> value = signer.sign('hello')
528+    >>> value
529+    'hello:1NMg5H:oPVuCqlJWmChm1rA2lyTUtelC-c'
530+    >>> signer.unsign(value)
531+    u'hello'
532+    >>> signer.unsign(value, max_age=10)
533+    ...
534+    SignatureExpired: Signature age 15.5289158821 > 10 seconds
535+    >>> signer.unsign(value, max_age=20)
536+    u'hello'
537+
538+Protecting complex data structures
539+----------------------------------
540+
541+If you wish to protect a list, tuple or dictionary you can do so using the
542+signed module's dumps and loads functions. These imitate Python's pickle
543+module, but use JSON serialization under the hood. JSON ensures that even if
544+your ``SECRET_KEY`` is stolen an attacker will not be able to execute
545+arbitrary commands by exploiting the pickle format.::
546+
547+    >>> from django.utils import signed
548+    >>> value = signed.dumps({"foo": "bar"})
549+    >>> value
550+    'eyJmb28iOiJiYXIifQ:1NMg1b:zGcDE4-TCkaeGzLeW9UQwZesciI'
551+    >>> signed.loads(value)
552+    {'foo': 'bar'}
553diff --git a/tests/regressiontests/signed_cookie_tests/__init__.py b/tests/regressiontests/signed_cookie_tests/__init__.py
554new file mode 100644
555index 0000000..e69de29
556diff --git a/tests/regressiontests/signed_cookie_tests/models.py b/tests/regressiontests/signed_cookie_tests/models.py
557new file mode 100644
558index 0000000..71abcc5
559--- /dev/null
560+++ b/tests/regressiontests/signed_cookie_tests/models.py
561@@ -0,0 +1 @@
562+# models.py file for tests to run.
563diff --git a/tests/regressiontests/signed_cookie_tests/tests.py b/tests/regressiontests/signed_cookie_tests/tests.py
564new file mode 100644
565index 0000000..cccc222
566--- /dev/null
567+++ b/tests/regressiontests/signed_cookie_tests/tests.py
568@@ -0,0 +1,66 @@
569+from django.test import TestCase
570+from django.http import HttpRequest, HttpResponse
571+from django.utils import signed
572+import time
573+
574+class SignedCookieTest(TestCase):
575+
576+    def test_can_set_and_read_signed_cookies(self):
577+        response = HttpResponse()
578+        response.set_signed_cookie('c', 'hello')
579+        self.assert_('c' in response.cookies)
580+        self.assert_(response.cookies['c'].value.startswith('hello:'))
581+        request = HttpRequest()
582+        request.COOKIES['c'] = response.cookies['c'].value
583+        value = request.get_signed_cookie('c')
584+        self.assertEqual(value, u'hello')
585+
586+    def test_can_use_salt(self):
587+        response = HttpResponse()
588+        response.set_signed_cookie('a', 'hello', salt='one')
589+        request = HttpRequest()
590+        request.COOKIES['a'] = response.cookies['a'].value
591+        value = request.get_signed_cookie('a', salt='one')
592+        self.assertEqual(value, u'hello')
593+        self.assertRaises(signed.BadSignature,
594+            request.get_signed_cookie, 'a', salt='two'
595+        )
596+
597+    def test_detects_tampering(self):
598+        response = HttpResponse()
599+        response.set_signed_cookie('c', 'hello')
600+        request = HttpRequest()
601+        request.COOKIES['c'] = response.cookies['c'].value[:-2] + '$$'
602+        self.assertRaises(signed.BadSignature,
603+            request.get_signed_cookie, 'c'
604+        )
605+
606+    def test_default_argument_supresses_exceptions(self):
607+        response = HttpResponse()
608+        response.set_signed_cookie('c', 'hello')
609+        request = HttpRequest()
610+        request.COOKIES['c'] = response.cookies['c'].value[:-2] + '$$'
611+        self.assertEqual(request.get_signed_cookie('c', default=None), None)
612+
613+    def test_max_age_argument(self):
614+        old_time = time.time
615+        time.time = lambda: 123456789
616+        v = u'hello'
617+        try:
618+            response = HttpResponse()
619+            response.set_signed_cookie('c', v)
620+            request = HttpRequest()
621+            request.COOKIES['c'] = response.cookies['c'].value
622+
623+            self.assertEqual(request.get_signed_cookie('c'), v)
624+
625+            time.time = lambda: 123456800
626+
627+            self.assertEqual(request.get_signed_cookie('c', max_age=12), v)
628+            self.assertEqual(request.get_signed_cookie('c', max_age=11), v)
629+            self.assertRaises(
630+                signed.SignatureExpired, request.get_signed_cookie, 'c',
631+                max_age = 10
632+            )
633+        finally:
634+            time.time = old_time
635diff --git a/tests/regressiontests/utils/baseconv.py b/tests/regressiontests/utils/baseconv.py
636new file mode 100644
637index 0000000..d149937
638--- /dev/null
639+++ b/tests/regressiontests/utils/baseconv.py
640@@ -0,0 +1,13 @@
641+from unittest import TestCase
642+from django.utils.baseconv import base2, base16, base36, base62
643+
644+class TestBaseConv(TestCase):
645+   
646+    def test_baseconv(self):
647+        nums = [-10 ** 10, 10 ** 10] + range(-100, 100)
648+        for convertor in [base2, base16, base36, base62]:
649+            for i in nums:
650+                self.assertEqual(
651+                    i, convertor.to_int(convertor.from_int(i))
652+                )
653+
654diff --git a/tests/regressiontests/utils/signed.py b/tests/regressiontests/utils/signed.py
655new file mode 100644
656index 0000000..9bf539e
657--- /dev/null
658+++ b/tests/regressiontests/utils/signed.py
659@@ -0,0 +1,120 @@
660+from unittest import TestCase
661+from django.utils import signed
662+from django.utils.hashcompat import sha_constructor
663+from django.utils.encoding import force_unicode
664+import time
665+
666+class TestSigner(TestCase):
667+
668+    def test_signature(self):
669+        "signature() method should generate a signature"
670+        signer = signed.Signer('predictable-secret')
671+        signer2 = signed.Signer('predictable-secret2')
672+        for s in (
673+            'hello',
674+            '3098247:529:087:',
675+            u'\u2019'.encode('utf8'),
676+        ):
677+            self.assertEqual(
678+                signer.signature(s),
679+                signed.base64_hmac(s, 'predictable-secret')
680+            )
681+            self.assertNotEqual(signer.signature(s), signer2.signature(s))
682+
683+    def test_signature_with_salt(self):
684+        "signature(value, salt=...) should work"
685+        signer = signed.Signer('predictable-secret')
686+        self.assertEqual(
687+            signer.signature('hello', salt='extra-salt'),
688+            signed.base64_hmac('hello', sha_constructor(
689+                'predictable-secret' + 'extra-salt'
690+            ).hexdigest())
691+        )
692+        self.assertNotEqual(
693+            signer.signature('hello', salt='one'),
694+            signer.signature('hello', salt='two'),
695+        )
696+   
697+    def test_sign_unsign(self):
698+        "sign/unsign should be reversible"
699+        signer = signed.Signer('predictable-secret')
700+        examples = (
701+            'q;wjmbk;wkmb',
702+            '3098247529087',
703+            '3098247:529:087:',
704+            'jkw osanteuh ,rcuh nthu aou oauh ,ud du',
705+            u'\u2019',
706+        )
707+        for example in examples:
708+            self.assert_(
709+                force_unicode(example) != force_unicode(signer.sign(example))
710+            )
711+            self.assertEqual(example, signer.unsign(signer.sign(example)))
712+
713+    def unsign_detects_tampering(self):
714+        "unsign should raise an exception if the value has been tampered with"
715+        signer = signed.Signer('predictable-secret')
716+        value = 'Another string'
717+        signed_value = signer.sign(value)
718+        transforms = (
719+            lambda s: s.upper(),
720+            lambda s: s + 'a',
721+            lambda s: 'a' + s[1:],
722+            lambda s: s.replace(':', ''),
723+        )
724+        self.assertEqual(value, signer.unsign(signed_value))
725+        for transform in transforms:
726+            self.assertRaises(
727+                signed.BadSignature, signer.unsign, transform(signed_value)
728+            )
729+
730+    def test_dumps_loads(self):
731+        "dumps and loads be reversible for any JSON serializable object"
732+        objects = (
733+            ['a', 'list'],
734+            'a string',
735+            u'a unicode string \u2019',
736+            {'a': 'dictionary'},
737+        )
738+        for o in objects:
739+            self.assert_(o != signed.dumps(o))
740+            self.assertEqual(o, signed.loads(signed.dumps(o)))
741+
742+    def test_decode_detects_tampering(self):
743+        "loads should raise exception for tampered objects"
744+        transforms = (
745+            lambda s: s.upper(),
746+            lambda s: s + 'a',
747+            lambda s: 'a' + s[1:],
748+            lambda s: s.replace(':', ''),
749+        )
750+        value = {'foo': 'bar', 'baz': 1}
751+        encoded = signed.dumps(value)
752+        self.assertEqual(value, signed.loads(encoded))
753+        for transform in transforms:
754+            self.assertRaises(
755+                signed.BadSignature, signed.loads, transform(encoded)
756+            )
757+
758+class TestTimestampSigner(TestCase):
759+
760+    def test_timestamp_signer(self):
761+        old_time = time.time
762+        time.time = lambda: 123456789
763+        try:
764+            signer = signed.TimestampSigner('predictable-key')
765+            v = u'hello'
766+            ts = signer.sign(v)
767+            self.assertNotEqual(ts,signed.Signer('predictable-key').sign(v))
768+
769+            self.assertEqual(signer.unsign(ts), v)
770+
771+            time.time = lambda: 123456800
772+
773+            self.assertEqual(signer.unsign(ts, max_age=12), v)
774+            self.assertEqual(signer.unsign(ts, max_age=11), v)
775+            self.assertRaises(
776+                signed.SignatureExpired, signer.unsign, ts, max_age=10
777+            )
778+        finally:
779+            time.time = old_time
780diff --git a/tests/regressiontests/utils/tests.py b/tests/regressiontests/utils/tests.py
781index 6258b81..6c6dee0 100644
782--- a/tests/regressiontests/utils/tests.py
783+++ b/tests/regressiontests/utils/tests.py
784@@ -11,6 +11,8 @@ import timesince
785 import datastructures
786 import dateformat
787 import itercompat
788+from baseconv import TestBaseConv
789+from signed import TestSigner, TestTimestampSigner
790 from decorators import DecoratorFromMiddlewareTests
791 
792 # We need this because "datastructures" uses sorted() and the tests are run in